@colixsystems/widget-sdk 0.70.0 → 0.72.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
@@ -54,7 +54,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
54
54
 
55
55
  ## Status
56
56
 
57
- `v0.70.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**.
57
+ `v0.71.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**.
58
+
59
+ ### What's new in 0.71.0
60
+
61
+ **A theme can set leading, tracking and case for text app-wide (sc-3857).** The `text` scope of `themeConfig.components` gains three tokens beside `color` and `fontSize`:
62
+
63
+ - **`lineHeight`** — leading as a MULTIPLE of the font size (0.8–3), so one app-wide value stays right at every size. Tight leading (1.0–1.15) is what makes a 32px+ headline read as a headline rather than as oversized body text.
64
+ - **`letterSpacing`** — tracking in pixels (−2 to 20). Wide positive tracking is what makes a small uppercase kicker read as a kicker.
65
+ - **`textTransform`** — `none` | `uppercase` | `capitalize`, published as `CONTRACT.themeComponentTextTransforms`.
66
+
67
+ Two new token value types back them: **`decimal`** (a clamped, 2-decimal number — `size` rounds, so it cannot carry a 1.05 multiplier) and **`textTransform`**. Each token binds to the identically named per-instance style field the target widgets read, so the author rule is unchanged: read `props.style` / `useWidgetStyle()`, and a per-instance value still wins over the app-wide token.
68
+
69
+ `CONTRACT.version` → `1.48.0`. Additive: no existing export changed signature, and a theme with none of the new tokens renders exactly as before.
58
70
 
59
71
  ### What's new in 0.70.0
60
72
 
@@ -420,7 +432,7 @@ A runtime schema resolver so widgets can render by column type.
420
432
  The tenant's **Theme Settings** now flow all the way into `useTheme()`.
421
433
 
422
434
  - **`themeTokens.colors` gains `secondary` + `onSecondary`.** `useTheme().colors.secondary` reflects the tenant's *Secondary Color* picker (with `onSecondary` as its readable contrast color), alongside the existing `primary` / `onPrimary`. Built-in widgets like Button use it for their secondary variant; third-party widgets can use it for a branded second accent. The full `colors` shape is now `{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }`.
423
- - **`colors.primary` / `colors.secondary` / `typography.fontFamily` are tenant-resolved.** The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font) onto the default tokens before handing them to `useTheme()`, on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. (Custom Google fonts render in the Player today; the exported app falls back to the system face for non-system fonts until font bundling lands.)
435
+ - **`colors.primary` / `colors.secondary` / `typography.fontFamily` / `typography.headingFontFamily` are tenant-resolved.** The host maps the Studio Theme Settings blob (Primary Color, Secondary Color, Global Font, Heading Font) onto the default tokens before handing them to `useTheme()`, on both the live Player and the exported app — so a widget that reads tokens re-themes automatically. Both families are loaded by the Player and bundled into the exported app, so they render the same on web and native.
424
436
  - **`CONTRACT.version` → `1.6.0`** (additive: two new `themeTokens.colors` keys). No existing export changed signature.
425
437
 
426
438
  ### What's new in 0.15.0
@@ -550,7 +562,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
550
562
 
551
563
  - **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
552
564
  - **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
553
- - **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`.
565
+ - **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`. Use `theme.typography.headingFontFamily` instead on your widget's HEADING-tier text — titles and display numbers — so a workspace that pairs a display face with a body face gets that pairing inside your widget too. It defaults to `fontFamily`, so an unpaired workspace looks identical.
554
566
  - **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
555
567
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
556
568
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
package/dist/contract.cjs CHANGED
@@ -76,6 +76,10 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
76
76
  typography: Object.freeze({
77
77
  fontFamily:
78
78
  'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
79
+ // The display face for heading-tier text. Defaults to `fontFamily` — an app
80
+ // that never pairs renders on one typeface exactly as before.
81
+ headingFontFamily:
82
+ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
79
83
  sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
80
84
  }),
81
85
  // REQ-THEME-15: the tenant's per-component style tokens, folded in by the
@@ -107,6 +111,14 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
107
111
  // cannot drift between what Mason may emit and what a host actually applies.
108
112
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
109
113
 
114
+ // sc-3857 — the `textTransform` token's closed enum, matching the per-instance
115
+ // Text/Label/Data Value style field and React Native's own vocabulary.
116
+ const THEME_COMPONENT_TEXT_TRANSFORMS = Object.freeze([
117
+ "none",
118
+ "uppercase",
119
+ "capitalize",
120
+ ]);
121
+
110
122
  // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
