@colixsystems/widget-sdk 0.102.1 → 0.104.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
@@ -68,7 +68,32 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
68
68
 
69
69
  ## Status
70
70
 
71
- `v0.95.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**.
71
+ `v0.104.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**.
72
+
73
+ ### What's new in 0.104.0 (contract 1.79.0)
74
+
75
+ **Each corner can be rounded on its own — the `cornerRadius` property type.** A radius was a single number, so every rounded surface was rounded on all four corners: a card that meets the screen edge, a tab rounded only on top, or a bubble with one squared corner had no expression.
76
+
77
+ Declare `{ type: "cornerRadius", label: "Corner radius", validation: { min: 0, max: 48 } }` in your `propertySchema` or `styleSchema`. The Studio renders a slider with a typeable number that sets all four corners, plus a disclosure for setting each one. The authored value is `number | { topLeft, topRight, bottomRight, bottomLeft }` — the scalar form is unchanged, so every value stored before is still valid.
78
+
79
+ Resolve it with the two new exports rather than reading the raw value, because they hide the shape and spell the style props the way BOTH hosts accept:
80
+
81
+ ```js
82
+ import { normaliseCornerRadius, cornerRadiusStyle } from "@colixsystems/widget-sdk";
83
+
84
+ const radius = normaliseCornerRadius(style.radius, 0, 48);
85
+ return <View style={[styles.card, cornerRadiusStyle(radius)]} />;
86
+ ```
87
+
88
+ `normaliseCornerRadius(value, fallback, max)` returns all four corners resolved and clamped; `cornerRadiusStyle(radius, format)` emits the `borderRadius` shorthand when they agree and the four long-hand props when they differ (pass `n => `+"`${n}px`"+`` for the DOM). `isUniformCornerRadius` and `hasCornerRadius` round out the set. `CONTRACT.version` → `1.79.0`. Additive: every value accepted before is accepted now.
89
+
90
+ ### What's new in 0.103.0 (contract 1.78.0)
91
+
92
+ **An app-wide style value may have SHAPE — `widgetStyles` is no longer scalars-only.** Your `styleSchema` is offered in two places: the widget editor (per placed instance) and Theme Settings (app-wide, under your widget's own name). A field holding a structured value — an overlay object, a list of ids — persisted in the first and was silently dropped by the second, so the same edit behaved two ways depending on where the author made it.
93
+
94
+ `normaliseWidgetStyles` now carries objects and arrays, bounded by `CONTRACT.themeWidgetStyles`: `maxValueDepth` (3), `maxValueEntries` (24 per level), `maxValueBytes` (512 per field), and a new `maxBytes` (64000) over the whole map — a ceiling the per-scalar limits never stated, so the worst-case payload of that unauthenticated cold-start read is now *smaller* than before. `__proto__`-style keys are refused at every level.
95
+
96
+ Host-integration surface only — nothing a widget imports changed, and your widget still reads `props.style` without learning which layer supplied a value. `CONTRACT.version` → `1.78.0`. Additive: every value accepted before is accepted now.
72
97
 
73
98
  ### What's new in 0.95.0 (contract 1.68.0)
74
99
 
package/dist/contract.cjs CHANGED
@@ -158,6 +158,15 @@ const THEME_WIDGET_STYLES = Object.freeze({
158
158
  maxWidgets: 200,
159
159
  // Matches the styleSchema field cap the widget agent is held to.
160
160
  maxFieldsPerWidget: 12,
161
+ // sc-5646: a styleSchema field may hold a STRUCTURED value (a container's
162
+ // overlay, a list of ids), so the ceiling is stated in bytes rather than
163
+ // assumed to be one short scalar.
164
+ maxValueBytes: 512,
165
+ // The whole map, because it rides that unauthenticated cold-start read.
166
+ maxBytes: 64000,
167
+ // Nesting/width a structured value may reach before it is dropped.
168
+ maxValueDepth: 3,
169
+ maxValueEntries: 24,
161
170
  });
162
171
  // REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
163
172
  // consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
@@ -230,6 +239,15 @@ const FORM_SUBMIT_FIELDS = Object.freeze({
230
239
  gradient: "submitGradient",
231
240
  });
232
241
 
242
+ // The Link card's action button -- its own `button*` names, kept distinct from
243
+ // the card surface the button sits on.
244
+ const LINK_ACTION_FIELDS = Object.freeze({
245
+ background: "buttonBackground",
246
+ textColor: "buttonTextColor",
247
+ gradient: "buttonGradient",
248
+ radius: "buttonRadius",
249
+ });
250
+
233
251
  // REQ-THEME-WIDGET: the card fields whose NAMES are unambiguous, so they bind to
234
252
  // ANY widget that reads them -- including a Mason-generated one, whose id can
235
253
  // never appear in a hand-maintained allowlist. That allowlist is why "make the
@@ -299,6 +317,8 @@ const THEME_COMPONENTS = Object.freeze({
299
317
  }),
300
318
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
301
319
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
320
+ // sc-5757: the Link card's action button, so the button scope reaches it.
321
+ "appstudio.link": LINK_ACTION_FIELDS,
302
322
  }),
