@colixsystems/widget-sdk 0.85.1 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/contract.js CHANGED
@@ -72,6 +72,9 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
72
72
  }),
73
73
  elevation: ELEVATION,
74
74
  spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
75
+ // REQ-THEME-LOOK: multiplies every layout spacing value at render. 1 is
76
+ // unchanged, so a theme that never sets it renders exactly as before.
77
+ spacingScale: 1,
75
78
  radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
76
79
  typography: Object.freeze({
77
80
  fontFamily:
@@ -86,6 +89,12 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
86
89
  // host so ONE channel carries them to the Player and the export. Empty by
87
90
  // default — an unconfigured theme resolves to no component overrides.
88
91
  components: Object.freeze({}),
92
+ // REQ-THEME-ELEMENT: the tenant's per-WIDGET-TYPE style values, keyed by
93
+ // manifest id. `components` above restyles a whole scope through a shared
94
+ // vocabulary, which only reaches a widget the vocabulary knows about; this
95
+ // reaches ANY widget by naming it, using that widget's OWN styleSchema field
96
+ // names. Empty by default.
97
+ widgetStyles: Object.freeze({}),
89
98
  });
90
99
 
91
100
  // REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
@@ -130,6 +139,32 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
130
139
  defaultAngle: 180,
131
140
  });
132
141
 
142
+ // REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
143
+ // NODES -- a container states its own `padding` / `gap` / `margin` -- so a plain
144
+ // theme key could never make an existing app breathe: every node already
145
+ // carried a value and there was no default left to change. This scales those
146
+ // node values at RENDER time instead, on both hosts, which is why it moves a
147
+ // page that was authored long ago. SPACING only -- never radii, font sizes or
148
+ // minHeight -- so a look keeps its shape while its air moves, and the author’s
149
+ // relative proportions survive: a tight table stays tighter than the card
150
+ // beside it.
151
+ // REQ-THEME-ELEMENT: bounds on the per-widget-type style map. `theme_config` is
152
+ // an unbounded JSON bag that an UNAUTHENTICATED GET /tenant/config returns on
153
+ // every cold Player start and that the compiler bakes verbatim into the native
154
+ // export -- so a map that grows with the widget catalog needs a stated ceiling.
155
+ // Declared here so the coercer, the Studio control and the planner prompt agree.
156
+ const THEME_WIDGET_STYLES = Object.freeze({
157
+ // One entry per widget TYPE, not per instance, so this is generous.
158
+ maxWidgets: 200,
159
+ // Matches the styleSchema field cap the widget agent is held to.
160
+ maxFieldsPerWidget: 12,
161
+ });
162
+ const THEME_SPACING_SCALE = Object.freeze({
163
+ min: 0.5,
164
+ max: 2,
165
+ default: 1,
166
+ });
167
+
133
168
  // The card-surface field names shared by every widget that paints its own card
134
169
  // (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
