@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 +21 -2
- package/dist/cli.js +14 -4
- package/dist/contract.cjs +130 -1
- package/dist/contract.js +130 -1
- package/dist/host.d.ts +23 -0
- package/dist/host.js +10 -0
- package/dist/linter.cjs +59 -0
- package/dist/linter.js +78 -0
- package/dist/theme-components.cjs +134 -0
- package/dist/theme-components.js +124 -0
- package/package.json +2 -2
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.
|
|
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 (
|
|
63
|
+
if (findings.length === 0) {
|
|
64
64
|
stdout.write(`${filePath}: clean\n`);
|
|
65
65
|
exit(0);
|
|
66
66
|
}
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|