123
  // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
124
  // variant (a component fill projects through `<Gradient>`, which is linear-only
@@ -129,6 +141,16 @@ const CARD_SURFACE_FIELDS = Object.freeze({
129
141
  gradient: "cardGradient",
130
142
  });
131
143
 
144
+ // The text field names every text-bearing built-in reads
145
+ // (frontend/src/components/widgets/_shared/textStyle.js TYPOGRAPHY_STYLE_FIELDS).
146
+ const TEXT_TYPOGRAPHY_FIELDS = Object.freeze({
147
+ color: "color",
148
+ fontSize: "fontSize",
149
+ lineHeight: "lineHeight",
150
+ letterSpacing: "letterSpacing",
151
+ textTransform: "textTransform",
152
+ });
153
+
132
154
  // A form widget's submit button — the `button` scope reaches it through the
133
155
  // form's own submit* fields, so "make the buttons coral" does not skip forms.
134
156
  const FORM_SUBMIT_FIELDS = Object.freeze({
@@ -190,14 +212,16 @@ const THEME_COMPONENTS = Object.freeze({
190
212
  tokens: Object.freeze({
191
213
  color: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
192
214
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.md" }),
215
+ // sc-3857 — leading as a MULTIPLE of the font size, so one app-wide value
216
+ // is right at every size; `size` cannot carry it because it rounds.
217
+ lineHeight: Object.freeze({ type: "decimal", min: 0.8, max: 3, step: 0.05 }),
218
+ letterSpacing: Object.freeze({ type: "decimal", min: -2, max: 20, step: 0.1 }),
219
+ textTransform: Object.freeze({ type: "textTransform" }),
193
220
  }),
194
221
  targets: Object.freeze({
195
- "appstudio.text": Object.freeze({ color: "color", fontSize: "fontSize" }),
196
- "appstudio.label": Object.freeze({ color: "color", fontSize: "fontSize" }),
197
- "appstudio.data-value": Object.freeze({
198
- color: "color",
199
- fontSize: "fontSize",
200
- }),
222
+ "appstudio.text": TEXT_TYPOGRAPHY_FIELDS,
223
+ "appstudio.label": TEXT_TYPOGRAPHY_FIELDS,
224
+ "appstudio.data-value": TEXT_TYPOGRAPHY_FIELDS,
201
225
  }),
202
226
  }),
203
227
  });