135
170
  const CARD_SURFACE_FIELDS = Object.freeze({
@@ -159,6 +194,23 @@ const FORM_SUBMIT_FIELDS = Object.freeze({
159
194
  gradient: "submitGradient",
160
195
  });
161
196
 
197
+ // REQ-THEME-WIDGET: the card fields whose NAMES are unambiguous, so they bind to
198
+ // ANY widget that reads them -- including a Mason-generated one, whose id can
199
+ // never appear in a hand-maintained allowlist. That allowlist is why "make the
200
+ // cards darker" reached the nine built-ins and nothing else.
201
+ //
202
+ // `shadow` is deliberately ABSENT: its name is bare and shared with the button
203
+ // scope, so binding it by name would cross the scopes. The bare names stay on
204
+ // the allowlist for exactly that reason -- `appstudio.image` also reads a
205
+ // `background` field, and the button scope must not leak into it.
206
+ const CARD_UNIVERSAL_FIELDS = Object.freeze({
207
+ background: "cardBackground",
208
+ borderColor: "cardBorderColor",
209
+ radius: "cardRadius",
210
+ padding: "cardPadding",
211
+ gradient: "cardGradient",
212
+ });
213
+
162
214
  const THEME_COMPONENTS = Object.freeze({
163
215
  button: Object.freeze({
164
216
  label: "Buttons",
@@ -187,6 +239,7 @@ const THEME_COMPONENTS = Object.freeze({
187
239
  }),
188
240
  card: Object.freeze({
189
241
  label: "Cards",
242
+ universalFields: CARD_UNIVERSAL_FIELDS,
190
243
  tokens: Object.freeze({
191
244
  background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
192
245
  borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
@@ -243,8 +296,18 @@ const HOOKS = [
243
296
  signature: "useTheme()",
244
297
  returnShape: {
245
298
  colors:
246
- "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }",
299
+ "{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }" +
300
+ "REQ-THEME-SURFACE: the surface group (surface / surfaceMuted / onSurface / " +
301
+ "onSurfaceMuted / border) describes the surface your widget SITS ON, not the " +
302
+ "page: a container painting its own background re-derives them for its " +
303
+ "subtree. Read them and your text is readable wherever the widget lands; " +
304
+ "there is nothing to opt into.",
247
305
  spacing: "{ xs, sm, md, lg, xl }",
306
+ spacingScale:
307
+ "number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
308
+ "HOST-OWNED: the host already scales layout spacing by it. Read it only " +
309
+ "if your widget draws its own internal spacing and wants to breathe with " +
310
+ "the rest of the app.",
248
311
  radii: "{ sm, md, lg, pill }",
249
312
  typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
250
313
  components:
@@ -436,6 +499,7 @@ const HOOKS = [
436
499
  signature: "useNavigation()",
437
500
  returnShape: {
438
501
  goTo: "(pageId: string, params?: object) => void",
502
+ openLink: "(link: string) => boolean",
439
503
  goBack: "() => void",
440
504
  push: "(pageId: string, params?: object) => void",
441
505
  replace: "(pageId: string, params?: object) => void",
@@ -1091,10 +1155,12 @@ const HOOKS = [
1091
1155
  description:
1092
1156
  "Surfaces a short auto-dismissing notification. Returns { showToast }. " +
1093
1157
  "showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
1094
- "asks the host to render a workspace-themed toast. If the host hasn't " +
1095
- "wired a renderer, the web variant dispatches an 'appstudio:widget-toast' " +
1096
- "CustomEvent on window; native logs to the console. The widget never " +
1097
- "owns the toast UI that's the host's responsibility.",
1158
+ "renders a workspace-themed toast. Both shipping hosts paint it — the " +
1159
+ "web Player and the exported Expo app so a confirmation you raise IS " +
1160
+ "seen by the user; use it to confirm every write. The widget never owns " +
1161
+ "the toast UI, so never build your own banner or call Alert.alert for a " +
1162
+ "routine save. An authoring preview (the Studio canvas) wires no " +
1163
+ "renderer, and there the call is simply a no-op.",
1098
1164
  returnShape: {
1099
1165
  showToast: "({ kind, message }) => void",
1100
1166
  },
@@ -1658,9 +1724,10 @@ const WIDGET_CONTEXT_SHAPE = {
1658
1724
  description:
1659
1725
  "Optional host toast slot. { showToast({ kind, message }): void }. " +
1660
1726
  "The host populates this to render workspace-themed notifications " +
1661
- "from any widget that calls useToast(). When omitted the SDK falls " +
1662
- "back to dispatching an 'appstudio:widget-toast' CustomEvent on web " +
1663
- "and console.log on native.",
1727
+ "from any widget that calls useToast(). Both rendering hosts wire it " +
1728
+ "(the web Player and the compiler's native WidgetHost); authoring " +
1729
+ "surfaces omit it, and the SDK then falls back to dispatching an " +
1730
+ "'appstudio:widget-toast' CustomEvent on web and console.log on native.",
1664
1731
  required: false,
1665
1732
  fields: { showToast: "function" },
1666
1733
  },
@@ -2635,6 +2702,28 @@ const CONTRACT = deepFreeze({
2635
2702
  // public endpoint instead, which skips the cache, the metering and the
2636
2703
  // workspace's provider. Publishing the host list here keeps the linter,
2637
2704
  // the Developer guide and the agent prompt reading one source.
2705
+ // 1.52.0: additive (REQ-THEME-LOOK) -- `themeTokens.spacingScale` (default 1)
2706
+ // and `themeSpacingScale`, its clamp bounds. A spacing control in the
2707
+ // Studio could reach nothing that mattered, because layout spacing is
2708
+ // authored ONTO each node (`padding` / `gap` / `margin`) rather than
2709
+ // inherited from the theme -- so no theme key could make a built app
2710
+ // breathe. Both hosts now multiply a node’s resolved spacing by this scale
2711
+ // at render. SPACING only: radii, font sizes and minHeight are untouched,
2712
+ // so a look keeps its shape and the author’s proportions hold. Additive:
2713
+ // no export changed signature and a theme without the key resolves to 1,
2714
+ // rendering identically to before.
2715
+ // 1.53.0: fix (REQ-THEME-17) -- `isHexColor` accepts the 8-digit
2716
+ // `#RRGGBBAA` form. The Studio has put an opacity row on every colour
2717
+ // field since sc-4158, so a brand colour or a page background routinely
2718
+ // arrives with alpha -- and this predicate rejecting it made
2719
+ // `themeConfigToTokens` DROP the key, so a translucent dark background
2720
+ // stopped reaching deriveSurfaceTokens and every panel fell back to white.
2721
+ // A second, alpha-aware predicate already existed beside it for gradients,
2722
+ // which is why the asymmetry kept being rediscovered surface by surface;
2723
+ // there is now one. `hexChannels` ignores the alpha pair, so contrast and
2724
+ // the derived tints still reason about the opaque colour while the value
2725
+ // keeps its transparency. Widening only: every input accepted before is
2726
+ // accepted now, and unchanged.
2638
2727
  // 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
2639
2728
  // `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
2640
2729
  // themselves out at a fixed size — UserManagement's rows alone carried
@@ -2666,7 +2755,36 @@ const CONTRACT = deepFreeze({
2666
2755
  // Developer guide have always said, so a widget written against the
2667
2756
  // declared shape destructured a function and threw on first interaction.
2668
2757
  // Declared as a bare callable now; no runtime behaviour changed.
2669
- version: "1.60.1",
2758
+ // 1.61.0: additive (REQ-THEME-SURFACE / -CARD / -WIDGET / -ELEMENT) -- the
2759
+ // theme reaches the elements an app is built from. Four things it could
2760
+ // not touch before, and one mechanism each:
2761
+ // * `deriveSurfaceTokens` (host export) -- the surface/text/border set is
2762
+ // derived per PAINTED SURFACE, not once per page, so an UNSET text
2763
+ // colour is readable inside a coloured container. It was a hand-mirrored
2764
+ // copy per host; now one symmetric implementation (a light fill resolves
2765
+ // to the light tokens instead of returning null).
2766
+ // * `themeComponents.card.universalFields` -- the card scope's unambiguous
2767
+ // field NAMES bind to any widget that declares them, so a widget whose id
2768
+ // can never appear in a hand-maintained allowlist still follows "Cards".
2769
+ // The bare names (`background`, `textColor`, `color`, `fontSize`,
2770
+ // `shadow`) keep the allowlist: they mean different things per scope.
2771
+ // `applyThemeComponentStyle` takes an optional 4th `styleSchema` so the
2772
+ // name-bound fields land only on a widget that reads them.
2773
+ // * `themeTokens.widgetStyles` + `themeWidgetStyles` bounds +
2774
+ // `normaliseWidgetStyles` (host export) -- app-wide values keyed by
2775
+ // widget MANIFEST ID and then by that widget's OWN styleSchema field
2776
+ // names, so `panelFill` is as reachable as `cardBackground`: no shared
2777
+ // vocabulary, no allowlist, no naming requirement. Validated
2778
+ // structurally, because the key space is a workspace's widget catalog
2779
+ // rather than this contract, and bounded because `theme_config` is
2780
+ // unbounded, read unauthenticated on every cold Player start, and baked
2781
+ // verbatim into the native export.
2782
+ // Precedence: contract default -> palette -> `components.<scope>` ->
2783
+ // `widgetStyles[manifestId]` -> the author's per-instance `props.style`.
2784
+ // Naming one widget is strictly more specific than restyling a scope, and
2785
+ // the Properties Panel stays the final word. Additive throughout: a theme
2786
+ // that sets none of it resolves exactly as before.
2787
+ version: "1.61.1",
2670
2788
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
2671
2789
  hooks: HOOKS,
2672
2790
  primitives: PRIMITIVES,
@@ -2681,6 +2799,8 @@ const CONTRACT = deepFreeze({
2681
2799
  themeComponentShadows: THEME_COMPONENT_SHADOWS,
2682
2800
  themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
2683
2801
  themeComponentGradient: THEME_COMPONENT_GRADIENT,
2802
+ themeSpacingScale: THEME_SPACING_SCALE,
2803
+ themeWidgetStyles: THEME_WIDGET_STYLES,
2684
2804
  widgetContextShape: WIDGET_CONTEXT_SHAPE,
2685
2805
  bundleExportContract: BUNDLE_EXPORT_CONTRACT,
2686
2806
  bannedApis: BANNED_APIS,
@@ -2720,12 +2840,31 @@ function requiredContextKeys() {
2720
2840
  // one source removes that drift (CLAUDE.md §3).
2721
2841
  // ---------------------------------------------------------------------------
2722
2842
 
2723
- const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
2843
+ // REQ-THEME-17 / sc-4158 — 3, 6 or 8 digits. The 8-digit form carries alpha,
2844
+ // and this predicate accepting it is load-bearing: the Studio puts an opacity
2845
+ // row on every colour field, so `primaryColor` and `backgroundColor` routinely
2846
+ // arrive as `#RRGGBBAA`. While this rejected them, `themeConfigToTokens` DROPPED
2847
+ // the key outright — a translucent page background stopped reaching
2848
+ // deriveSurfaceTokens and every panel in the app fell back to white.
2849
+ //
2850
+ // There used to be a second, alpha-aware predicate beside this one for
2851
+ // gradients, which is how the asymmetry kept being rediscovered: each new
2852
+ // alpha-carrying surface met the strict one first. There is now only this.
2853
+ const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
2724
2854
 
2725
2855
  function isHexColor(value) {
2726
2856
  return typeof value === "string" && HEX_RE.test(value);
2727
2857
  }
2728
2858
 
2859
+ /**
2860
+ * The R/G/B bytes, with any alpha pair IGNORED.
2861
+ *
2862
+ * Deliberate: everything downstream of this is luminance maths — contrast,
2863
+ * readable text, derived tints — and none of it can composite without knowing
2864
+ * the backdrop, which a token table does not have. Alpha survives in the VALUE
2865
+ * (the host hands `#RRGGBBAA` straight to the renderer); it just takes no part
2866
+ * in deciding whether a colour reads as light or dark.
2867
+ */
2729
2868
  function hexChannels(hex) {
2730
2869
  let h = hex.slice(1);
2731
2870
  if (h.length === 3) {
@@ -2810,6 +2949,54 @@ function deriveAccentTints(primary, surface, onSurface) {
2810
2949
  };
2811
2950
  }
2812
2951
 
2952
+ // REQ-THEME-SURFACE: how far a derived surface travels toward white, and the
2953
+ // light-on-dark text pair a dark surface carries.
2954
+ const SURFACE_LIFT = Object.freeze({ surface: 0.1, surfaceMuted: 0.05, border: 0.18 });
2955
+ const DARK_SURFACE_TEXT = Object.freeze({
2956
+ onSurface: "#f8fafc",
2957
+ onSurfaceMuted: "#cbd5e1",
2958
+ });
2959
+
2960
+ /**
2961
+ * REQ-THEME-DARK: the coherent surface / text / border set one background wants.
2962
+ * A dark background lifts its panels toward white and goes light-on-dark; a
2963
+ * light one resolves to the contract's own light tokens.
2964
+ *
2965
+ * REQ-THEME-SURFACE: applied to the PAGE background and — because every surface
2966
+ * a page is built from may paint its own fill — to a CONTAINER background too.
2967
+ * That is what makes an UNSET text colour readable inside a dark card on a light
2968
+ * page, which is in turn what lets a built app follow its theme instead of
2969
+ * carrying a baked-in hex for every heading.
2970
+ *
2971
+ * Returns all five keys together, never a subset: layering a dark card's text
2972
+ * colour over a light ancestor's surface is exactly how light-on-light happens.
2973
+ * `null` only when the colour is unusable, letting the caller keep what it had.
2974
+ */
2975
+ function deriveSurfaceTokens(backgroundColor) {
2976
+ if (!isHexColor(backgroundColor)) return null;
2977
+ // "Dark" = the background wants light text (same luminance test as the
2978
+ // on-color contrast picker).
2979
+ const wantsLightText =
2980
+ readableTextColor(backgroundColor, "__dark__", "__light__") === "__light__";
2981
+ if (!wantsLightText) {
2982
+ const base = DEFAULT_THEME_TOKENS.colors;
2983
+ return {
2984
+ surface: base.surface,
2985
+ surfaceMuted: base.surfaceMuted,
2986
+ onSurface: base.onSurface,
2987
+ onSurfaceMuted: base.onSurfaceMuted,
2988
+ border: base.border,
2989
+ };
2990
+ }
2991
+ return {
2992
+ surface: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surface),
2993
+ surfaceMuted: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surfaceMuted),
2994
+ onSurface: DARK_SURFACE_TEXT.onSurface,
2995
+ onSurfaceMuted: DARK_SURFACE_TEXT.onSurfaceMuted,
2996
+ border: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.border),
2997
+ };
2998
+ }
2999
+
2813
3000
  /**
2814
3001
  * Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
2815
3002
  * `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
@@ -2828,9 +3015,10 @@ function gradientAngleToVector(angle) {
2828
3015
  };
2829
3016
  }
2830
3017
 
2831
- // Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
2832
- // persists 8-digit component colours; the host must not drop what it accepted.
2833
- const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
3018
+ // Gradients used to need their own alpha-aware predicate because HEX_RE was not.
3019
+ // HEX_RE is now, so this is the same check by another name kept only as a
3020
+ // local alias so the call sites below read as before.
3021
+ const GRADIENT_HEX_RE = HEX_RE;
2834
3022
 
2835
3023
  /**
2836
3024
  * sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
@@ -2855,8 +3043,33 @@ function normaliseComponentGradient(raw) {
2855
3043
  return { from, to, angle: ((deg % 360) + 360) % 360 };
2856
3044
  }
2857
3045
 
3046
+ // REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
3047
+ // because BOTH hosts multiply node spacing by it and a disagreement here would
3048
+ // re-space an exported page against the Player. Anything absent, non-finite or
3049
+ // out of band resolves to 1 (unchanged) rather than throwing -- `theme_config`
3050
+ // is a JSON bag a workspace admin can PUT verbatim.
3051
+ function clampSpacingScale(value) {
3052
+ // Absent means UNSET, not "as tight as possible" — Number(null) and Number("")
3053
+ // are both 0, which would otherwise clamp an untouched theme to the minimum.
3054
+ if (value === null || value === undefined || value === "") {
3055
+ return THEME_SPACING_SCALE.default;
3056
+ }
3057
+ const n = typeof value === "number" ? value : Number(value);
3058
+ if (!Number.isFinite(n)) return THEME_SPACING_SCALE.default;
3059
+ return Math.min(Math.max(n, THEME_SPACING_SCALE.min), THEME_SPACING_SCALE.max);
3060
+ }
3061
+
3062
+ // REQ-THEME-LOOK: apply that scale to ONE spacing value. Rounded to whole
3063
+ // pixels so a scaled gap stays on the same pixel grid as an unscaled one.
3064
+ function scaleSpacing(value, scale) {
3065
+ if (typeof value !== "number" || !Number.isFinite(value)) return value;
3066
+ return Math.round(value * clampSpacingScale(scale));
3067
+ }
3068
+
2858
3069
  export {
2859
3070
  CONTRACT,
3071
+ clampSpacingScale,
3072
+ scaleSpacing,
2860
3073
  isHookAllowed,
2861
3074
  requiredContextKeys,
2862
3075
  isHexColor,
@@ -2864,6 +3077,7 @@ export {
2864
3077
  contrastRatio,
2865
3078
  readableTextColor,
2866
3079
  deriveAccentTints,
3080
+ deriveSurfaceTokens,
2867
3081
  gradientAngleToVector,
2868
3082
  normaliseComponentGradient,
2869
3083
  formatMoneyIn,
package/dist/host.d.ts CHANGED
@@ -25,6 +25,12 @@ export type ThemeComponentStyle = Record<
25
25
  >;
26
26
  export type ThemeComponents = Record<string, ThemeComponentStyle>;
27
27
 
28
+ // REQ-THEME-ELEMENT: app-wide style values keyed by widget MANIFEST ID, then by
29
+ // that widget's own styleSchema field name. Values are structural (the
30
+ // authoritative type is the widget's own schema), so this mirrors
31
+ // ThemeComponentStyle rather than naming a closed vocabulary.
32
+ export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
33
+
28
34
  /**
29
35
  * REQ-THEME-15 host helper: validates a raw `themeConfig.components` blob down
30
36
  * to `CONTRACT.themeComponents` — unknown scopes/tokens and malformed values are
@@ -33,6 +39,14 @@ export type ThemeComponents = Record<string, ThemeComponentStyle>;
33
39
  */
34
40
  export function normaliseThemeComponents(raw: unknown): ThemeComponents;
35
41
 
42
+ /**
43
+ * REQ-THEME-ELEMENT host helper: validates a raw `themeConfig.widgetStyles` blob.
44
+ * Structural only — the key space is the workspace's widget catalog, not the
45
+ * contract, so the authoritative field type is the widget's own styleSchema.
46
+ * Bounded by `CONTRACT.themeWidgetStyles`.
47
+ */
48
+ export function normaliseWidgetStyles(raw: unknown): ThemeWidgetStyles;
49
+
36
50
  /**
37
51
  * REQ-THEME-15 host render-boundary helper: folds the theme's per-component
38
52
  * tokens into a widget's props as `style` DEFAULTS, with the author's
@@ -41,6 +55,81 @@ export function normaliseThemeComponents(raw: unknown): ThemeComponents;
41
55
  */
42
56
  export function applyThemeComponentStyle<T = Record<string, unknown>>(
43
57
  manifestId: string,
44
- theme: { components?: ThemeComponents } | null | undefined,
58
+ theme:
59
+ | { components?: ThemeComponents; widgetStyles?: ThemeWidgetStyles }
60
+ | null
61
+ | undefined,
45
62
  props: T,
63
+ styleSchema?: Record<string, unknown> | null,
46
64
  ): T;
65
+
66
+ // sc-4939 — the host half of `useToast()`. A widget only ever calls the hook;
67
+ // these are what a platform host puts behind `WidgetContext.toast`.
68
+
69
+ export type ToastKind = "success" | "error" | "warning" | "info";
70
+
71
+ export interface ToastPayload {
72
+ kind?: ToastKind | string;
73
+ message?: string;
74
+ }
75
+
76
+ export interface HostToast {
77
+ id: string;
78
+ kind: ToastKind;
79
+ message: string;
80
+ }
81
+
82
+ /** Resolved values a host paints one toast with. `elevation` is the React
83
+ * Native style object; `boxShadow` is the CSS string derived from it. */
84
+ export interface ToastTokens {
85
+ kind: ToastKind;
86
+ accent: string;
87
+ surface: string;
88
+ text: string;
89
+ border: string;
90
+ radius: number;
91
+ padding: number;
92
+ gap: number;
93
+ stackGap: number;
94
+ accentBarWidth: number;
95
+ fontFamily?: string;
96
+ fontSize: number;
97
+ elevation: Record<string, unknown>;
98
+ boxShadow: string;
99
+ }
100
+
101
+ export interface ToastController {
102
+ /** Enqueue a toast. Returns its id, or null when the message is empty. */
103
+ show(payload: ToastPayload): string | null;
104
+ dismiss(id: string): void;
105
+ getToasts(): HostToast[];
106
+ /** Subscribe to the queue; returns an unsubscribe function. */
107
+ subscribe(listener: (toasts: HostToast[]) => void): () => void;
108
+ /** Clear every pending timer and listener. */
109
+ destroy(): void;
110
+ }
111
+
112
+ export interface ToastControllerOptions {
113
+ durationMs?: number;
114
+ maxVisible?: number;
115
+ setTimer?: (fn: () => void, ms: number) => unknown;
116
+ clearTimer?: (handle: unknown) => void;
117
+ }
118
+
119
+ export const TOAST_DEFAULTS: { durationMs: number; maxVisible: number };
120
+
121
+ export function normalizeToastKind(kind: unknown): ToastKind;
122
+
123
+ export function resolveToastTokens(
124
+ theme: unknown,
125
+ kind: ToastKind | string,
126
+ ): ToastTokens;
127
+
128
+ /**
129
+ * The host-side toast queue: newest-first stacking capped at `maxVisible`,
130
+ * auto-dismiss after `durationMs`, injectable timers. Shared by the web Player
131
+ * and the compiler's native WidgetHost so the two cannot drift.
132
+ */
133
+ export function createToastController(
134
+ opts?: ToastControllerOptions,
135
+ ): ToastController;
package/dist/host.js CHANGED
@@ -18,5 +18,24 @@ 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
+ normaliseWidgetStyles,
21
22
  applyThemeComponentStyle,
22
23
  } from "./theme-components.js";
24
+
25
+ // REQ-THEME-SURFACE: the surface-token derivation both hosts apply per painted
26
+ // surface — the page background and every container that carries its own fill.
27
+ // Host plumbing, deliberately off the author entry point: a widget reads the
28
+ // already-correct `useTheme().colors`, it never derives them itself.
29
+ export { deriveSurfaceTokens } from "./contract.js";
30
+
31
+ // REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
32
+ // auto-dismiss timing, and the themed values a toast is painted with. The
33
+ // widget-facing hook only forwards to `ctx.toast.showToast`; the host owns the
34
+ // surface, and sharing everything but the JSX is what keeps the web Player and
35
+ // the Expo export rendering the same notification (CLAUDE.md §8).
36
+ export {
37
+ createToastController,
38
+ resolveToastTokens,
39
+ normalizeToastKind,
40
+ TOAST_DEFAULTS,
41
+ } from "./toast-host.js";
package/dist/index.js CHANGED
@@ -99,5 +99,7 @@ export {
99
99
  deriveAccentTints,
100
100
  gradientAngleToVector,
101
101
  normaliseComponentGradient,
102
+ clampSpacingScale,
103
+ scaleSpacing,
102
104
  } from "./contract.js";
103
105
  export { normalizeLucideIconName } from "./lucideIconName.js";
@@ -97,5 +97,7 @@ export {
97
97
  deriveAccentTints,
98
98
  gradientAngleToVector,
99
99
  normaliseComponentGradient,
100
+ clampSpacingScale,
101
+ scaleSpacing,
100
102
  } from "./contract.js";
101
103
  export { normalizeLucideIconName } from "./lucideIconName.js";
package/dist/linter.cjs CHANGED
@@ -877,6 +877,72 @@ function _imagePercentHeightRules(source) {
877
877
  return findings;
878
878
  }
879
879
 
880
+ // sc-4913 — a measured frame INCLUDES the element's own padding, so cells sized
881
+ // from it overflow the content box and the last one wraps into an empty column.
882
+ // Mirror of linter.js (see there for the full rationale).
883
+ const _JSX_TAG_RE = /<([A-Z][\w.]*)\b/g;
884
+ const _ON_LAYOUT_ATTR_RE = /\bonLayout\s*=/;
885
+ const _OWN_PADDING_RE = /\bpadding(?:Horizontal|Left|Right|Start|End)?\s*:/;
886
+ const _WIDTH_HOLDER_RE =
887
+ /const\s*\[\s*([\w$]+)\s*,\s*([\w$]+)\s*\]\s*=\s*(useState|useContainerWidth)\b/g;
888
+ const _LAYOUT_FEED_WINDOW = 80;
889
+
890
+ function _measuredWidthNames(code) {
891
+ const names = [];
892
+ _WIDTH_HOLDER_RE.lastIndex = 0;
893
+ let held;
894
+ while ((held = _WIDTH_HOLDER_RE.exec(code))) {
895
+ const [, value, setter, hook] = held;
896
+ if (hook === "useContainerWidth") {
897
+ names.push(value);
898
+ continue;
899
+ }
900
+ const fedByLayout = new RegExp(
901
+ `\\b${setter}\\s*\\([\\s\\S]{0,${_LAYOUT_FEED_WINDOW}}?\\blayout\\.width\\b`,
902
+ );
903
+ if (fedByLayout.test(code)) names.push(value);
904
+ }
905
+ return names;
906
+ }
907
+
908
+ function _sizesFromMeasuredWidth(code, names) {
909
+ return names.some((name) =>
910
+ new RegExp(`\\b${name}\\s*[-/*]|[-/*]\\s*\\b${name}\\b`).test(code),
911
+ );
912
+ }
913
+
914
+ function _measuredPaddingRules(source) {
915
+ const code = _stripNonCode(source, { keepStrings: true });
916
+ const measured = _measuredWidthNames(code);
917
+ if (!_sizesFromMeasuredWidth(code, measured)) return [];
918
+ const sourceLines = source.split(/\r?\n/);
919
+ const findings = [];
920
+ _JSX_TAG_RE.lastIndex = 0;
921
+ let tag;
922
+ while ((tag = _JSX_TAG_RE.exec(code))) {
923
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
924
+ const attrs = code.slice(tag.index, end);
925
+ _JSX_TAG_RE.lastIndex = end;
926
+ if (!_ON_LAYOUT_ATTR_RE.test(attrs)) continue;
927
+ const padded = _OWN_PADDING_RE.exec(attrs);
928
+ if (!padded) continue;
929
+ const line = code
930
+ .slice(0, tag.index + padded.index)
931
+ .split(/\r?\n/).length;
932
+ findings.push({
933
+ rule: "measured-width-ignores-padding",
934
+ severity: "warning",
935
+ label:
936
+ `<${tag[1]}> has onLayout AND its own padding: the measured width ` +
937
+ `INCLUDES that padding, so cells sized from it overflow and the last ` +
938
+ `wraps, leaving an empty column. Measure an unpadded inner View.`,
939
+ line,
940
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
941
+ });
942
+ }
943
+ return findings;
944
+ }
945
+
880
946
  // Narrow a split-impl widget's manifest to the platform a single bundle file
881
947
  // ships to, so `import-platform-mismatch` lints each file against what it
882
948
  // actually targets. Mirror of linter.js.
@@ -939,6 +1005,9 @@ function lintSource(source, options) {
939
1005
  findings.push(..._lucideIconRules(source));
940
1006
  findings.push(..._reactInScopeRules(source));
941
1007
  findings.push(..._imagePercentHeightRules(source));
1008
+ // sc-4913 — soft warning: a measured width that includes the widget's own
1009
+ // padding wraps the last grid column into an empty one.
1010
+ findings.push(..._measuredPaddingRules(source));
942
1011
  // sc-4650 — soft warning: every payment refusal reported as "try again".
943
1012
  findings.push(..._paymentCurrencyRules(source));
944
1013
  findings.push(..._hardcodedCurrencyLabelRules(source));