@colixsystems/widget-sdk 0.65.0 → 0.67.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
@@ -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,26 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
53
53
 
54
54
  ## Status
55
55
 
56
- `v0.65.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.
67
+
68
+ ### What's new in 0.66.0
69
+
70
+ **New linter rule `image-percent-height`, and `appstudio-widget lint` finally prints warnings (sc-3493).**
71
+
72
+ - **`image-percent-height` (severity `warning`, non-blocking).** An `<Image>` / `<ImageBackground>` sized with a literal percentage `height` — `style={{ width: "100%", height: "47%" }}` — is flagged. React Native / Yoga resolves a percentage height against the **parent's** height, so under a content-sized parent it collapses to 0: the `uri` still fetches, but the image is invisible on both the web Player and the native Expo export, with nothing in the console to trace. Author fix: size it with `aspectRatio` (`{ width: "100%", aspectRatio: 1 }`) or a numeric pixel height. It is a **warning**, not an error, precisely because `height: "100%"` *is* correct inside a parent with a definite height (a fixed-height hero) and a text scan cannot tell the two apart — so the rule informs without rejecting a valid widget. Scope is the literal inline form only; a height threaded through a variable or a `StyleSheet` object is beyond an AST-free scan, and the guidance in the `useFilestoreFile` note below remains the primary guard. Comments are not scanned, so documenting the anti-pattern is safe.
73
+ - **The CLI no longer swallows warnings.** `runLint` reported `clean` and dropped every `severity: "warning"` finding whenever there were no errors, which made the existing `no-host-api-url` warning (and this new one) invisible to anyone using `appstudio-widget lint`. It now prints an `N error(s), M warning(s)` header and one line per finding tagged `error` / `warning`. **Exit codes are unchanged:** `0` when there are no error-severity findings (warnings included), `1` otherwise — so a warning still never blocks a build. `clean` is printed only when there are genuinely zero findings.
74
+
75
+ `CONTRACT` is unchanged (no new field), and no export changed signature.
57
76
 
58
77
  ### What's new in 0.65.0
59
78
 
package/dist/cli.js CHANGED
@@ -60,15 +60,25 @@ function runLint(rest) {
60
60
  exit(1);
61
61
  }
62
62
  const { ok, findings } = lintSource(source);
63
- if (ok) {
63
+ if (findings.length === 0) {
64
64
  stdout.write(`${filePath}: clean\n`);
65
65
  exit(0);
66
66
  }
67
- stderr.write(`${filePath}: ${findings.length} finding(s)\n`);
67
+ // sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
68
+ // for warnings, so the CLI printed "clean" and dropped them. A warning nobody
69
+ // sees is pointless. Report every finding; only errors change the exit code.
70
+ const errors = findings.filter((f) => f.severity !== "warning").length;
71
+ const stream = ok ? stdout : stderr;
72
+ stream.write(
73
+ `${filePath}: ${errors} error(s), ${findings.length - errors} warning(s)\n`,
74
+ );
68
75
  for (const f of findings) {
69
- stderr.write(` [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`);
76
+ const severity = f.severity === "warning" ? "warning" : "error";
77
+ stream.write(
78
+ ` ${severity} [${f.rule}] line ${f.line}: ${f.label}\n ${f.snippet}\n`,
79
+ );
70
80
  }
71
- exit(1);
81
+ exit(ok ? 0 : 1);
72
82
  }
73
83
 
74
84
  async function runDev(rest) {
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/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";
package/dist/linter.cjs CHANGED
@@ -669,6 +669,64 @@ function _reactInScopeRules(source) {
669
669
  return findings;
670
670
  }
671
671
 
672
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0 against a
673
+ // content-sized parent, so the image loads but renders invisible on both hosts.
674
+ // `severity: "warning"`: the same value is correct under a definite-height
675
+ // parent, which this AST-free scan cannot see. Mirror of linter.js.
676
+ const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
677
+ const _PERCENT_HEIGHT_RE =
678
+ /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
679
+
680
+ function _jsxOpenTagEnd(source, from) {
681
+ let depth = 0;
682
+ let quote = "";
683
+ for (let i = from; i < source.length; i += 1) {
684
+ const ch = source[i];
685
+ if (quote) {
686
+ if (ch === "\\") i += 1;
687
+ else if (ch === quote) quote = "";
688
+ continue;
689
+ }
690
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
691
+ else if (ch === "{") depth += 1;
692
+ else if (ch === "}") depth -= 1;
693
+ else if (ch === ">" && depth <= 0) return i;
694
+ }
695
+ return source.length;
696
+ }
697
+
698
+ function _imagePercentHeightRules(source) {
699
+ const findings = [];
700
+ const code = _stripNonCode(source, { keepStrings: true });
701
+ const sourceLines = source.split(/\r?\n/);
702
+ _IMAGE_TAG_RE.lastIndex = 0;
703
+ let tag;
704
+ while ((tag = _IMAGE_TAG_RE.exec(code))) {
705
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
706
+ const attrs = code.slice(tag.index, end);
707
+ _PERCENT_HEIGHT_RE.lastIndex = 0;
708
+ const hit = _PERCENT_HEIGHT_RE.exec(attrs);
709
+ if (!hit) continue;
710
+ const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
711
+ findings.push({
712
+ rule: "image-percent-height",
713
+ severity: "warning",
714
+ label:
715
+ `<${tag[1]}> sizes its height with a percentage — React Native ` +
716
+ `resolves that against the PARENT's height, and a content-sized ` +
717
+ `parent has none, so it collapses to 0: the image loads but is ` +
718
+ `invisible on BOTH the web Player and the native Expo export. Size ` +
719
+ `it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
720
+ `numeric pixel height. Warning only — a percentage height is correct ` +
721
+ `when the parent has a definite height (e.g. a fixed-height hero).`,
722
+ line,
723
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
724
+ });
725
+ _IMAGE_TAG_RE.lastIndex = end;
726
+ }
727
+ return findings;
728
+ }
729
+
672
730
  // Narrow a split-impl widget's manifest to the platform a single bundle file
673
731
  // ships to, so `import-platform-mismatch` lints each file against what it
674
732
  // actually targets. Mirror of linter.js.
@@ -729,6 +787,7 @@ function lintSource(source, options) {
729
787
  findings.push(..._hostApiUrlRules(source));
730
788
  findings.push(..._lucideIconRules(source));
731
789
  findings.push(..._reactInScopeRules(source));
790
+ findings.push(..._imagePercentHeightRules(source));
732
791
  findings.push(
733
792
  ..._scopeRules(source, options && options.manifest).map((f) => ({
734
793
  ...f,
package/dist/linter.js CHANGED
@@ -761,6 +761,82 @@ function _reactInScopeRules(source) {
761
761
  return findings;
762
762
  }
763
763
 
764
+ // sc-3466 / sc-3493 — percentage height on an <Image> collapses to 0.
765
+ // React Native / Yoga resolves a percentage `height` against the PARENT's
766
+ // height; a content-sized parent has none, so the value resolves to 0 and the
767
+ // image fetches its uri but renders invisible on BOTH the web Player and the
768
+ // native Expo export. sc-3466 taught the rule to the AI widget agent's
769
+ // DEFAULT_SYSTEM_PROMPT, which remains the primary guard — this is the
770
+ // mechanical belt-and-braces catch for a model (or a human author) that
771
+ // ignores it.
772
+ //
773
+ // `severity: "warning"` deliberately: `height: "100%"` IS correct inside a
774
+ // parent with a definite height (a fixed-height hero), which the AST-free scan
775
+ // cannot see, so a blocking rule would reject valid widgets.
776
+ //
777
+ // Scope is the literal inline form only. A height threaded through a variable
778
+ // or a StyleSheet object stays out of reach of a text scan.
779
+ const _IMAGE_TAG_RE = /<(Image|ImageBackground)\b/g;
780
+ const _PERCENT_HEIGHT_RE =
781
+ /(^|[{,;\s])height\s*:\s*(["'])\s*\d+(?:\.\d+)?\s*%\s*\2/g;
782
+
783
+ // Index of the `>` closing the JSX opening tag that starts at `from`. Braces
784
+ // and string literals are skipped so a `>` inside `onPress={() => …}` or an
785
+ // attribute string can't end the tag early.
786
+ function _jsxOpenTagEnd(source, from) {
787
+ let depth = 0;
788
+ let quote = "";
789
+ for (let i = from; i < source.length; i += 1) {
790
+ const ch = source[i];
791
+ if (quote) {
792
+ if (ch === "\\") i += 1;
793
+ else if (ch === quote) quote = "";
794
+ continue;
795
+ }
796
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
797
+ else if (ch === "{") depth += 1;
798
+ else if (ch === "}") depth -= 1;
799
+ else if (ch === ">" && depth <= 0) return i;
800
+ }
801
+ return source.length;
802
+ }
803
+
804
+ function _imagePercentHeightRules(source) {
805
+ const findings = [];
806
+ // Comments are blanked (string contents kept) so a commented-out example —
807
+ // including the one in this rule's own docs — is never flagged.
808
+ const code = _stripNonCode(source, { keepStrings: true });
809
+ const sourceLines = source.split(/\r?\n/);
810
+ _IMAGE_TAG_RE.lastIndex = 0;
811
+ let tag;
812
+ while ((tag = _IMAGE_TAG_RE.exec(code))) {
813
+ const end = _jsxOpenTagEnd(code, tag.index + tag[0].length);
814
+ const attrs = code.slice(tag.index, end);
815
+ _PERCENT_HEIGHT_RE.lastIndex = 0;
816
+ const hit = _PERCENT_HEIGHT_RE.exec(attrs);
817
+ if (!hit) continue;
818
+ const line = code.slice(0, tag.index + hit.index).split(/\r?\n/).length;
819
+ findings.push({
820
+ rule: "image-percent-height",
821
+ severity: "warning",
822
+ label:
823
+ `<${tag[1]}> sizes its height with a percentage — React Native ` +
824
+ `resolves that against the PARENT's height, and a content-sized ` +
825
+ `parent has none, so it collapses to 0: the image loads but is ` +
826
+ `invisible on BOTH the web Player and the native Expo export. Size ` +
827
+ `it with aspectRatio (e.g. { width: "100%", aspectRatio: 1 }) or a ` +
828
+ `numeric pixel height. Warning only — a percentage height is correct ` +
829
+ `when the parent has a definite height (e.g. a fixed-height hero).`,
830
+ line,
831
+ snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
832
+ });
833
+ // One finding per <Image>; a second percentage height on the same tag is
834
+ // the same defect.
835
+ _IMAGE_TAG_RE.lastIndex = end;
836
+ }
837
+ return findings;
838
+ }
839
+
764
840
  /**
765
841
  * Narrow a split-impl widget's manifest to the platform a single bundle file
766
842
  * ships to, so `import-platform-mismatch` lints each file against what it
@@ -828,6 +904,8 @@ export function lintSource(source, options) {
828
904
  findings.push(..._lucideIconRules(source));
829
905
  // sc-2353 — widget source must be self-contained (reference React ⇒ import it).
830
906
  findings.push(..._reactInScopeRules(source));
907
+ // sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
908
+ findings.push(..._imagePercentHeightRules(source));
831
909
  // REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
832
910
  // line-by-line scan so banned-identifier findings stay first in the
833
911
  // output.
@@ -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.65.0",
3
+ "version": "0.67.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "build": "node scripts/build.js",
51
- "test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-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-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__/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"