@colixsystems/widget-sdk 0.66.0 → 0.67.1

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
@@ -17,7 +17,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
17
17
 
18
18
  | Group | Hook (signature) | Returns | Reads / scope |
19
19
  | ----- | ---------------- | ------- | ------------- |
20
- | **CORE** | `useTheme()` | `{ colors, spacing, radii, typography }` | `ctx.workspace.theme` — no scope |
20
+ | **CORE** | `useTheme()` | `{ colors, spacing, radii, typography, components }` | `ctx.workspace.theme` — no scope. `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
21
21
  | **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
22
22
  | **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
23
23
  | **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
@@ -53,7 +53,17 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
53
53
 
54
54
  ## Status
55
55
 
56
- `v0.66.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**.
56
+ `v0.67.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
+
58
+ ### What's new in 0.67.0
59
+
60
+ **The theme can restyle ONE component type — buttons, cards or text — without moving the global palette (sc-1497).** A workspace theme may now carry `themeConfig.components` (`{ button, card, text }`), and the host resolves each scope onto the `styleSchema` fields the target widgets already read. **Nothing changes for a widget author:** you keep reading `props.style` / `useWidgetStyle()`, and an author's per-instance value still wins over a theme token — the theme is the app-wide default underneath it.
61
+
62
+ - **`useTheme()` gains a `components` slice.** It is HOST-OWNED plumbing, not an author API: by the time your component renders, the host has already folded the matching tokens into `props.style`. Do not read `theme.components` and do not re-apply it — you would double-apply the theme and defeat the author's own styling.
63
+ - **New host-only exports on `@colixsystems/widget-sdk/host`:** `normaliseThemeComponents(raw)` and `applyThemeComponentStyle(manifestId, theme, props)`. These are the platform-host surface (the web Player / Studio canvas and the exported Expo app), never the author API — one implementation, so the two hosts cannot diverge.
64
+ - **`CONTRACT.themeComponents` / `CONTRACT.themeComponentShadows`** publish the vocabulary: each scope's tokens, their value types and ranges, and the widget → style-field bindings. `themeTokens.components` defaults to `{}`.
65
+
66
+ `CONTRACT.version` → `1.45.0`. Additive; no existing export changed signature, and an unthemed app renders identically.
57
67
 
58
68
  ### What's new in 0.66.0
59
69
 
package/dist/contract.cjs CHANGED
@@ -31,6 +31,111 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
31
31
  'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
32
32
  sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
33
33
  }),
34
+ // REQ-THEME-15: the tenant's per-component style tokens, folded in by the
35
+ // host so ONE channel carries them to the Player and the export. Empty by
36
+ // default — an unconfigured theme resolves to no component overrides.
37
+ components: Object.freeze({}),
38
+ });
39
+
40
+ // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
41
+ // single lever: remapping `primaryColor` to recolour "the buttons" recolours
42
+ // every element that shares it. A theme may therefore also carry
43
+ // `themeConfig.components` — `{ <scope>: { <token>: <value> } }` — that restyles
44
+ // ONE component type app-wide.
45
+ //
46
+ // This is NOT a second styling vocabulary. Each scope's tokens bind to the
47
+ // REQ-THEME-13 per-instance `styleSchema` fields the target widgets ALREADY
48
+ // read, so a theme-level token needs no widget change: the host resolves the
49
+ // scope's tokens into the widget's `props.style` DEFAULTS and an author's
50
+ // per-instance value still wins.
51
+ //
52
+ // `tokens` declares each token's value type once per scope (what the Mason
53
+ // `set_theme` coercion and the host boundary validate against); `targets` binds
54
+ // token → the style field each widget reads, so one scope can drive differently
55
+ // named fields on different widgets (a Form's submit button is `submitBackground`,
56
+ // a Button's is `background`). The keys of `targets` ARE the widgets in scope.
57
+ //
58
+ // Single source for four consumers — the build runner's set_theme coercion, the
59
+ // shared host resolver, the planner prompt, and the SDK docs — so the vocabulary
60
+ // cannot drift between what Mason may emit and what a host actually applies.
61
+ const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
62
+
63
+ // The card-surface field names shared by every widget that paints its own card
64
+ // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
65
+ const CARD_SURFACE_FIELDS = Object.freeze({
66
+ background: "cardBackground",
67
+ borderColor: "cardBorderColor",
68
+ radius: "cardRadius",
69
+ padding: "cardPadding",
70
+ shadow: "shadow",
71
+ });
72
+
73
+ // A form widget's submit button — the `button` scope reaches it through the
74
+ // form's own submit* fields, so "make the buttons coral" does not skip forms.
75
+ const FORM_SUBMIT_FIELDS = Object.freeze({
76
+ background: "submitBackground",
77
+ textColor: "submitTextColor",
78
+ });
79
+
80
+ const THEME_COMPONENTS = Object.freeze({
81
+ button: Object.freeze({
82
+ label: "Buttons",
83
+ tokens: Object.freeze({
84
+ background: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
85
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onPrimary" }),
86
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
87
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
88
+ fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
89
+ shadow: Object.freeze({ type: "shadow" }),
90
+ }),
91
+ targets: Object.freeze({
92
+ "appstudio.button": Object.freeze({
93
+ background: "background",
94
+ textColor: "textColor",
95
+ borderColor: "borderColor",
96
+ radius: "radius",
97
+ fontSize: "fontSize",
98
+ shadow: "shadow",
99
+ }),
100
+ "appstudio.form-input": FORM_SUBMIT_FIELDS,
101
+ "appstudio.form-builder": FORM_SUBMIT_FIELDS,
102
+ }),
103
+ }),
104
+ card: Object.freeze({
105
+ label: "Cards",
106
+ tokens: Object.freeze({
107
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
108
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
109
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
110
+ padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
111
+ shadow: Object.freeze({ type: "shadow" }),
112
+ }),
113
+ targets: Object.freeze({
114
+ "appstudio.user": CARD_SURFACE_FIELDS,
115
+ "appstudio.data-list": CARD_SURFACE_FIELDS,
116
+ "appstudio.gallery": CARD_SURFACE_FIELDS,
117
+ "appstudio.files": CARD_SURFACE_FIELDS,
118
+ "appstudio.newsfeed": CARD_SURFACE_FIELDS,
119
+ "appstudio.notifications": CARD_SURFACE_FIELDS,
120
+ "appstudio.form-input": CARD_SURFACE_FIELDS,
121
+ "appstudio.form-builder": CARD_SURFACE_FIELDS,
122
+ }),
123
+ }),
124
+ text: Object.freeze({
125
+ label: "Text",
126
+ tokens: Object.freeze({
127
+ color: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
128
+ fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.md" }),
129
+ }),
130
+ targets: Object.freeze({
131
+ "appstudio.text": Object.freeze({ color: "color", fontSize: "fontSize" }),
132
+ "appstudio.label": Object.freeze({ color: "color", fontSize: "fontSize" }),
133
+ "appstudio.data-value": Object.freeze({
134
+ color: "color",
135
+ fontSize: "fontSize",
136
+ }),
137
+ }),
138
+ }),
34
139
  });
35
140
 
36
141
  const HOOKS = [
@@ -43,6 +148,10 @@ const HOOKS = [
43
148
  spacing: "{ xs, sm, md, lg, xl }",
44
149
  radii: "{ sm, md, lg, pill }",
45
150
  typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
151
+ components:
152
+ "{ [scope]: { [token]: value } } — REQ-THEME-15 per-component theme " +
153
+ "tokens. HOST-OWNED: the host already folds them into your " +
154
+ "props.style, so read props.style / useWidgetStyle() instead.",
46
155
  },
47
156
  requiredContextSlice: ["workspace.theme"],
48
157
  scopes: null,
@@ -1960,7 +2069,25 @@ const CONTRACT = deepFreeze({
1960
2069
  // matching, so a comment quoting the bad path is not a finding, and a
1961
2070
  // legitimate third-party relative path can opt out with an
1962
2071
  // `appstudio-lint-ignore no-host-api-url` comment. Additive.
1963
- version: "1.44.0",
2072
+ //
2073
+ // 1.45.0: additive (REQ-THEME-15, sc-1497) — per-component style tokens. A
2074
+ // workspace theme may carry `themeConfig.components` (`{ button, card, text }`)
2075
+ // that restyles ONE component type app-wide instead of remapping the shared
2076
+ // global palette. New `themeComponents` (the scope -> token -> target-field
2077
+ // vocabulary, with each token's value type + range) and
2078
+ // `themeComponentShadows` (the closed shadow enum) publish that vocabulary as
2079
+ // the SINGLE source read by the Mason build runner's set_theme coercion, the
2080
+ // shared host resolver, and the planner prompt. `themeTokens` gains a
2081
+ // `components` namespace (default `{}`) because the resolved theme is the
2082
+ // channel that carries the tokens to both hosts, so `useTheme()` returnShape
2083
+ // gains `components` — HOST-OWNED: the host folds the matching tokens into a
2084
+ // widget's `props.style` before render, so an author still reads
2085
+ // `props.style` / `useWidgetStyle()` and must NOT re-apply this slice. The
2086
+ // resolvers themselves ship on the host-only entry
2087
+ // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2088
+ // export changed signature and a theme with no `components` key resolves to
2089
+ // an empty override, rendering identically to before.
2090
+ version: "1.45.0",
1964
2091
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1965
2092
  hooks: HOOKS,
1966
2093
  primitives: PRIMITIVES,
@@ -1971,6 +2098,8 @@ const CONTRACT = deepFreeze({
1971
2098
  actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
1972
2099
  actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
1973
2100
  themeTokens: DEFAULT_THEME_TOKENS,
2101
+ themeComponents: THEME_COMPONENTS,
2102
+ themeComponentShadows: THEME_COMPONENT_SHADOWS,
1974
2103
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
1975
2104
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
1976
2105
  bannedApis: BANNED_APIS,
package/dist/contract.js CHANGED
@@ -31,6 +31,111 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
31
31
  'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
32
32
  sizes: Object.freeze({ xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 }),
33
33
  }),
34
+ // REQ-THEME-15: the tenant's per-component style tokens, folded in by the
35
+ // host so ONE channel carries them to the Player and the export. Empty by
36
+ // default — an unconfigured theme resolves to no component overrides.
37
+ components: Object.freeze({}),
38
+ });
39
+
40
+ // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
41
+ // single lever: remapping `primaryColor` to recolour "the buttons" recolours
42
+ // every element that shares it. A theme may therefore also carry
43
+ // `themeConfig.components` — `{ <scope>: { <token>: <value> } }` — that restyles
44
+ // ONE component type app-wide.
45
+ //
46
+ // This is NOT a second styling vocabulary. Each scope's tokens bind to the
47
+ // REQ-THEME-13 per-instance `styleSchema` fields the target widgets ALREADY
48
+ // read, so a theme-level token needs no widget change: the host resolves the
49
+ // scope's tokens into the widget's `props.style` DEFAULTS and an author's
50
+ // per-instance value still wins.
51
+ //
52
+ // `tokens` declares each token's value type once per scope (what the Mason
53
+ // `set_theme` coercion and the host boundary validate against); `targets` binds
54
+ // token → the style field each widget reads, so one scope can drive differently
55
+ // named fields on different widgets (a Form's submit button is `submitBackground`,
56
+ // a Button's is `background`). The keys of `targets` ARE the widgets in scope.
57
+ //
58
+ // Single source for four consumers — the build runner's set_theme coercion, the
59
+ // shared host resolver, the planner prompt, and the SDK docs — so the vocabulary
60
+ // cannot drift between what Mason may emit and what a host actually applies.
61
+ const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
62
+
63
+ // The card-surface field names shared by every widget that paints its own card
64
+ // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
65
+ const CARD_SURFACE_FIELDS = Object.freeze({
66
+ background: "cardBackground",
67
+ borderColor: "cardBorderColor",
68
+ radius: "cardRadius",
69
+ padding: "cardPadding",
70
+ shadow: "shadow",
71
+ });
72
+
73
+ // A form widget's submit button — the `button` scope reaches it through the
74
+ // form's own submit* fields, so "make the buttons coral" does not skip forms.
75
+ const FORM_SUBMIT_FIELDS = Object.freeze({
76
+ background: "submitBackground",
77
+ textColor: "submitTextColor",
78
+ });
79
+
80
+ const THEME_COMPONENTS = Object.freeze({
81
+ button: Object.freeze({
82
+ label: "Buttons",
83
+ tokens: Object.freeze({
84
+ background: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
85
+ textColor: Object.freeze({ type: "color", uiDefault: "colors.onPrimary" }),
86
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
87
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
88
+ fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
89
+ shadow: Object.freeze({ type: "shadow" }),
90
+ }),
91
+ targets: Object.freeze({
92
+ "appstudio.button": Object.freeze({
93
+ background: "background",
94
+ textColor: "textColor",
95
+ borderColor: "borderColor",
96
+ radius: "radius",
97
+ fontSize: "fontSize",
98
+ shadow: "shadow",
99
+ }),
100
+ "appstudio.form-input": FORM_SUBMIT_FIELDS,
101
+ "appstudio.form-builder": FORM_SUBMIT_FIELDS,
102
+ }),
103
+ }),
104
+ card: Object.freeze({
105
+ label: "Cards",
106
+ tokens: Object.freeze({
107
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
108
+ borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
109
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
110
+ padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
111
+ shadow: Object.freeze({ type: "shadow" }),
112
+ }),
113
+ targets: Object.freeze({
114
+ "appstudio.user": CARD_SURFACE_FIELDS,
115
+ "appstudio.data-list": CARD_SURFACE_FIELDS,
116
+ "appstudio.gallery": CARD_SURFACE_FIELDS,
117
+ "appstudio.files": CARD_SURFACE_FIELDS,
118
+ "appstudio.newsfeed": CARD_SURFACE_FIELDS,
119
+ "appstudio.notifications": CARD_SURFACE_FIELDS,
120
+ "appstudio.form-input": CARD_SURFACE_FIELDS,
121
+ "appstudio.form-builder": CARD_SURFACE_FIELDS,
122
+ }),
123
+ }),
124
+ text: Object.freeze({
125
+ label: "Text",
126
+ tokens: Object.freeze({
127
+ color: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
128
+ fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.md" }),
129
+ }),
130
+ targets: Object.freeze({
131
+ "appstudio.text": Object.freeze({ color: "color", fontSize: "fontSize" }),
132
+ "appstudio.label": Object.freeze({ color: "color", fontSize: "fontSize" }),
133
+ "appstudio.data-value": Object.freeze({
134
+ color: "color",
135
+ fontSize: "fontSize",
136
+ }),
137
+ }),
138
+ }),
34
139
  });
35
140
 
36
141
  const HOOKS = [
@@ -43,6 +148,10 @@ const HOOKS = [
43
148
  spacing: "{ xs, sm, md, lg, xl }",
44
149
  radii: "{ sm, md, lg, pill }",
45
150
  typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
151
+ components:
152
+ "{ [scope]: { [token]: value } } — REQ-THEME-15 per-component theme " +
153
+ "tokens. HOST-OWNED: the host already folds them into your " +
154
+ "props.style, so read props.style / useWidgetStyle() instead.",
46
155
  },
47
156
  requiredContextSlice: ["workspace.theme"],
48
157
  scopes: null,
@@ -1960,7 +2069,25 @@ const CONTRACT = deepFreeze({
1960
2069
  // matching, so a comment quoting the bad path is not a finding, and a
1961
2070
  // legitimate third-party relative path can opt out with an
1962
2071
  // `appstudio-lint-ignore no-host-api-url` comment. Additive.
1963
- version: "1.44.0",
2072
+ //
2073
+ // 1.45.0: additive (REQ-THEME-15, sc-1497) — per-component style tokens. A
2074
+ // workspace theme may carry `themeConfig.components` (`{ button, card, text }`)
2075
+ // that restyles ONE component type app-wide instead of remapping the shared
2076
+ // global palette. New `themeComponents` (the scope -> token -> target-field
2077
+ // vocabulary, with each token's value type + range) and
2078
+ // `themeComponentShadows` (the closed shadow enum) publish that vocabulary as
2079
+ // the SINGLE source read by the Mason build runner's set_theme coercion, the
2080
+ // shared host resolver, and the planner prompt. `themeTokens` gains a
2081
+ // `components` namespace (default `{}`) because the resolved theme is the
2082
+ // channel that carries the tokens to both hosts, so `useTheme()` returnShape
2083
+ // gains `components` — HOST-OWNED: the host folds the matching tokens into a
2084
+ // widget's `props.style` before render, so an author still reads
2085
+ // `props.style` / `useWidgetStyle()` and must NOT re-apply this slice. The
2086
+ // resolvers themselves ship on the host-only entry
2087
+ // (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
2088
+ // export changed signature and a theme with no `components` key resolves to
2089
+ // an empty override, rendering identically to before.
2090
+ version: "1.45.0",
1964
2091
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
1965
2092
  hooks: HOOKS,
1966
2093
  primitives: PRIMITIVES,
@@ -1971,6 +2098,8 @@ const CONTRACT = deepFreeze({
1971
2098
  actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
1972
2099
  actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
1973
2100
  themeTokens: DEFAULT_THEME_TOKENS,
2101
+ themeComponents: THEME_COMPONENTS,
2102
+ themeComponentShadows: THEME_COMPONENT_SHADOWS,
1974
2103
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
1975
2104
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
1976
2105
  bannedApis: BANNED_APIS,
package/dist/hooks.js CHANGED
@@ -593,6 +593,12 @@ function toDatastoreError(err) {
593
593
  });
594
594
  }
595
595
 
596
+ // sc-1579 — consecutive renders with a different serialized query before
597
+ // `useDatastoreQuery` calls the loop out. High enough that no real interaction
598
+ // reaches it (a settled fetch resets the count), low enough to fire instantly
599
+ // on a genuine loop.
600
+ const VOLATILE_QUERY_RENDER_LIMIT = 10;
601
+
596
602
  /**
597
603
  * Stateful datastore query hook. Returns { data, loading, error, refetch }.
598
604
  *
@@ -682,6 +688,35 @@ export function useDatastoreQuery(table, query) {
682
688
  return null;
683
689
  }
684
690
  })();
691
+
692
+ // sc-1579 — a query argument built from a volatile value (`Date.now()`,
693
+ // `new Date()`, `Math.random()`) serialises differently every render, so the
694
+ // effect below re-fetches forever and the widget never settles. A genuine
695
+ // edit (a typed filter) settles within one render because the re-render the
696
+ // fetch triggers reuses the same argument; a volatile one never does. That
697
+ // gap is the signal — no timers, no heuristics. Warned once per instance,
698
+ // and in production too: the Studio canvas and the widget agent's render
699
+ // smoke check both run production builds, which is where this must surface.
700
+ const volatileKeyRef = useRef({ key: queryKey, changes: 0, warned: false });
701
+ const keyWatch = volatileKeyRef.current;
702
+ if (keyWatch.key === queryKey) {
703
+ keyWatch.changes = 0;
704
+ } else {
705
+ keyWatch.key = queryKey;
706
+ keyWatch.changes += 1;
707
+ if (keyWatch.changes >= VOLATILE_QUERY_RENDER_LIMIT && !keyWatch.warned) {
708
+ keyWatch.warned = true;
709
+ // eslint-disable-next-line no-console
710
+ console.warn(
711
+ `useDatastoreQuery(${JSON.stringify(table)}): the query argument changed on ` +
712
+ `${keyWatch.changes} consecutive renders, so this widget is re-fetching in a ` +
713
+ "loop. Something in the query is regenerated every render — usually a date " +
714
+ "or random value. Compute it once outside render (or with useMemo) and pass " +
715
+ "the stable value in.",
716
+ );
717
+ }
718
+ }
719
+
685
720
  useEffect(() => {
686
721
  doFetch();
687
722
  // eslint-disable-next-line react-hooks/exhaustive-deps
package/dist/host.d.ts CHANGED
@@ -11,3 +11,26 @@ export function resolveProps<T = Record<string, unknown>>(
11
11
  schema: WidgetPropertySchema,
12
12
  props: unknown,
13
13
  ): T;
14
+
15
+ export type ThemeComponentStyle = Record<string, string | number>;
16
+ export type ThemeComponents = Record<string, ThemeComponentStyle>;
17
+
18
+ /**
19
+ * REQ-THEME-15 host helper: validates a raw `themeConfig.components` blob down
20
+ * to `CONTRACT.themeComponents` — unknown scopes/tokens and malformed values are
21
+ * dropped. Called when a host folds the tenant theme into the resolved widget
22
+ * theme, so `theme.components` is always clean.
23
+ */
24
+ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
25
+
26
+ /**
27
+ * REQ-THEME-15 host render-boundary helper: folds the theme's per-component
28
+ * tokens into a widget's props as `style` DEFAULTS, with the author's
29
+ * per-instance values winning. Returns the same `props` reference when the theme
30
+ * sets nothing for this widget. Applied by the platform hosts, never by authors.
31
+ */
32
+ export function applyThemeComponentStyle<T = Record<string, unknown>>(
33
+ manifestId: string,
34
+ theme: { components?: ThemeComponents } | null | undefined,
35
+ props: T,
36
+ ): T;
package/dist/host.js CHANGED
@@ -10,3 +10,13 @@
10
10
  // not appear in the author import surface or the Developer guide.
11
11
 
12
12
  export { resolveProps } from "./property-schema.js";
13
+
14
+ // REQ-THEME-15: the per-component theme-token resolvers. `normaliseThemeComponents`
15
+ // validates a raw `themeConfig.components` blob when the host folds it into the
16
+ // resolved widget theme; `applyThemeComponentStyle` turns that slice into a
17
+ // widget's `style` defaults at the render boundary. One implementation for both
18
+ // hosts, so the Player and the Expo export cannot diverge.
19
+ export {
20
+ normaliseThemeComponents,
21
+ applyThemeComponentStyle,
22
+ } from "./theme-components.js";
@@ -0,0 +1,134 @@
1
+ // CommonJS mirror of theme-components.js — the Mason build runner (CJS)
2
+ // validates a `set_theme` components blob against the SAME vocabulary the hosts
3
+ // apply, so what a planner may persist and what a widget renders cannot diverge.
4
+ //
5
+ // The BODY below is copied VERBATIM from theme-components.js; only the module
6
+ // syntax differs. theme-components-parity.test.js pins both facts — identical
7
+ // bodies AND identical behaviour over a shared case table — so drift fails CI.
8
+
9
+ // REQ-THEME-15 (sc-1497) — the host side of per-component style tokens.
10
+ //
11
+ // Host-integration surface, re-exported from `host.js`: consumed ONLY by the
12
+ // platform hosts that render widgets (the web Player / Studio Canvas via
13
+ // buildHostWidgetContext.js, and the exported Expo app's generated WidgetHost),
14
+ // never by a widget author. A widget keeps reading `props.style` /
15
+ // `useWidgetStyle()` and never learns a theme token was involved.
16
+ //
17
+ // Living here — one implementation both hosts import — is what makes the tokens
18
+ // render identically in the Player and the export (widget-parity skill). The
19
+ // alternative, a copy per host, is the drift this file exists to prevent.
20
+ //
21
+ // `CONTRACT.themeComponents` is the single source of the vocabulary: which
22
+ // scopes exist, each token's value type, and the `styleSchema` field each target
23
+ // widget reads. The Mason build runner validates `set_theme` against the SAME
24
+ // literal, so what a planner may emit and what a host applies cannot diverge.
25
+
26
+ const { CONTRACT } = require("./contract.cjs");
27
+
28
+ // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
29
+ // must never drop a colour the runner already accepted.
30
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
31
+
32
+ function isPlainObject(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ function coerceToken(def, value) {
37
+ if (def.type === "color") {
38
+ if (typeof value !== "string") return undefined;
39
+ const trimmed = value.trim();
40
+ return HEX_COLOR.test(trimmed) ? trimmed : undefined;
41
+ }
42
+ if (def.type === "size") {
43
+ const n = typeof value === "number" ? value : Number(value);
44
+ if (!Number.isFinite(n)) return undefined;
45
+ return Math.min(def.max, Math.max(def.min, Math.round(n)));
46
+ }
47
+ if (def.type === "shadow") {
48
+ return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * Validate a raw `themeConfig.components` blob down to the declared vocabulary:
55
+ * unknown scopes, unknown tokens and malformed values are dropped, and an
56
+ * emptied scope is omitted rather than kept as `{}`.
57
+ *
58
+ * `theme_config` is a JSON bag any workspace admin can PUT verbatim, so this is
59
+ * the guard that keeps a hand-edited theme from handing a widget a style value
60
+ * its renderer can't use. Returns a frozen-shaped plain object, never null, so
61
+ * callers can fold the result in unconditionally.
62
+ *
63
+ * @param {unknown} raw — `themeConfig.components`.
64
+ * @returns {Record<string, Record<string, string|number>>}
65
+ */
66
+ function normaliseThemeComponents(raw) {
67
+ if (!isPlainObject(raw)) return {};
68
+ const out = {};
69
+ for (const [scope, definition] of Object.entries(CONTRACT.themeComponents)) {
70
+ const requested = raw[scope];
71
+ if (!isPlainObject(requested)) continue;
72
+ const tokens = {};
73
+ for (const [token, def] of Object.entries(definition.tokens)) {
74
+ if (requested[token] === undefined) continue;
75
+ const coerced = coerceToken(def, requested[token]);
76
+ if (coerced !== undefined) tokens[token] = coerced;
77
+ }
78
+ if (Object.keys(tokens).length > 0) out[scope] = tokens;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * The per-component style fields that apply to one widget, keyed by the
85
+ * `styleSchema` field name the widget actually reads. A widget may sit in more
86
+ * than one scope (a Form is a `card` whose submit button is a `button`), so
87
+ * every matching scope contributes.
88
+ *
89
+ * Re-validates the slice rather than trusting it: this is the last boundary
90
+ * before a value becomes a widget's style, and a host that folded the theme in
91
+ * without normalising must not be able to hand a widget malformed input.
92
+ *
93
+ * @returns {Record<string, string|number>|null} null when nothing applies.
94
+ */
95
+ function componentStyleFor(manifestId, components) {
96
+ if (typeof manifestId !== "string") return null;
97
+ const validated = normaliseThemeComponents(components);
98
+ const out = {};
99
+ for (const [scope, definition] of Object.entries(CONTRACT.themeComponents)) {
100
+ const tokens = validated[scope];
101
+ const fields = definition.targets[manifestId];
102
+ if (!tokens || !fields) continue;
103
+ for (const [token, field] of Object.entries(fields)) {
104
+ if (tokens[token] !== undefined) out[field] = tokens[token];
105
+ }
106
+ }
107
+ return Object.keys(out).length > 0 ? out : null;
108
+ }
109
+
110
+ /**
111
+ * Fold the theme's per-component tokens into a widget's props as `style`
112
+ * DEFAULTS. The author's per-instance REQ-THEME-13 values are spread last and
113
+ * therefore always win — the theme token is the app-wide baseline, the
114
+ * Properties Panel is the final word.
115
+ *
116
+ * Returns the SAME `props` reference when the theme sets nothing for this
117
+ * widget, so an unthemed app takes no extra render work and behaves exactly as
118
+ * it did before REQ-THEME-15.
119
+ *
120
+ * @param {string} manifestId — the widget's canonical manifest id.
121
+ * @param {object} theme — the resolved widget theme (`workspace.theme`); its
122
+ * `components` slice is read.
123
+ * @param {object} props — the widget's resolved props (post-`resolveProps`).
124
+ * @returns {object} props, with `style` folded when the theme applies.
125
+ */
126
+ function applyThemeComponentStyle(manifestId, theme, props) {
127
+ const themed = componentStyleFor(manifestId, theme && theme.components);
128
+ if (!themed) return props;
129
+ const base = isPlainObject(props) ? props : {};
130
+ const authored = isPlainObject(base.style) ? base.style : null;
131
+ return { ...base, style: { ...themed, ...authored } };
132
+ }
133
+
134
+ module.exports = { normaliseThemeComponents, applyThemeComponentStyle };
@@ -0,0 +1,124 @@
1
+ // REQ-THEME-15 (sc-1497) — the host side of per-component style tokens.
2
+ //
3
+ // Host-integration surface, re-exported from `host.js`: consumed ONLY by the
4
+ // platform hosts that render widgets (the web Player / Studio Canvas via
5
+ // buildHostWidgetContext.js, and the exported Expo app's generated WidgetHost),
6
+ // never by a widget author. A widget keeps reading `props.style` /
7
+ // `useWidgetStyle()` and never learns a theme token was involved.
8
+ //
9
+ // Living here — one implementation both hosts import — is what makes the tokens
10
+ // render identically in the Player and the export (widget-parity skill). The
11
+ // alternative, a copy per host, is the drift this file exists to prevent.
12
+ //
13
+ // `CONTRACT.themeComponents` is the single source of the vocabulary: which
14
+ // scopes exist, each token's value type, and the `styleSchema` field each target
15
+ // widget reads. The Mason build runner validates `set_theme` against the SAME
16
+ // literal, so what a planner may emit and what a host applies cannot diverge.
17
+
18
+ import { CONTRACT } from "./contract.js";
19
+
20
+ // 3-, 6- and 8-digit hex, matching what the build runner persists — the host
21
+ // must never drop a colour the runner already accepted.
22
+ const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
23
+
24
+ function isPlainObject(value) {
25
+ return value !== null && typeof value === "object" && !Array.isArray(value);
26
+ }
27
+
28
+ function coerceToken(def, value) {
29
+ if (def.type === "color") {
30
+ if (typeof value !== "string") return undefined;
31
+ const trimmed = value.trim();
32
+ return HEX_COLOR.test(trimmed) ? trimmed : undefined;
33
+ }
34
+ if (def.type === "size") {
35
+ const n = typeof value === "number" ? value : Number(value);
36
+ if (!Number.isFinite(n)) return undefined;
37
+ return Math.min(def.max, Math.max(def.min, Math.round(n)));
38
+ }
39
+ if (def.type === "shadow") {
40
+ return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ /**
46
+ * Validate a raw `themeConfig.components` blob down to the declared vocabulary:
47
+ * unknown scopes, unknown tokens and malformed values are dropped, and an
48
+ * emptied scope is omitted rather than kept as `{}`.
49
+ *
50
+ * `theme_config` is a JSON bag any workspace admin can PUT verbatim, so this is
51
+ * the guard that keeps a hand-edited theme from handing a widget a style value
52
+ * its renderer can't use. Returns a frozen-shaped plain object, never null, so
53
+ * callers can fold the result in unconditionally.
54
+ *
55
+ * @param {unknown} raw — `themeConfig.components`.
56
+ * @returns {Record<string, Record<string, string|number>>}
57
+ */
58
+ export function normaliseThemeComponents(raw) {
59
+ if (!isPlainObject(raw)) return {};
60
+ const out = {};
61
+ for (const [scope, definition] of Object.entries(CONTRACT.themeComponents)) {
62
+ const requested = raw[scope];
63
+ if (!isPlainObject(requested)) continue;
64
+ const tokens = {};
65
+ for (const [token, def] of Object.entries(definition.tokens)) {
66
+ if (requested[token] === undefined) continue;
67
+ const coerced = coerceToken(def, requested[token]);
68
+ if (coerced !== undefined) tokens[token] = coerced;
69
+ }
70
+ if (Object.keys(tokens).length > 0) out[scope] = tokens;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * The per-component style fields that apply to one widget, keyed by the
77
+ * `styleSchema` field name the widget actually reads. A widget may sit in more
78
+ * than one scope (a Form is a `card` whose submit button is a `button`), so
79
+ * every matching scope contributes.
80
+ *
81
+ * Re-validates the slice rather than trusting it: this is the last boundary
82
+ * before a value becomes a widget's style, and a host that folded the theme in
83
+ * without normalising must not be able to hand a widget malformed input.
84
+ *
85
+ * @returns {Record<string, string|number>|null} null when nothing applies.
86
+ */
87
+ function componentStyleFor(manifestId, components) {
88
+ if (typeof manifestId !== "string") return null;
89
+ const validated = normaliseThemeComponents(components);
90
+ const out = {};
91
+ for (const [scope, definition] of Object.entries(CONTRACT.themeComponents)) {
92
+ const tokens = validated[scope];
93
+ const fields = definition.targets[manifestId];
94
+ if (!tokens || !fields) continue;
95
+ for (const [token, field] of Object.entries(fields)) {
96
+ if (tokens[token] !== undefined) out[field] = tokens[token];
97
+ }
98
+ }
99
+ return Object.keys(out).length > 0 ? out : null;
100
+ }
101
+
102
+ /**
103
+ * Fold the theme's per-component tokens into a widget's props as `style`
104
+ * DEFAULTS. The author's per-instance REQ-THEME-13 values are spread last and
105
+ * therefore always win — the theme token is the app-wide baseline, the
106
+ * Properties Panel is the final word.
107
+ *
108
+ * Returns the SAME `props` reference when the theme sets nothing for this
109
+ * widget, so an unthemed app takes no extra render work and behaves exactly as
110
+ * it did before REQ-THEME-15.
111
+ *
112
+ * @param {string} manifestId — the widget's canonical manifest id.
113
+ * @param {object} theme — the resolved widget theme (`workspace.theme`); its
114
+ * `components` slice is read.
115
+ * @param {object} props — the widget's resolved props (post-`resolveProps`).
116
+ * @returns {object} props, with `style` folded when the theme applies.
117
+ */
118
+ export function applyThemeComponentStyle(manifestId, theme, props) {
119
+ const themed = componentStyleFor(manifestId, theme && theme.components);
120
+ if (!themed) return props;
121
+ const base = isPlainObject(props) ? props : {};
122
+ const authored = isPlainObject(base.style) ? base.style : null;
123
+ return { ...base, style: { ...themed, ...authored } };
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.66.0",
3
+ "version": "0.67.1",
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-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.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__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.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-record-permissions.test.js src/__tests__/hooks-geolocation.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-image-height.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__/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"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=18"