@colixsystems/widget-sdk 0.110.0 → 0.111.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
@@ -69,7 +69,22 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
69
69
 
70
70
  ## Status
71
71
 
72
- `v0.110.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
+ `v0.111.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**.
73
+
74
+ ### What's new in 0.111.0 (contract 1.85.0)
75
+
76
+ **Each side can be spaced on its own — the `spacing` property type (sc-6447).** Padding and margin were single numbers, so every inset applied to all four sides at once: a hero with generous top padding and none at the bottom, or a card held off only its left neighbour, had no expression. `cornerRadius` already offered each corner (0.104.0); padding and margin were the last four-valued members of the box model that did not.
77
+
78
+ Declare `{ type: "spacing", label: "Padding", validation: { min: 0, max: 64 } }` in your `propertySchema` or `styleSchema`. The Studio renders a slider with a typeable number that sets all four sides, plus a disclosure for setting each one. The authored value is `number | { top, right, bottom, left }` — the scalar form is unchanged, so every value stored before is still valid.
79
+
80
+ ```js
81
+ import { normaliseSpacing, spacingStyle } from "@colixsystems/widget-sdk";
82
+
83
+ const padding = normaliseSpacing(props.style?.padding, 0, 64);
84
+ return <View style={[styles.card, spacingStyle(padding, "padding")]} />;
85
+ ```
86
+
87
+ `normaliseSpacing(value, fallback, max)` returns all four sides resolved and clamped; `spacingStyle(spacing, property, format)` emits the `padding`/`margin` shorthand when the sides agree and the four long-hand props when they differ (pass `` n => `${n}px` `` for the DOM). `mapSpacing` pushes each side through your own scaling, `isUniformSpacing` and `isZeroSpacing` round out the set. `CONTRACT.version` → `1.85.0`. Additive: every value accepted before is accepted now.
73
88
 
74
89
  ### What's new in 0.110.0 (contract 1.84.0)
75
90
 