303
323
  }),
304
324
  card: Object.freeze({
@@ -322,6 +342,7 @@ const THEME_COMPONENTS = Object.freeze({
322
342
  "appstudio.form-input": CARD_SURFACE_FIELDS,
323
343
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
324
344
  "appstudio.user-management": CARD_SURFACE_FIELDS,
345
+ "appstudio.link": CARD_SURFACE_FIELDS,
325
346
  }),
326
347
  }),
327
348
  text: Object.freeze({
@@ -3266,7 +3287,21 @@ const CONTRACT = deepFreeze({
3266
3287
  // PAGE_ROUTES maps both to the screen name), but the contract said
3267
3288
  // `pageId` only, so data-driven widgets — whose rows carry slugs —
3268
3289
  // invented params patterns that navigate nowhere. No code changed shape.
3269
- version: "1.77.0",
3290
+ // 1.79.0: additive (sc-5890) — the `cornerRadius` property type: a radius
3291
+ // value is `number | { topLeft, topRight, bottomRight, bottomLeft }`, resolved
3292
+ // by `normaliseCornerRadius` and emitted by `cornerRadiusStyle`.
3293
+ // 1.78.0: additive (sc-5646) — `widgetStyles` carries every value shape a
3294
+ // `styleSchema` field can produce, not just scalars and gradients. The
3295
+ // Studio offers the SAME field in the widget editor and in Theme
3296
+ // Settings, so a structured value (a container-style overlay, a list of
3297
+ // ids) that persisted per instance but was dropped app-wide made one
3298
+ // edit behave two ways. Objects and arrays are now carried, bounded by
3299
+ // `themeWidgetStyles.maxValueDepth` / `maxValueEntries` /
3300
+ // `maxValueBytes`, with `maxBytes` capping the whole map — a ceiling the
3301
+ // per-scalar limits never stated, so the worst-case unauthenticated
3302
+ // payload is SMALLER than before. `__proto__`-style keys are refused at
3303
+ // every level. Additive: every value accepted before is accepted now.
3304
+ version: "1.79.0",
3270
3305
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3271
3306
  hooks: HOOKS,
3272
3307
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -158,6 +158,15 @@ const THEME_WIDGET_STYLES = Object.freeze({
158
158
  maxWidgets: 200,
159
159
  // Matches the styleSchema field cap the widget agent is held to.
160
160
  maxFieldsPerWidget: 12,
161
+ // sc-5646: a styleSchema field may hold a STRUCTURED value (a container's
162
+ // overlay, a list of ids), so the ceiling is stated in bytes rather than
163
+ // assumed to be one short scalar.
164
+ maxValueBytes: 512,
165
+ // The whole map, because it rides that unauthenticated cold-start read.
166
+ maxBytes: 64000,
167
+ // Nesting/width a structured value may reach before it is dropped.
168
+ maxValueDepth: 3,
169
+ maxValueEntries: 24,
161
170
  });
162
171
  // REQ-NAV-STRUCTURE: the SHAPE an app's navigation takes. One catalogue, four
163
172
  // consumers -- the Studio's Navigation page, Mason's set_theme coercion, the web
@@ -230,6 +239,15 @@ const FORM_SUBMIT_FIELDS = Object.freeze({
230
239
  gradient: "submitGradient",
231
240
  });
232
241
 
242
+ // The Link card's action button -- its own `button*` names, kept distinct from
243
+ // the card surface the button sits on.
244
+ const LINK_ACTION_FIELDS = Object.freeze({
245
+ background: "buttonBackground",
246
+ textColor: "buttonTextColor",
247
+ gradient: "buttonGradient",
248
+ radius: "buttonRadius",
249
+ });
250
+
233
251
  // REQ-THEME-WIDGET: the card fields whose NAMES are unambiguous, so they bind to
234
252
  // ANY widget that reads them -- including a Mason-generated one, whose id can
235
253
  // never appear in a hand-maintained allowlist. That allowlist is why "make the
@@ -299,6 +317,8 @@ const THEME_COMPONENTS = Object.freeze({
299
317
  }),
300
318
  "appstudio.form-input": FORM_SUBMIT_FIELDS,
301
319
  "appstudio.form-builder": FORM_SUBMIT_FIELDS,
320
+ // sc-5757: the Link card's action button, so the button scope reaches it.
321
+ "appstudio.link": LINK_ACTION_FIELDS,
302
322
  }),
303
323
  }),
304
324
  card: Object.freeze({
@@ -322,6 +342,7 @@ const THEME_COMPONENTS = Object.freeze({
322
342
  "appstudio.form-input": CARD_SURFACE_FIELDS,
323
343
  "appstudio.form-builder": CARD_SURFACE_FIELDS,
324
344
  "appstudio.user-management": CARD_SURFACE_FIELDS,
345
+ "appstudio.link": CARD_SURFACE_FIELDS,
325
346
  }),
326
347
  }),