@@ -2215,7 +2239,26 @@ const CONTRACT = deepFreeze({
2215
2239
  // null, which is how one button stays flat while the rest are gradiented.
2216
2240
  // Additive: no export changed signature and a theme with no gradient token
2217
2241
  // renders exactly as before.
2218
- version: "1.47.0",
2242
+ // 1.48.0: additive (sc-3857) — leading, tracking and case on the `text` scope.
2243
+ // `lineHeight` (a MULTIPLE of the font size), `letterSpacing` (pixels) and
2244
+ // `textTransform` join the existing `color` / `fontSize` tokens and bind to
2245
+ // the identically named per-instance style fields Text, Label and Data Value
2246
+ // now read, so a theme can set app-wide typography and an author still
2247
+ // overrides it per instance. Two new token value types back them: `decimal`
2248
+ // (a clamped 2-decimal number — `size` rounds, so it cannot carry a 1.05
2249
+ // multiplier) and `textTransform`, whose closed enum is published as
2250
+ // `themeComponentTextTransforms`. Additive: no export changed signature and
2251
+ // a theme with none of the new tokens renders exactly as before.
2252
+ //
2253
+ // 1.49.0: additive (REQ-THEME-12) — `themeTokens.typography` gains
2254
+ // `headingFontFamily`, the display face for heading-tier text, and the
2255
+ // per-instance text vocabulary gains `fontRole` (`body` | `heading`) so an
2256
+ // author or Mason marks which text is a heading. `headingFontFamily`
2257
+ // defaults to the resolved `fontFamily`, and `fontRole` defaults to
2258
+ // `body`, so an app that sets neither renders on one typeface exactly as
2259
+ // before. Genuine headings — rich-text h1-h3 and container titles — take
2260
+ // the heading face without the field.
2261
+ version: "1.49.0",
2219
2262
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2220
2263
  hooks: HOOKS,
2221
2264
  primitives: PRIMITIVES,
@@ -2228,6 +2271,7 @@ const CONTRACT = deepFreeze({
2228
2271
  themeTokens: DEFAULT_THEME_TOKENS,
2229
2272
  themeComponents: THEME_COMPONENTS,
2230
2273
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2274
+ themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2231
2275
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2232
2276
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2233
2277
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/contract.js CHANGED
@@ -76,6 +76,10 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
76
76
  typography: Object.freeze({
77
77
  fontFamily:
78
78
  'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
79
+ // The display face for heading-tier text. Defaults to `fontFamily` — an app
80
+ // that never pairs renders on one typeface exactly as before.
81
+ headingFontFamily:
82
+ 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
79
83
  sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
80
84
  }),
81
85
  // REQ-THEME-15: the tenant's per-component style tokens, folded in by the
@@ -107,6 +111,14 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
107
111
  // cannot drift between what Mason may emit and what a host actually applies.
108
112
  const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
109
113
 
114
+ // sc-3857 — the `textTransform` token's closed enum, matching the per-instance
115
+ // Text/Label/Data Value style field and React Native's own vocabulary.
116
+ const THEME_COMPONENT_TEXT_TRANSFORMS = Object.freeze([
117
+ "none",
118
+ "uppercase",
119
+ "capitalize",
120
+ ]);
121
+
110
122
  // sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
111
123
  // angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
112
124
  // variant (a component fill projects through `<Gradient>`, which is linear-only
@@ -129,6 +141,16 @@ const CARD_SURFACE_FIELDS = Object.freeze({
129
141
  gradient: "cardGradient",
130
142
  });
131
143
 
144
+ // The text field names every text-bearing built-in reads
145
+ // (frontend/src/components/widgets/_shared/textStyle.js TYPOGRAPHY_STYLE_FIELDS).
146
+ const TEXT_TYPOGRAPHY_FIELDS = Object.freeze({
147
+ color: "color",
148
+ fontSize: "fontSize",
149
+ lineHeight: "lineHeight",
150
+ letterSpacing: "letterSpacing",
151
+ textTransform: "textTransform",
152
+ });
153
+
132
154
  // A form widget's submit button — the `button` scope reaches it through the
133
155
  // form's own submit* fields, so "make the buttons coral" does not skip forms.
134
156
  const FORM_SUBMIT_FIELDS = Object.freeze({
@@ -190,14 +212,16 @@ const THEME_COMPONENTS = Object.freeze({
190
212
  tokens: Object.freeze({
191
213
  color: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
192
214
  fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.md" }),
215
+ // sc-3857 — leading as a MULTIPLE of the font size, so one app-wide value
216
+ // is right at every size; `size` cannot carry it because it rounds.
217
+ lineHeight: Object.freeze({ type: "decimal", min: 0.8, max: 3, step: 0.05 }),
218
+ letterSpacing: Object.freeze({ type: "decimal", min: -2, max: 20, step: 0.1 }),
219
+ textTransform: Object.freeze({ type: "textTransform" }),
193
220
  }),
194
221
  targets: Object.freeze({
195
- "appstudio.text": Object.freeze({ color: "color", fontSize: "fontSize" }),
196
- "appstudio.label": Object.freeze({ color: "color", fontSize: "fontSize" }),
197
- "appstudio.data-value": Object.freeze({
198
- color: "color",
199
- fontSize: "fontSize",
200
- }),
222
+ "appstudio.text": TEXT_TYPOGRAPHY_FIELDS,
223
+ "appstudio.label": TEXT_TYPOGRAPHY_FIELDS,
224
+ "appstudio.data-value": TEXT_TYPOGRAPHY_FIELDS,
201
225
  }),
202
226
  }),
203
227
  });
@@ -2215,7 +2239,26 @@ const CONTRACT = deepFreeze({
2215
2239
  // null, which is how one button stays flat while the rest are gradiented.
2216
2240
  // Additive: no export changed signature and a theme with no gradient token
2217
2241
  // renders exactly as before.
2218
- version: "1.47.0",
2242
+ // 1.48.0: additive (sc-3857) — leading, tracking and case on the `text` scope.
2243
+ // `lineHeight` (a MULTIPLE of the font size), `letterSpacing` (pixels) and
2244
+ // `textTransform` join the existing `color` / `fontSize` tokens and bind to
2245
+ // the identically named per-instance style fields Text, Label and Data Value
2246
+ // now read, so a theme can set app-wide typography and an author still
2247
+ // overrides it per instance. Two new token value types back them: `decimal`
2248
+ // (a clamped 2-decimal number — `size` rounds, so it cannot carry a 1.05
2249
+ // multiplier) and `textTransform`, whose closed enum is published as
2250
+ // `themeComponentTextTransforms`. Additive: no export changed signature and
2251
+ // a theme with none of the new tokens renders exactly as before.
2252
+ //
2253
+ // 1.49.0: additive (REQ-THEME-12) — `themeTokens.typography` gains
2254
+ // `headingFontFamily`, the display face for heading-tier text, and the
2255
+ // per-instance text vocabulary gains `fontRole` (`body` | `heading`) so an
2256
+ // author or Mason marks which text is a heading. `headingFontFamily`
2257
+ // defaults to the resolved `fontFamily`, and `fontRole` defaults to
2258
+ // `body`, so an app that sets neither renders on one typeface exactly as
2259
+ // before. Genuine headings — rich-text h1-h3 and container titles — take
2260
+ // the heading face without the field.
2261
+ version: "1.49.0",
2219
2262
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2220
2263
  hooks: HOOKS,
2221
2264
  primitives: PRIMITIVES,
@@ -2228,6 +2271,7 @@ const CONTRACT = deepFreeze({
2228
2271
  themeTokens: DEFAULT_THEME_TOKENS,
2229
2272
  themeComponents: THEME_COMPONENTS,
2230
2273
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2274
+ themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2231
2275
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2232
2276
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2233
2277
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
package/dist/index.d.ts CHANGED
@@ -306,6 +306,8 @@ export interface ThemeTokens {
306
306
  radii: { sm: number; md: number; lg: number; pill: number };
307
307
  typography: {
308
308
  fontFamily: string;
309
+ /** The display face for heading-tier text. Defaults to `fontFamily`. */
310
+ headingFontFamily: string;
309
311
  sizes: { xs: number; sm: number; md: number; lg: number; xl: number };
310
312
  };
311
313
  }
@@ -44,9 +44,22 @@ function coerceToken(def, value) {
44
44
  if (!Number.isFinite(n)) return undefined;
45
45
  return Math.min(def.max, Math.max(def.min, Math.round(n)));
46
46
  }
47
+ // sc-3857: a leading multiplier / tracking value must survive the decimals
48
+ // `size` rounds away — 1.05 leading is the whole point of the token.
49
+ if (def.type === "decimal") {
50
+ const n = typeof value === "number" ? value : Number(value);
51
+ if (!Number.isFinite(n)) return undefined;
52
+ const clamped = Math.min(def.max, Math.max(def.min, n));
53
+ return Math.round(clamped * 100) / 100;
54
+ }
47
55
  if (def.type === "shadow") {
48
56
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
49
57
  }
58
+ if (def.type === "textTransform") {
59
+ return CONTRACT.themeComponentTextTransforms.includes(value)
60
+ ? value
61
+ : undefined;
62
+ }
50
63
  // sc-3727: the gradient value has its own normaliser on the contract, shared
51
64
  // with the widget render path so both routes agree (CLAUDE.md §3).
52
65
  if (def.type === "gradient") {
@@ -36,9 +36,22 @@ function coerceToken(def, value) {
36
36
  if (!Number.isFinite(n)) return undefined;
37
37
  return Math.min(def.max, Math.max(def.min, Math.round(n)));
38
38
  }
39
+ // sc-3857: a leading multiplier / tracking value must survive the decimals
40
+ // `size` rounds away — 1.05 leading is the whole point of the token.
41
+ if (def.type === "decimal") {
42
+ const n = typeof value === "number" ? value : Number(value);
43
+ if (!Number.isFinite(n)) return undefined;
44
+ const clamped = Math.min(def.max, Math.max(def.min, n));
45
+ return Math.round(clamped * 100) / 100;
46
+ }
39
47
  if (def.type === "shadow") {
40
48
  return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
41
49
  }
50
+ if (def.type === "textTransform") {
51
+ return CONTRACT.themeComponentTextTransforms.includes(value)
52
+ ? value
53
+ : undefined;
54
+ }
42
55
  // sc-3727: the gradient value has its own normaliser on the contract, shared
43
56
  // with the widget render path so both routes agree (CLAUDE.md §3).
44
57
  if (def.type === "gradient") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.70.0",
3
+ "version": "0.72.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",