package/dist/contract.cjs CHANGED
@@ -3374,7 +3374,19 @@ const CONTRACT = deepFreeze({
3374
3374
  // one `headerTintColor` cannot express; an authored `topBar.textColor`
3375
3375
  // drives both. `show` is deliberately absent -- it depends on the menu
3376
3376
  // type, not the theme, and is a no-op on native.
3377
- version: "1.84.0",
3377
+ //
3378
+ // 1.85.0: additive (sc-6447) — the `spacing` property type: a padding or
3379
+ // margin value is `number | { top, right, bottom, left }`, resolved by
3380
+ // `normaliseSpacing` and emitted by `spacingStyle(resolved, "padding" |
3381
+ // "margin")`. Padding and margin were the last four-valued members of the
3382
+ // box model an author could only set on all sides at once, while
3383
+ // `cornerRadius` (1.79.0) already offered each corner — so the same
3384
+ // master-plus-disclosure control now backs all three. The scalar form is
3385
+ // unchanged and still emits the shorthand, so an existing style renders
3386
+ // byte-identically. `mapSpacing` pushes each side through the responsive
3387
+ // and theme scaling the scalar already got; `isZeroSpacing` lets each box
3388
+ // property keep its own zero policy.
3389
+ version: "1.85.0",
3378
3390
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3379
3391
  hooks: HOOKS,
3380
3392
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -3374,7 +3374,19 @@ const CONTRACT = deepFreeze({
3374
3374
  // one `headerTintColor` cannot express; an authored `topBar.textColor`
3375
3375
  // drives both. `show` is deliberately absent -- it depends on the menu
3376
3376
  // type, not the theme, and is a no-op on native.
3377
- version: "1.84.0",
3377
+ //
3378
+ // 1.85.0: additive (sc-6447) — the `spacing` property type: a padding or
3379
+ // margin value is `number | { top, right, bottom, left }`, resolved by
3380
+ // `normaliseSpacing` and emitted by `spacingStyle(resolved, "padding" |
3381
+ // "margin")`. Padding and margin were the last four-valued members of the
3382
+ // box model an author could only set on all sides at once, while
3383
+ // `cornerRadius` (1.79.0) already offered each corner — so the same
3384
+ // master-plus-disclosure control now backs all three. The scalar form is
3385
+ // unchanged and still emits the shorthand, so an existing style renders
3386
+ // byte-identically. `mapSpacing` pushes each side through the responsive
3387
+ // and theme scaling the scalar already got; `isZeroSpacing` lets each box
3388
+ // property keep its own zero policy.
3389
+ version: "1.85.0",
3378
3390
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3379
3391
  hooks: HOOKS,
3380
3392
  primitives: PRIMITIVES,
package/dist/index.js CHANGED
@@ -13,6 +13,14 @@ export {
13
13
  cornerRadiusStyle,
14
14
  hasCornerRadius,
15
15
  } from "./corner-radius.js";
16
+ export {
17
+ SPACING_KEYS,
18
+ normaliseSpacing,
19
+ isUniformSpacing,
20
+ isZeroSpacing,
21
+ mapSpacing,
22
+ spacingStyle,
23
+ } from "./spacing.js";
16
24
  export {
17
25
  WidgetContextProvider,
18
26
  DatastoreError,
@@ -13,6 +13,14 @@ export {
13
13
  cornerRadiusStyle,
14
14
  hasCornerRadius,
15
15
  } from "./corner-radius.js";
16
+ export {
17
+ SPACING_KEYS,
18
+ normaliseSpacing,
19
+ isUniformSpacing,
20
+ isZeroSpacing,
21
+ mapSpacing,
22
+ spacingStyle,
23
+ } from "./spacing.js";
16
24
  export {
17
25
  WidgetContextProvider,
18
26
  DatastoreError,
@@ -2,6 +2,7 @@
2
2
  // Drives the schema-driven Properties Panel and validates persisted page JSON.
3
3
 
4
4
  import { CORNER_RADIUS_KEYS } from "./corner-radius.js";
5
+ import { SPACING_KEYS } from "./spacing.js";
5
6
 
6
7
  const VALID_TYPES = new Set([
7
8
  "string", "number", "boolean",
@@ -57,6 +58,13 @@ const VALID_TYPES = new Set([
57
58
  // which both hosts spell identically. Plain numbers, so tenant-copy needs
58
59
  // no remap.
59
60
  "cornerRadius",
61
+ // REQ-LAY-17 (sc-6447): `spacing` is a padding/margin picker. Its value is
62
+ // `number | { top, right, bottom, left }` — a scalar spaces all four sides,
63
+ // the object spaces each independently. The Studio renders a slider +
64
+ // typeable number with a per-side disclosure; a widget turns the value into
65
+ // style props with `spacingStyle(normaliseSpacing(v), "padding")`, which both
66
+ // hosts spell identically. Plain numbers, so tenant-copy needs no remap.
67
+ "spacing",
60
68
  "expression", "eventBinding",
61
69
  "object", "array",
62
70
  ]);
@@ -192,6 +200,36 @@ function coerceLeaf(def, value, path, errors) {
192
200
  }
193
201
  return value;
194
202
  }
203
+ case "spacing": {
204
+ // REQ-LAY-17: a scalar spaces all four sides; an object spaces each.
205
+ // Every side is optional so a half-set object stays valid while the
206
+ // author is still adjusting — an unset side falls back to the scalar.
207
+ const { min = 0, max } = def.validation || {};
208
+ const checkSide = (n, at) => {
209
+ if (typeof n !== "number" || Number.isNaN(n)) {
210
+ errors.push(`${at}: expected number`);
211
+ return;
212
+ }
213
+ if (n < min) errors.push(`${at}: must be >= ${min}`);
214
+ if (max !== undefined && n > max) errors.push(`${at}: must be <= ${max}`);
215
+ };
216
+ if (typeof value === "number") {
217
+ checkSide(value, path);
218
+ return value;
219
+ }
220
+ if (!isPlainObject(value)) {
221
+ errors.push(`${path}: expected number or per-side object`);
222
+ return value;
223
+ }
224
+ for (const [k, n] of Object.entries(value)) {
225
+ if (!SPACING_KEYS.includes(k)) {
226
+ errors.push(`${path}.${k}: unknown side`);
227
+ } else if (n !== undefined && n !== null) {
228
+ checkSide(n, `${path}.${k}`);
229
+ }
230
+ }
231
+ return value;
232
+ }
195
233
  case "select":
196
234
  if (Array.isArray(def.enum) && !def.enum.some((e) => e.value === value)) {
197
235
  errors.push(`${path}: value not in enum`);
@@ -0,0 +1,118 @@
1
+ // REQ-LAY-17 (sc-6447): the per-side spacing vocabulary. ONE normaliser and ONE
2
+ // style emitter for the web Player, the Builder canvas, the exported Expo app,
3
+ // and any custom widget declaring a `padding`/`margin` field — so the four can
4
+ // never disagree about what a spacing value means.
5
+ //
6
+ // The authored value is `number | { top, right, bottom, left }`. The scalar form
7
+ // is what every existing style holds, so it stays first-class rather than being
8
+ // migrated away: widening the shape beats adding a second key beside it
9
+ // (CLAUDE.md §3). This mirrors `corner-radius.js` deliberately — padding/margin
10
+ // and cornerRadius are the four-valued members of one box model, and an author
11
+ // meets the same affordance in both.
12
+ //
13
+ // Both hosts spell the long-hand props identically (`paddingTop` / `marginTop`
14
+ // &co in React inline style AND in React Native), which is why one emitter
15
+ // serves both.
16
+
17
+ export const SPACING_KEYS = Object.freeze(["top", "right", "bottom", "left"]);
18
+
19
+ // The CSS/RN suffix per side. `padding` + "Top" and `margin` + "Top" are both
20
+ // valid on web and native, so the property name is the caller's to choose.
21
+ const LONGHAND_SUFFIX = Object.freeze({
22
+ top: "Top",
23
+ right: "Right",
24
+ bottom: "Bottom",
25
+ left: "Left",
26
+ });
27
+
28
+ function clampSide(value, fallback, max) {
29
+ // Unset (undefined/null/"") falls back; an explicit 0 is honoured — that
30
+ // distinction is what makes "no padding on just this side" expressible.
31
+ if (value === undefined || value === null || value === "") return fallback;
32
+ const n = Number(value);
33
+ if (!Number.isFinite(n)) return fallback;
34
+ // Truncates, where `corner-radius.js` rounds: padding and margin have always
35
+ // truncated (`clampInt` / `clampMargin`), and changing that would shift every
36
+ // existing fractional value by a pixel.
37
+ return Math.min(Math.max(Math.trunc(n), 0), max);
38
+ }
39
+
40
+ /**
41
+ * Resolve an authored spacing to its four sides.
42
+ *
43
+ * @param {number|object|null|undefined} value the authored `number | {sides}`
44
+ * @param {number} [fallback] the value each unset side takes
45
+ * @param {number} [max] the upper clamp, matching the field's declared max
46
+ * @returns {{top:number, right:number, bottom:number, left:number}}
47
+ */
48
+ export function normaliseSpacing(value, fallback = 0, max = 64) {
49
+ const base = clampSide(
50
+ typeof value === "number" || typeof value === "string" ? value : undefined,
51
+ clampSide(fallback, 0, max),
52
+ max,
53
+ );
54
+ const sides = value && typeof value === "object" ? value : null;
55
+ const out = {};
56
+ for (const key of SPACING_KEYS) {
57
+ out[key] = clampSide(sides ? sides[key] : undefined, base, max);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** True when all four sides resolve to the same number. */
63
+ export function isUniformSpacing(spacing) {
64
+ if (!spacing) return true;
65
+ const { top } = spacing;
66
+ return SPACING_KEYS.every((key) => spacing[key] === top);
67
+ }
68
+
69
+ /** True when every side resolves to 0 — i.e. the value asks for no spacing. */
70
+ export function isZeroSpacing(spacing) {
71
+ return !spacing || SPACING_KEYS.every((key) => !spacing[key]);
72
+ }
73
+
74
+ /**
75
+ * Map every side through `fn`, keeping the resolved shape. Used to push each
76
+ * side through the responsive/theme scaling the scalar form already got, so a
77
+ * per-side value scales down on a phone exactly like a uniform one.
78
+ *
79
+ * @param {object|null} spacing output of {@link normaliseSpacing}
80
+ * @param {(n: number) => number} fn
81
+ * @returns {object|null}
82
+ */
83
+ export function mapSpacing(spacing, fn) {
84
+ if (!spacing) return null;
85
+ const out = {};
86
+ for (const key of SPACING_KEYS) {
87
+ const next = Number(fn(spacing[key]));
88
+ out[key] = Number.isFinite(next) ? next : spacing[key];
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * The style props for a resolved spacing, in the spelling BOTH hosts accept.
95
+ * A uniform value emits the shorthand so an untouched style is byte-identical
96
+ * to what the scalar form produced; only a genuinely mixed value pays for the
97
+ * four long-hand props.
98
+ *
99
+ * This emitter is MECHANICAL — it always emits, including an all-zero value.
100
+ * The zero policy differs per box property (a container's `padding: 0` has
101
+ * always been written, while a zero `margin` has always emitted nothing), so it
102
+ * belongs with each caller via {@link isZeroSpacing}, not baked in here.
103
+ *
104
+ * @param {object|null} spacing output of {@link normaliseSpacing}
105
+ * @param {"padding"|"margin"} [property] which box property to spell
106
+ * @param {(n: number) => any} [format] wraps each number — the DOM needs "12px"
107
+ * @returns {object|null}
108
+ */
109
+ export function spacingStyle(spacing, property = "padding", format) {
110
+ if (!spacing) return null;
111
+ const wrap = format || ((n) => n);
112
+ if (isUniformSpacing(spacing)) return { [property]: wrap(spacing.top) };
113
+ const out = {};
114
+ for (const key of SPACING_KEYS) {
115
+ out[`${property}${LONGHAND_SUFFIX[key]}`] = wrap(spacing[key]);
116
+ }
117
+ return out;
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.110.0",
3
+ "version": "0.111.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",