327
348
  text: Object.freeze({
@@ -3266,7 +3287,21 @@ const CONTRACT = deepFreeze({
3266
3287
  // PAGE_ROUTES maps both to the screen name), but the contract said
3267
3288
  // `pageId` only, so data-driven widgets — whose rows carry slugs —
3268
3289
  // invented params patterns that navigate nowhere. No code changed shape.
3269
- version: "1.77.0",
3290
+ // 1.79.0: additive (sc-5890) — the `cornerRadius` property type: a radius
3291
+ // value is `number | { topLeft, topRight, bottomRight, bottomLeft }`, resolved
3292
+ // by `normaliseCornerRadius` and emitted by `cornerRadiusStyle`.
3293
+ // 1.78.0: additive (sc-5646) — `widgetStyles` carries every value shape a
3294
+ // `styleSchema` field can produce, not just scalars and gradients. The
3295
+ // Studio offers the SAME field in the widget editor and in Theme
3296
+ // Settings, so a structured value (a container-style overlay, a list of
3297
+ // ids) that persisted per instance but was dropped app-wide made one
3298
+ // edit behave two ways. Objects and arrays are now carried, bounded by
3299
+ // `themeWidgetStyles.maxValueDepth` / `maxValueEntries` /
3300
+ // `maxValueBytes`, with `maxBytes` capping the whole map — a ceiling the
3301
+ // per-scalar limits never stated, so the worst-case unauthenticated
3302
+ // payload is SMALLER than before. `__proto__`-style keys are refused at
3303
+ // every level. Additive: every value accepted before is accepted now.
3304
+ version: "1.79.0",
3270
3305
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3271
3306
  hooks: HOOKS,
3272
3307
  primitives: PRIMITIVES,
@@ -0,0 +1,95 @@
1
+ // REQ-LAY-16 (sc-5890): the per-corner radius vocabulary. ONE normaliser and
2
+ // ONE style emitter for the web Player, the Builder canvas, the exported Expo
3
+ // app, and any custom widget declaring a `cornerRadius` field — so the four can
4
+ // never disagree about what a radius value means.
5
+ //
6
+ // The authored value is `number | { topLeft, topRight, bottomRight, bottomLeft }`.
7
+ // The scalar form is the one sc-3797 shipped and is still what most styles hold,
8
+ // so it stays first-class rather than being migrated away: widening the shape
9
+ // beats adding a second key beside it (CLAUDE.md §3).
10
+ //
11
+ // Both hosts spell the long-hand props identically (`borderTopLeftRadius` &co in
12
+ // React inline style AND in React Native), which is why one emitter serves both.
13
+
14
+ export const CORNER_RADIUS_KEYS = Object.freeze([
15
+ "topLeft",
16
+ "topRight",
17
+ "bottomRight",
18
+ "bottomLeft",
19
+ ]);
20
+
21
+ const LONGHAND_PROP = Object.freeze({
22
+ topLeft: "borderTopLeftRadius",
23
+ topRight: "borderTopRightRadius",
24
+ bottomRight: "borderBottomRightRadius",
25
+ bottomLeft: "borderBottomLeftRadius",
26
+ });
27
+
28
+ function clampCorner(value, fallback, max) {
29
+ // Unset (undefined/null/"") falls back; an explicit 0 is honoured — that
30
+ // distinction is what makes "square just this corner" expressible.
31
+ if (value === undefined || value === null || value === "") return fallback;
32
+ const n = Number(value);
33
+ if (!Number.isFinite(n)) return fallback;
34
+ return Math.min(Math.max(Math.round(n), 0), max);
35
+ }
36
+
37
+ /**
38
+ * Resolve an authored radius to its four corners.
39
+ *
40
+ * @param {number|object|null|undefined} value the authored `number | {corners}`
41
+ * @param {number} [fallback] the value each unset corner takes
42
+ * @param {number} [max] the upper clamp, matching the field's declared max
43
+ * @returns {{topLeft:number, topRight:number, bottomRight:number, bottomLeft:number}}
44
+ */
45
+ export function normaliseCornerRadius(value, fallback = 0, max = 48) {
46
+ const base = clampCorner(
47
+ typeof value === "number" || typeof value === "string" ? value : undefined,
48
+ clampCorner(fallback, 0, max),
49
+ max,
50
+ );
51
+ const corners = value && typeof value === "object" ? value : null;
52
+ const out = {};
53
+ for (const key of CORNER_RADIUS_KEYS) {
54
+ out[key] = clampCorner(corners ? corners[key] : undefined, base, max);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** True when all four corners resolve to the same number. */
60
+ export function isUniformCornerRadius(radius) {
61
+ if (!radius) return true;
62
+ const { topLeft } = radius;
63
+ return CORNER_RADIUS_KEYS.every((key) => radius[key] === topLeft);
64
+ }
65
+
66
+ /**
67
+ * The style props for a resolved radius, in the spelling BOTH hosts accept.
68
+ * A uniform radius emits the `borderRadius` shorthand so an untouched style is
69
+ * byte-identical to what sc-3797 produced; only a genuinely mixed radius pays
70
+ * for the four long-hand props. A radius of all-zero emits nothing at all.
71
+ *
72
+ * @param {object|null} radius output of {@link normaliseCornerRadius}
73
+ * @param {(n: number) => any} [format] wraps each number — the DOM needs "12px"
74
+ * @returns {object|null}
75
+ */
76
+ export function cornerRadiusStyle(radius, format) {
77
+ if (!radius) return null;
78
+ const wrap = format || ((n) => n);
79
+ if (isUniformCornerRadius(radius)) {
80
+ return radius.topLeft > 0 ? { borderRadius: wrap(radius.topLeft) } : null;
81
+ }
82
+ const out = {};
83
+ for (const key of CORNER_RADIUS_KEYS) {
84
+ out[LONGHAND_PROP[key]] = wrap(radius[key]);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * True when the authored value asks for anything rounded. Callers use it to
91
+ * decide whether a background layer needs clipping at all.
92
+ */
93
+ export function hasCornerRadius(radius) {
94
+ return Boolean(radius) && CORNER_RADIUS_KEYS.some((key) => radius[key] > 0);
95
+ }
package/dist/host.d.ts CHANGED
@@ -45,6 +45,17 @@ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
45
45
  * contract, so the authoritative field type is the widget's own styleSchema.
46
46
  * Bounded by `CONTRACT.themeWidgetStyles`.
47
47
  */
48
+ /**
49
+ * sc-5646 host helper: validates ONE widget style object — a placed node's
50
+ * `props.style` or an app-wide `widgetStyles[id]` entry. Both scopes hold the
51
+ * same vocabulary, so both are validated the same way; `maxFields` applies the
52
+ * per-widget cap only the app-wide map needs.
53
+ */
54
+ export function normaliseWidgetStyleFields(
55
+ raw: unknown,
56
+ options?: { maxFields?: number },
57
+ ): Record<string, unknown>;
58
+
48
59
  export function normaliseWidgetStyles(raw: unknown): ThemeWidgetStyles;
49
60
 
50
61
  /**
package/dist/host.js CHANGED
@@ -18,6 +18,7 @@ export { resolveProps } from "./property-schema.js";
18
18
  // hosts, so the Player and the Expo export cannot diverge.
19
19
  export {
20
20
  normaliseThemeComponents,
21
+ normaliseWidgetStyleFields,
21
22
  normaliseWidgetStyles,
22
23
  applyThemeComponentStyle,
23
24
  } from "./theme-components.js";
package/dist/index.d.ts CHANGED
@@ -53,6 +53,10 @@ export type WidgetPropertyType =
53
53
  // label, required, optionsSource, inlineOptions, optionsTableId,
54
54
  // optionsValueColumn, optionsLabelColumn }.
55
55
  | "fieldList"
56
+ // REQ-LAY-16 (sc-5890): corner-radius picker. Value is
57
+ // `number | { topLeft, topRight, bottomRight, bottomLeft }` — a scalar rounds
58
+ // all four corners, the object rounds each independently.
59
+ | "cornerRadius"
56
60
  | "expression"
57
61
  | "eventBinding"
58
62
  | "object"
@@ -692,6 +696,44 @@ export function validateProps<T = Record<string, unknown>>(
692
696
  props: unknown,
693
697
  ): { ok: true; value: T } | { ok: false; errors: string[] };
694
698
 
699
+ /**
700
+ * REQ-LAY-16 (sc-5890): the per-corner radius vocabulary. A `cornerRadius`
701
+ * field's authored value is a scalar or a per-corner object; resolve it with
702
+ * `normaliseCornerRadius` and turn it into style props with `cornerRadiusStyle`
703
+ * — both hosts spell the long-hand props identically, so one call serves the
704
+ * web Player and the Expo export.
705
+ */
706
+ export type CornerRadiusKey =
707
+ | "topLeft"
708
+ | "topRight"
709
+ | "bottomRight"
710
+ | "bottomLeft";
711
+
712
+ export type CornerRadiusValue = number | Partial<Record<CornerRadiusKey, number>>;
713
+
714
+ export type ResolvedCornerRadius = Record<CornerRadiusKey, number>;
715
+
716
+ export const CORNER_RADIUS_KEYS: readonly CornerRadiusKey[];
717
+
718
+ export function normaliseCornerRadius(
719
+ value: CornerRadiusValue | null | undefined,
720
+ fallback?: number,
721
+ max?: number,
722
+ ): ResolvedCornerRadius;
723
+
724
+ export function isUniformCornerRadius(
725
+ radius: ResolvedCornerRadius | null | undefined,
726
+ ): boolean;
727
+
728
+ export function cornerRadiusStyle<T = number>(
729
+ radius: ResolvedCornerRadius | null | undefined,
730
+ format?: (n: number) => T,
731
+ ): Record<string, T> | null;
732
+
733
+ export function hasCornerRadius(
734
+ radius: ResolvedCornerRadius | null | undefined,
735
+ ): boolean;
736
+
695
737
  export interface Query {
696
738
  filter?: Record<string, unknown>;
697
739
  sort?: Array<{ field: string; dir: "asc" | "desc" }>;
package/dist/index.js CHANGED
@@ -5,6 +5,14 @@
5
5
  export { defineWidget } from "./define-widget.js";
6
6
  export { validateManifest, canonicalCategory } from "./manifest.js";
7
7
  export { validatePropertySchema, validateProps } from "./property-schema.js";
8
+ // REQ-LAY-16 (sc-5890): per-corner radius — one normaliser/emitter for both hosts.
9
+ export {
10
+ CORNER_RADIUS_KEYS,
11
+ normaliseCornerRadius,
12
+ isUniformCornerRadius,
13
+ cornerRadiusStyle,
14
+ hasCornerRadius,
15
+ } from "./corner-radius.js";
8
16
  export {
9
17
  WidgetContextProvider,
10
18
  DatastoreError,
@@ -5,6 +5,14 @@
5
5
  export { defineWidget } from "./define-widget.js";
6
6
  export { validateManifest, canonicalCategory } from "./manifest.js";
7
7
  export { validatePropertySchema, validateProps } from "./property-schema.js";
8
+ // REQ-LAY-16 (sc-5890): per-corner radius — one normaliser/emitter for both hosts.
9
+ export {
10
+ CORNER_RADIUS_KEYS,
11
+ normaliseCornerRadius,
12
+ isUniformCornerRadius,
13
+ cornerRadiusStyle,
14
+ hasCornerRadius,
15
+ } from "./corner-radius.js";
8
16
  export {
9
17
  WidgetContextProvider,
10
18
  DatastoreError,
@@ -1,6 +1,8 @@
1
1
  // Property schema validation per docs/architecture/widget-marketplace.md §2.2.
2
2
  // Drives the schema-driven Properties Panel and validates persisted page JSON.
3
3
 
4
+ import { CORNER_RADIUS_KEYS } from "./corner-radius.js";
5
+
4
6
  const VALID_TYPES = new Set([
5
7
  "string", "number", "boolean",
6
8
  "color", "icon", "image",
@@ -47,6 +49,14 @@ const VALID_TYPES = new Set([
47
49
  // NOT copied, so tenant-copy leaves the id as-is (it resolves to the space
48
50
  // root if absent in the target).
49
51
  "folderRef",
52
+ // REQ-LAY-16 (sc-5890): `cornerRadius` is a corner-radius picker. Its value
53
+ // is `number | { topLeft, topRight, bottomRight, bottomLeft }` — a scalar
54
+ // rounds all four, the object rounds each independently. The Studio renders
55
+ // a slider + typeable number with a per-corner disclosure; a widget turns
56
+ // the value into style props with `cornerRadiusStyle(normaliseCornerRadius(v))`,
57
+ // which both hosts spell identically. Plain numbers, so tenant-copy needs
58
+ // no remap.
59
+ "cornerRadius",
50
60
  "expression", "eventBinding",
51
61
  "object", "array",
52
62
  ]);
@@ -152,6 +162,36 @@ function coerceLeaf(def, value, path, errors) {
152
162
  case "boolean":
153
163
  if (typeof value !== "boolean") errors.push(`${path}: expected boolean`);
154
164
  return value;
165
+ case "cornerRadius": {
166
+ // REQ-LAY-16: a scalar rounds all four corners; an object rounds each.
167
+ // Every corner is optional so a half-set object stays valid while the
168
+ // author is still adjusting — an unset corner falls back to the scalar.
169
+ const { min = 0, max } = def.validation || {};
170
+ const checkCorner = (n, at) => {
171
+ if (typeof n !== "number" || Number.isNaN(n)) {
172
+ errors.push(`${at}: expected number`);
173
+ return;
174
+ }
175
+ if (n < min) errors.push(`${at}: must be >= ${min}`);
176
+ if (max !== undefined && n > max) errors.push(`${at}: must be <= ${max}`);
177
+ };
178
+ if (typeof value === "number") {
179
+ checkCorner(value, path);
180
+ return value;
181
+ }
182
+ if (!isPlainObject(value)) {
183
+ errors.push(`${path}: expected number or per-corner object`);
184
+ return value;
185
+ }
186
+ for (const [k, n] of Object.entries(value)) {
187
+ if (!CORNER_RADIUS_KEYS.includes(k)) {
188
+ errors.push(`${path}.${k}: unknown corner`);
189
+ } else if (n !== undefined && n !== null) {
190
+ checkCorner(n, `${path}.${k}`);
191
+ }
192
+ }
193
+ return value;
194
+ }
155
195
  case "select":
156
196
  if (Array.isArray(def.enum) && !def.enum.some((e) => e.value === value)) {
157
197
  errors.push(`${path}: value not in enum`);
@@ -99,12 +99,30 @@ function normaliseThemeComponents(raw) {
99
99
  }
100
100
 
101
101
 
102
+ // Values are assigned onto plain object literals, so `__proto__` would set a
103
+ // PROTOTYPE rather than a style field. Refused at every level.
104
+ const UNSAFE_STYLE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
105
+
106
+ function isUsableStyleKey(key) {
107
+ return (
108
+ typeof key === "string" &&
109
+ key.length > 0 &&
110
+ key.length <= 64 &&
111
+ !UNSAFE_STYLE_KEYS.has(key)
112
+ );
113
+ }
114
+
102
115
  // REQ-THEME-ELEMENT: one value out of the per-widget map. Unlike a component
103
116
  // token, there is no declared `type` to coerce against -- the key space is the
104
117
  // workspace's widget catalog, not the contract -- so validation here is
105
118
  // STRUCTURAL. The authoritative type is the widget's own styleSchema, which the
106
119
  // Studio honours by only ever offering fields that widget declares.
107
- function coerceWidgetStyleValue(value) {
120
+ //
121
+ // sc-5646: that same field is offered per instance AND app-wide, so carrying
122
+ // only scalars here made one edit behave two ways. A structured value rides
123
+ // along too -- bounded by depth, width and bytes, never by trust.
124
+ function coerceWidgetStyleValue(value, depth = 0) {
125
+ const { maxValueDepth, maxValueEntries } = CONTRACT.themeWidgetStyles;
108
126
  if (typeof value === "string") {
109
127
  const trimmed = value.trim();
110
128
  // Long enough for a hex, an enum value or a font name; short enough that a
@@ -113,10 +131,75 @@ function coerceWidgetStyleValue(value) {
113
131
  }
114
132
  if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
115
133
  if (typeof value === "boolean") return value;
116
- if (isPlainObject(value)) return normaliseComponentGradient(value) || undefined;
134
+ if (depth >= maxValueDepth) return undefined;
135
+ if (Array.isArray(value)) {
136
+ const items = [];
137
+ for (const item of value) {
138
+ if (items.length >= maxValueEntries) break;
139
+ const coerced = coerceWidgetStyleValue(item, depth + 1);
140
+ if (coerced !== undefined) items.push(coerced);
141
+ }
142
+ return items.length > 0 ? items : undefined;
143
+ }
144
+ if (isPlainObject(value)) {
145
+ // A gradient keeps its own normaliser -- its stops are interpolated into
146
+ // CSS and into generated export source, so they never ride the generic
147
+ // path. Claiming the grammar is enough to be held to it: an invalid one is
148
+ // dropped rather than carried through as a plain object.
149
+ if ("from" in value || "to" in value) {
150
+ return normaliseComponentGradient(value) || undefined;
151
+ }
152
+ const out = {};
153
+ let kept = 0;
154
+ for (const [key, item] of Object.entries(value)) {
155
+ if (kept >= maxValueEntries) break;
156
+ if (!isUsableStyleKey(key)) continue;
157
+ const coerced = coerceWidgetStyleValue(item, depth + 1);
158
+ if (coerced === undefined) continue;
159
+ out[key] = coerced;
160
+ kept += 1;
161
+ }
162
+ return kept > 0 ? out : undefined;
163
+ }
117
164
  return undefined;
118
165
  }
119
166
 
167
+ // What the value actually costs the cold-start read and the baked export.
168
+ function styleValueBytes(value) {
169
+ const json = JSON.stringify(value);
170
+ return typeof json === "string" ? json.length : Infinity;
171
+ }
172
+
173
+ /**
174
+ * sc-5646: ONE widget's style fields, validated. The app-wide layer and a placed
175
+ * node's `props.style` hold the SAME vocabulary at different scopes, so they must
176
+ * accept the same values — a field an author can set on a node and not app-wide
177
+ * (or the reverse) is exactly the divergence the two surfaces are meant not to
178
+ * have. The per-widget FIELD cap is deliberately not applied here: it bounds the
179
+ * app-wide map that rides the unauthenticated cold-start read, not one node in a
180
+ * page layout the builder already writes unbounded.
181
+ *
182
+ * @param {unknown} raw — a `props.style` / `widgetStyles[id]` object.
183
+ * @param {{ maxFields?: number }} [options]
184
+ * @returns {Record<string, unknown>} the fields that survived; `{}` for junk.
185
+ */
186
+ function normaliseWidgetStyleFields(raw, { maxFields } = {}) {
187
+ if (!isPlainObject(raw)) return {};
188
+ const { maxValueBytes } = CONTRACT.themeWidgetStyles;
189
+ const out = {};
190
+ let count = 0;
191
+ for (const [field, value] of Object.entries(raw)) {
192
+ if (maxFields !== undefined && count >= maxFields) break;
193
+ if (!isUsableStyleKey(field)) continue;
194
+ const coerced = coerceWidgetStyleValue(value);
195
+ if (coerced === undefined) continue;
196
+ if (styleValueBytes(coerced) > maxValueBytes) continue;
197
+ out[field] = coerced;
198
+ count += 1;
199
+ }
200
+ return out;
201
+ }
202
+
120
203
  /**
121
204
  * REQ-THEME-ELEMENT: validate `themeConfig.widgetStyles` -- app-wide style values
122
205
  * keyed by WIDGET MANIFEST ID, then by that widget's own styleSchema field name.
@@ -132,26 +215,31 @@ function coerceWidgetStyleValue(value) {
132
215
  */
133
216
  function normaliseWidgetStyles(raw) {
134
217
  if (!isPlainObject(raw)) return {};
135
- const { maxWidgets, maxFieldsPerWidget } = CONTRACT.themeWidgetStyles;
218
+ const { maxWidgets, maxFieldsPerWidget, maxBytes } =
219
+ CONTRACT.themeWidgetStyles;
136
220
  const idPattern = CONTRACT.manifestSchema.id.pattern;
137
221
  const out = {};
138
222
  let widgets = 0;
223
+ let bytes = 0;
139
224
  for (const [manifestId, fields] of Object.entries(raw)) {
140
225
  if (widgets >= maxWidgets) break;
141
226
  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
- }
227
+ // The SAME per-field validation a placed node's style gets, plus the cap
228
+ // that only this map needs.
229
+ const kept = normaliseWidgetStyleFields(fields, {
230
+ maxFields: maxFieldsPerWidget,
231
+ });
232
+ const count = Object.keys(kept).length;
151
233
  // An emptied entry is dropped rather than persisted as `{}`, mirroring
152
234
  // normaliseThemeComponents.
153
235
  if (count === 0) continue;
236
+ // Whole entries, never half of one: a widget rendering with SOME of its
237
+ // app-wide fields reads as a bug, where one rendering with none reads as
238
+ // "not styled yet".
239
+ const entryBytes = styleValueBytes(kept) + manifestId.length;
240
+ if (bytes + entryBytes > maxBytes) break;
154
241
  out[manifestId] = kept;
242
+ bytes += entryBytes;
155
243
  widgets += 1;
156
244
  }
157
245
  return out;
@@ -245,4 +333,4 @@ function applyThemeComponentStyle(manifestId, theme, props, styleSchema) {
245
333
  return { ...base, style: { ...themed, ...authored } };
246
334
  }
247
335
 
248
- module.exports = { normaliseThemeComponents, normaliseWidgetStyles, applyThemeComponentStyle };
336
+ module.exports = { normaliseThemeComponents, normaliseWidgetStyleFields, normaliseWidgetStyles, applyThemeComponentStyle };
@@ -91,12 +91,30 @@ export function normaliseThemeComponents(raw) {
91
91
  }
92
92
 
93
93
 
94
+ // Values are assigned onto plain object literals, so `__proto__` would set a
95
+ // PROTOTYPE rather than a style field. Refused at every level.
96
+ const UNSAFE_STYLE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
97
+
98
+ function isUsableStyleKey(key) {
99
+ return (
100
+ typeof key === "string" &&
101
+ key.length > 0 &&
102
+ key.length <= 64 &&
103
+ !UNSAFE_STYLE_KEYS.has(key)
104
+ );
105
+ }
106
+
94
107
  // REQ-THEME-ELEMENT: one value out of the per-widget map. Unlike a component
95
108
  // token, there is no declared `type` to coerce against -- the key space is the
96
109
  // workspace's widget catalog, not the contract -- so validation here is
97
110
  // STRUCTURAL. The authoritative type is the widget's own styleSchema, which the
98
111
  // Studio honours by only ever offering fields that widget declares.
99
- function coerceWidgetStyleValue(value) {
112
+ //
113
+ // sc-5646: that same field is offered per instance AND app-wide, so carrying
114
+ // only scalars here made one edit behave two ways. A structured value rides
115
+ // along too -- bounded by depth, width and bytes, never by trust.
116
+ function coerceWidgetStyleValue(value, depth = 0) {
117
+ const { maxValueDepth, maxValueEntries } = CONTRACT.themeWidgetStyles;
100
118
  if (typeof value === "string") {
101
119
  const trimmed = value.trim();
102
120
  // Long enough for a hex, an enum value or a font name; short enough that a
@@ -105,10 +123,75 @@ function coerceWidgetStyleValue(value) {
105
123
  }
106
124
  if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
107
125
  if (typeof value === "boolean") return value;
108
- if (isPlainObject(value)) return normaliseComponentGradient(value) || undefined;
126
+ if (depth >= maxValueDepth) return undefined;
127
+ if (Array.isArray(value)) {
128
+ const items = [];
129
+ for (const item of value) {
130
+ if (items.length >= maxValueEntries) break;
131
+ const coerced = coerceWidgetStyleValue(item, depth + 1);
132
+ if (coerced !== undefined) items.push(coerced);
133
+ }
134
+ return items.length > 0 ? items : undefined;
135
+ }
136
+ if (isPlainObject(value)) {
137
+ // A gradient keeps its own normaliser -- its stops are interpolated into
138
+ // CSS and into generated export source, so they never ride the generic
139
+ // path. Claiming the grammar is enough to be held to it: an invalid one is
140
+ // dropped rather than carried through as a plain object.
141
+ if ("from" in value || "to" in value) {
142
+ return normaliseComponentGradient(value) || undefined;
143
+ }
144
+ const out = {};
145
+ let kept = 0;
146
+ for (const [key, item] of Object.entries(value)) {
147
+ if (kept >= maxValueEntries) break;
148
+ if (!isUsableStyleKey(key)) continue;
149
+ const coerced = coerceWidgetStyleValue(item, depth + 1);
150
+ if (coerced === undefined) continue;
151
+ out[key] = coerced;
152
+ kept += 1;
153
+ }
154
+ return kept > 0 ? out : undefined;
155
+ }
109
156
  return undefined;
110
157
  }
111
158
 
159
+ // What the value actually costs the cold-start read and the baked export.
160
+ function styleValueBytes(value) {
161
+ const json = JSON.stringify(value);
162
+ return typeof json === "string" ? json.length : Infinity;
163
+ }
164
+
165
+ /**
166
+ * sc-5646: ONE widget's style fields, validated. The app-wide layer and a placed
167
+ * node's `props.style` hold the SAME vocabulary at different scopes, so they must
168
+ * accept the same values — a field an author can set on a node and not app-wide
169
+ * (or the reverse) is exactly the divergence the two surfaces are meant not to
170
+ * have. The per-widget FIELD cap is deliberately not applied here: it bounds the
171
+ * app-wide map that rides the unauthenticated cold-start read, not one node in a
172
+ * page layout the builder already writes unbounded.
173
+ *
174
+ * @param {unknown} raw — a `props.style` / `widgetStyles[id]` object.
175
+ * @param {{ maxFields?: number }} [options]
176
+ * @returns {Record<string, unknown>} the fields that survived; `{}` for junk.
177
+ */
178
+ export function normaliseWidgetStyleFields(raw, { maxFields } = {}) {
179
+ if (!isPlainObject(raw)) return {};
180
+ const { maxValueBytes } = CONTRACT.themeWidgetStyles;
181
+ const out = {};
182
+ let count = 0;
183
+ for (const [field, value] of Object.entries(raw)) {
184
+ if (maxFields !== undefined && count >= maxFields) break;
185
+ if (!isUsableStyleKey(field)) continue;
186
+ const coerced = coerceWidgetStyleValue(value);
187
+ if (coerced === undefined) continue;
188
+ if (styleValueBytes(coerced) > maxValueBytes) continue;
189
+ out[field] = coerced;
190
+ count += 1;
191
+ }
192
+ return out;
193
+ }
194
+
112
195
  /**
113
196
  * REQ-THEME-ELEMENT: validate `themeConfig.widgetStyles` -- app-wide style values
114
197
  * keyed by WIDGET MANIFEST ID, then by that widget's own styleSchema field name.
@@ -124,26 +207,31 @@ function coerceWidgetStyleValue(value) {
124
207
  */
125
208
  export function normaliseWidgetStyles(raw) {
126
209
  if (!isPlainObject(raw)) return {};
127
- const { maxWidgets, maxFieldsPerWidget } = CONTRACT.themeWidgetStyles;
210
+ const { maxWidgets, maxFieldsPerWidget, maxBytes } =
211
+ CONTRACT.themeWidgetStyles;
128
212
  const idPattern = CONTRACT.manifestSchema.id.pattern;
129
213
  const out = {};
130
214
  let widgets = 0;
215
+ let bytes = 0;
131
216
  for (const [manifestId, fields] of Object.entries(raw)) {
132
217
  if (widgets >= maxWidgets) break;
133
218
  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
- }
219
+ // The SAME per-field validation a placed node's style gets, plus the cap
220
+ // that only this map needs.
221
+ const kept = normaliseWidgetStyleFields(fields, {
222
+ maxFields: maxFieldsPerWidget,
223
+ });
224
+ const count = Object.keys(kept).length;
143
225
  // An emptied entry is dropped rather than persisted as `{}`, mirroring
144
226
  // normaliseThemeComponents.
145
227
  if (count === 0) continue;
228
+ // Whole entries, never half of one: a widget rendering with SOME of its
229
+ // app-wide fields reads as a bug, where one rendering with none reads as
230
+ // "not styled yet".
231
+ const entryBytes = styleValueBytes(kept) + manifestId.length;
232
+ if (bytes + entryBytes > maxBytes) break;
146
233
  out[manifestId] = kept;
234
+ bytes += entryBytes;
147
235
  widgets += 1;
148
236
  }
149
237
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.102.1",
3
+ "version": "0.104.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-invites.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-page-url.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__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.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-invites.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-page-url.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__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"