@colixsystems/widget-sdk 0.67.1 → 0.69.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 +10 -7
- package/dist/contract.cjs +252 -3
- package/dist/contract.js +252 -3
- package/dist/gradient-stops.js +21 -0
- package/dist/gradient.js +35 -0
- package/dist/gradient.native.js +32 -0
- package/dist/host.d.ts +11 -1
- package/dist/index.d.ts +109 -0
- package/dist/index.js +18 -1
- package/dist/index.native.js +18 -1
- package/dist/primitives.js +4 -0
- package/dist/primitives.native.js +4 -0
- package/dist/theme-components.cjs +6 -1
- package/dist/theme-components.js +6 -1
- 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, 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. |
|
|
20
|
+
| **CORE** | `useTheme()` | `{ colors, elevation, spacing, radii, typography, components }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`). `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) |
|
|
@@ -61,9 +61,10 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
61
61
|
|
|
62
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
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 `{}`.
|
|
64
|
+
- **`CONTRACT.themeComponents` / `CONTRACT.themeComponentShadows` / `CONTRACT.themeComponentGradient`** publish the vocabulary: each scope's tokens, their value types and ranges, and the widget → style-field bindings. `themeTokens.components` defaults to `{}`.
|
|
65
|
+
- **The `button` and `card` scopes carry a `gradient` token (sc-3727)** — `{ from: "#hex", to: "#hex", angle: 0-359 }`, painted through the `<Gradient>` primitive. It reaches a widget as an ordinary style field (`gradient`, `cardGradient`, `submitGradient`), so the author rule is unchanged: read `props.style`, and treat an explicit `null` as "this instance opted out of the app-wide gradient" rather than as unset.
|
|
65
66
|
|
|
66
|
-
`CONTRACT.version` → `1.
|
|
67
|
+
`CONTRACT.version` → `1.46.0`. Additive; no existing export changed signature, and an unthemed app renders identically.
|
|
67
68
|
|
|
68
69
|
### What's new in 0.66.0
|
|
69
70
|
|
|
@@ -514,17 +515,19 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
514
515
|
|
|
515
516
|
**Decide before you build:** the widget's shape (card, list, form, control), its hierarchy (the one thing the eye lands on first), its single accent moment, and its empty/loading/error look — then compose. Specific decisions make a distinctive widget; leaving them to default makes a generic one.
|
|
516
517
|
|
|
517
|
-
- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners. Don't hardcode raw pixel values.
|
|
518
|
-
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
518
|
+
- **Pull spacing and corners from tokens.** Use `theme.spacing` (`xs / sm / md / lg / xl`) for a consistent padding and gap rhythm, and `theme.radii` (`sm / md / lg / pill`) for corners — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. Don't hardcode raw pixel values.
|
|
519
|
+
- **Build a hierarchy.** A clear title (large, bold, `colors.onSurface`), body text, and muted captions in `colors.onSurfaceMuted` — three weights, not one flat size. Reserve full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
519
520
|
- **Set the theme font on every `Text`.** React Native `Text` does not inherit `fontFamily` from a parent, so a text element that omits it falls back to the system font and ignores the workspace's configured font. Put `theme.typography.fontFamily` on every text style (a shared `StyleSheet` built from `theme` keeps it in one place) and size text with `theme.typography.sizes`.
|
|
520
|
-
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.
|
|
521
|
+
- **Contain and elevate.** Wrap a logical unit in a surface: `colors.surface` + padding + `radii.lg` + `...theme.elevation.sm`. Give it the elevation **or** a `colors.border` hairline, not both — and prefer the elevation, because a hairline-only card reads as a wireframe. `theme.elevation` is a token table you spread into a style (`...theme.elevation.md`), covering `none / sm / md / lg / xl`; never hand-write `shadowOpacity` / `shadowRadius` / `boxShadow`. Use the status roles (`danger / success / warning / info`) for state.
|
|
522
|
+
- **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
|
|
523
|
+
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
521
524
|
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the native-safe way to go multi-column (widgets have no breakpoint hook, so never hard-code fixed columns). Keep wide fields (email, address, notes) full-width, cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only).
|
|
522
525
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
523
526
|
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
|
524
527
|
- **Use imagery deliberately.** Render pictures with the `Image` primitive (`source` takes a URL or `{ uri }`); resolve workspace assets via `useAsset()`. Give every image a sized, `radii`-clipped container so it never renders as a raw rectangle, and never hardcode a credentialed image URL — expose an `image`-type property instead.
|
|
525
528
|
- **Design the empty, loading, and error states.** A blank box on a fresh install reads as broken — show a short helper line when a list is empty, a calm loading line, and a single human sentence in `colors.danger` on error.
|
|
526
529
|
|
|
527
|
-
**Honest ceilings:** the styling surface is React Native style objects, not full CSS.
|
|
530
|
+
**Honest ceilings:** the styling surface is React Native style objects, not full CSS. Gradients come from the `<Gradient>` primitive (not a CSS `linear-gradient` string), depth comes from `theme.elevation` (not arbitrary `box-shadow` stacks), and there are no custom CSS keyframe animations or `transition` strings, no `filter` / `backdrop-filter` / `clip-path` / `mask` / blend modes, and no opacity-faked tints (that's what `primarySoft` is for). Aim for clean, confident, professional polish within those bounds — lifted surfaces, generous corners, one accent moment.
|
|
528
531
|
|
|
529
532
|
## Managing app users from a widget
|
|
530
533
|
|
package/dist/contract.cjs
CHANGED
|
@@ -8,10 +8,56 @@
|
|
|
8
8
|
// Keep edits in lockstep between `contract.cjs` and `contract.js`. The
|
|
9
9
|
// SDK build script copies both into `dist/`.
|
|
10
10
|
|
|
11
|
+
// REQ-AI-AGENT-DESIGN (sc-3696): the ONE cross-platform elevation vocabulary.
|
|
12
|
+
// `shadow*` applies on iOS + react-native-web, `elevation` drives Android.
|
|
13
|
+
// Consumed by `_shared/cardStyle.js` SHADOW_PRESETS (web + baked built-ins),
|
|
14
|
+
// the compiler's generated layout `elevationStyles`, and widgets reading
|
|
15
|
+
// `useTheme().elevation` — so a widget shadow and a container shadow match.
|
|
16
|
+
const ELEVATION = Object.freeze({
|
|
17
|
+
none: Object.freeze({}),
|
|
18
|
+
sm: Object.freeze({
|
|
19
|
+
shadowColor: "#000",
|
|
20
|
+
shadowOffset: Object.freeze({ width: 0, height: 1 }),
|
|
21
|
+
shadowOpacity: 0.08,
|
|
22
|
+
shadowRadius: 2,
|
|
23
|
+
elevation: 2,
|
|
24
|
+
}),
|
|
25
|
+
md: Object.freeze({
|
|
26
|
+
shadowColor: "#000",
|
|
27
|
+
shadowOffset: Object.freeze({ width: 0, height: 2 }),
|
|
28
|
+
shadowOpacity: 0.12,
|
|
29
|
+
shadowRadius: 4,
|
|
30
|
+
elevation: 4,
|
|
31
|
+
}),
|
|
32
|
+
lg: Object.freeze({
|
|
33
|
+
shadowColor: "#000",
|
|
34
|
+
shadowOffset: Object.freeze({ width: 0, height: 4 }),
|
|
35
|
+
shadowOpacity: 0.16,
|
|
36
|
+
shadowRadius: 8,
|
|
37
|
+
elevation: 8,
|
|
38
|
+
}),
|
|
39
|
+
xl: Object.freeze({
|
|
40
|
+
shadowColor: "#000",
|
|
41
|
+
shadowOffset: Object.freeze({ width: 0, height: 8 }),
|
|
42
|
+
shadowOpacity: 0.2,
|
|
43
|
+
shadowRadius: 12,
|
|
44
|
+
elevation: 12,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
11
48
|
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
12
49
|
colors: Object.freeze({
|
|
13
50
|
primary: "#3b82f6",
|
|
14
51
|
onPrimary: "#ffffff",
|
|
52
|
+
// REQ-AI-AGENT-DESIGN (sc-3696): the accent's quiet tiers. `primarySoft` is
|
|
53
|
+
// a tint of the accent over the surface (chips, secondary buttons, progress
|
|
54
|
+
// tracks); `onPrimarySoft` is contrast-guaranteed against it;
|
|
55
|
+
// `primaryStrong` is the accent's deeper end for a gradient's far stop.
|
|
56
|
+
// All three are re-derived per tenant by `deriveAccentTints` — these
|
|
57
|
+
// literals are the derivation applied to the default palette.
|
|
58
|
+
primarySoft: "#ebf3fe",
|
|
59
|
+
onPrimarySoft: "#3168c2",
|
|
60
|
+
primaryStrong: "#326bc8",
|
|
15
61
|
secondary: "#10b981",
|
|
16
62
|
onSecondary: "#ffffff",
|
|
17
63
|
surface: "#ffffff",
|
|
@@ -24,6 +70,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
24
70
|
warning: "#d97706",
|
|
25
71
|
info: "#0284c7",
|
|
26
72
|
}),
|
|
73
|
+
elevation: ELEVATION,
|
|
27
74
|
spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
|
|
28
75
|
radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
|
|
29
76
|
typography: Object.freeze({
|
|
@@ -60,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
60
107
|
// cannot drift between what Mason may emit and what a host actually applies.
|
|
61
108
|
const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
|
|
62
109
|
|
|
110
|
+
// sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
|
|
111
|
+
// angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
|
|
112
|
+
// variant (a component fill projects through `<Gradient>`, which is linear-only
|
|
113
|
+
// on both hosts). Declared once so the coercion, the Studio control and the
|
|
114
|
+
// planner prompt agree on the bounds.
|
|
115
|
+
const THEME_COMPONENT_GRADIENT = Object.freeze({
|
|
116
|
+
angleMin: 0,
|
|
117
|
+
angleMax: 359,
|
|
118
|
+
defaultAngle: 180,
|
|
119
|
+
});
|
|
120
|
+
|
|
63
121
|
// The card-surface field names shared by every widget that paints its own card
|
|
64
122
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
65
123
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -68,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
|
|
|
68
126
|
radius: "cardRadius",
|
|
69
127
|
padding: "cardPadding",
|
|
70
128
|
shadow: "shadow",
|
|
129
|
+
gradient: "cardGradient",
|
|
71
130
|
});
|
|
72
131
|
|
|
73
132
|
// A form widget's submit button — the `button` scope reaches it through the
|
|
@@ -75,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
|
|
|
75
134
|
const FORM_SUBMIT_FIELDS = Object.freeze({
|
|
76
135
|
background: "submitBackground",
|
|
77
136
|
textColor: "submitTextColor",
|
|
137
|
+
gradient: "submitGradient",
|
|
78
138
|
});
|
|
79
139
|
|
|
80
140
|
const THEME_COMPONENTS = Object.freeze({
|
|
@@ -87,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
87
147
|
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
|
|
88
148
|
fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
|
|
89
149
|
shadow: Object.freeze({ type: "shadow" }),
|
|
150
|
+
gradient: Object.freeze({ type: "gradient" }),
|
|
90
151
|
}),
|
|
91
152
|
targets: Object.freeze({
|
|
92
153
|
"appstudio.button": Object.freeze({
|
|
@@ -96,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
96
157
|
radius: "radius",
|
|
97
158
|
fontSize: "fontSize",
|
|
98
159
|
shadow: "shadow",
|
|
160
|
+
gradient: "gradient",
|
|
99
161
|
}),
|
|
100
162
|
"appstudio.form-input": FORM_SUBMIT_FIELDS,
|
|
101
163
|
"appstudio.form-builder": FORM_SUBMIT_FIELDS,
|
|
@@ -109,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
109
171
|
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
|
|
110
172
|
padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
|
|
111
173
|
shadow: Object.freeze({ type: "shadow" }),
|
|
174
|
+
gradient: Object.freeze({ type: "gradient" }),
|
|
112
175
|
}),
|
|
113
176
|
targets: Object.freeze({
|
|
114
177
|
"appstudio.user": CARD_SURFACE_FIELDS,
|
|
@@ -119,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
119
182
|
"appstudio.notifications": CARD_SURFACE_FIELDS,
|
|
120
183
|
"appstudio.form-input": CARD_SURFACE_FIELDS,
|
|
121
184
|
"appstudio.form-builder": CARD_SURFACE_FIELDS,
|
|
185
|
+
"appstudio.user-management": CARD_SURFACE_FIELDS,
|
|
122
186
|
}),
|
|
123
187
|
}),
|
|
124
188
|
text: Object.freeze({
|
|
@@ -376,7 +440,13 @@ const HOOKS = [
|
|
|
376
440
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
377
441
|
"ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
|
|
378
442
|
"to the created file row or throws the wire error; a 404 means the " +
|
|
379
|
-
"destination folder denied a write (REQ-FSH canWrite gate)."
|
|
443
|
+
"destination folder denied a write (REQ-FSH canWrite gate). " +
|
|
444
|
+
"ALWAYS pass spaceType explicitly — omitting it falls back to " +
|
|
445
|
+
"'project', which is unreadable by a logged-out visitor. Choose it by " +
|
|
446
|
+
"who must SEE the file: 'public' for content the app displays to " +
|
|
447
|
+
"anyone browsing (product, catalogue, gallery, cover images), " +
|
|
448
|
+
"'project' for content restricted to signed-in workspace users, " +
|
|
449
|
+
"'personal' for a file private to the uploading app user.",
|
|
380
450
|
returnShape: {
|
|
381
451
|
upload: "(file, { folderId? }) => Promise<FilestoreFile>",
|
|
382
452
|
uploading: "boolean",
|
|
@@ -958,6 +1028,16 @@ const PRIMITIVES = [
|
|
|
958
1028
|
rnComponent: null,
|
|
959
1029
|
docsUrl: null,
|
|
960
1030
|
},
|
|
1031
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
|
|
1032
|
+
// no gradient at all, which is why generated widgets read flatter than the
|
|
1033
|
+
// competition; this is the ONE cross-platform way to paint one.
|
|
1034
|
+
{
|
|
1035
|
+
name: "Gradient",
|
|
1036
|
+
description:
|
|
1037
|
+
"Linear-gradient surface. `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={{ borderRadius: theme.radii.lg, padding: theme.spacing.md }}>…</Gradient>`. A View that paints a gradient behind its children — accepts every View style prop, so it replaces the `View` you would otherwise give a flat `backgroundColor`. `colors` takes two or more theme colour roles (never raw hex); `angle` is CSS degrees (0 = to top, 90 = to right, default 180 = to bottom). Web paints a CSS `linear-gradient`; native delegates to expo-linear-gradient with the same angle projection, so the two hosts match. Use it for at most ONE element per widget (the hero action or a stat header) — text on it uses `colors.onPrimary`.",
|
|
1038
|
+
rnComponent: "expo-linear-gradient",
|
|
1039
|
+
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
1040
|
+
},
|
|
961
1041
|
];
|
|
962
1042
|
|
|
963
1043
|
const CATEGORIES = [
|
|
@@ -1573,7 +1653,7 @@ const VETTED_IMPORTS = [
|
|
|
1573
1653
|
platforms: ["web", "native"],
|
|
1574
1654
|
category: "drawing",
|
|
1575
1655
|
description:
|
|
1576
|
-
"Linear-gradient fill (<LinearGradient colors={[...]} />).
|
|
1656
|
+
"Linear-gradient fill (<LinearGradient colors={[...]} />). Prefer the SDK's <Gradient> primitive: it wraps this on native and CSS on web, so it resolves in an AI-agent widget (which is transpiled, not bundled) where a bare import of this package does not. Reach for the package directly only from a BUNDLED widget needing a gradient shape this primitive can't express.",
|
|
1577
1657
|
},
|
|
1578
1658
|
{
|
|
1579
1659
|
specifier: "lottie-react-native",
|
|
@@ -2087,7 +2167,21 @@ const CONTRACT = deepFreeze({
|
|
|
2087
2167
|
// (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
|
|
2088
2168
|
// export changed signature and a theme with no `components` key resolves to
|
|
2089
2169
|
// an empty override, rendering identically to before.
|
|
2090
|
-
|
|
2170
|
+
// 1.46.0: additive (sc-3727) — a `gradient` token type on the per-component
|
|
2171
|
+
// vocabulary. The `button` and `card` scopes each gain a `gradient` token
|
|
2172
|
+
// whose value is `{ from: "#hex", to: "#hex", angle: 0-359 }` — the
|
|
2173
|
+
// `backgroundGradient` grammar minus the radial variant, because a component
|
|
2174
|
+
// fill paints through the `<Gradient>` primitive, which is linear on both
|
|
2175
|
+
// hosts. `themeComponentGradient` publishes the angle bounds + default so the
|
|
2176
|
+
// coercion, the Studio control and the planner prompt cannot disagree. The
|
|
2177
|
+
// token binds to per-instance style fields the target widgets read
|
|
2178
|
+
// (`gradient` on Button, `cardGradient` on every card surface,
|
|
2179
|
+
// `submitGradient` on a form's submit button), so the theme value is a
|
|
2180
|
+
// DEFAULT an author overrides per instance — or suppresses with an explicit
|
|
2181
|
+
// null, which is how one button stays flat while the rest are gradiented.
|
|
2182
|
+
// Additive: no export changed signature and a theme with no gradient token
|
|
2183
|
+
// renders exactly as before.
|
|
2184
|
+
version: "1.46.0",
|
|
2091
2185
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2092
2186
|
hooks: HOOKS,
|
|
2093
2187
|
primitives: PRIMITIVES,
|
|
@@ -2100,6 +2194,7 @@ const CONTRACT = deepFreeze({
|
|
|
2100
2194
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
2101
2195
|
themeComponents: THEME_COMPONENTS,
|
|
2102
2196
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
2197
|
+
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
2103
2198
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
2104
2199
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
2105
2200
|
bannedApis: BANNED_APIS,
|
|
@@ -2122,10 +2217,164 @@ function requiredContextKeys() {
|
|
|
2122
2217
|
return [...keys];
|
|
2123
2218
|
}
|
|
2124
2219
|
|
|
2220
|
+
// ---------------------------------------------------------------------------
|
|
2221
|
+
// Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
|
|
2222
|
+
//
|
|
2223
|
+
// The colour maths every host applies on top of a tenant's picked palette.
|
|
2224
|
+
// It lives HERE, in the dual-format contract, because BOTH hosts need it and
|
|
2225
|
+
// both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
|
|
2226
|
+
// and the exported Expo app through the compiler's generated widget-theme
|
|
2227
|
+
// module (which already imports `CONTRACT` from this package). Previously each
|
|
2228
|
+
// host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
|
|
2229
|
+
// one source removes that drift (CLAUDE.md §3).
|
|
2230
|
+
// ---------------------------------------------------------------------------
|
|
2231
|
+
|
|
2232
|
+
const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
|
2233
|
+
|
|
2234
|
+
function isHexColor(value) {
|
|
2235
|
+
return typeof value === "string" && HEX_RE.test(value);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function hexChannels(hex) {
|
|
2239
|
+
let h = hex.slice(1);
|
|
2240
|
+
if (h.length === 3) {
|
|
2241
|
+
h = h
|
|
2242
|
+
.split("")
|
|
2243
|
+
.map((c) => c + c)
|
|
2244
|
+
.join("");
|
|
2245
|
+
}
|
|
2246
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/** Relative luminance (WCAG 2.1) of a hex colour. */
|
|
2250
|
+
function relativeLuminance(hex) {
|
|
2251
|
+
const [r, g, b] = hexChannels(hex).map((v) => v / 255);
|
|
2252
|
+
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
2253
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** WCAG contrast ratio between two hex colours. Always >= 1. */
|
|
2257
|
+
function contrastRatio(a, b) {
|
|
2258
|
+
const la = relativeLuminance(a);
|
|
2259
|
+
const lb = relativeLuminance(b);
|
|
2260
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
|
|
2264
|
+
function mixHex(a, b, t) {
|
|
2265
|
+
if (!isHexColor(a) || !isHexColor(b)) return a;
|
|
2266
|
+
const pa = hexChannels(a);
|
|
2267
|
+
const pb = hexChannels(b);
|
|
2268
|
+
const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
|
|
2269
|
+
const hex = (n) =>
|
|
2270
|
+
Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
|
|
2271
|
+
return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Pick a readable text colour (`dark` or `light`) for a solid background.
|
|
2276
|
+
* A light background gets the dark option and vice versa, so `onPrimary` /
|
|
2277
|
+
* `onSecondary` follow whatever accent the tenant picked.
|
|
2278
|
+
*/
|
|
2279
|
+
function readableTextColor(hex, dark, light) {
|
|
2280
|
+
if (!isHexColor(hex)) return light;
|
|
2281
|
+
return relativeLuminance(hex) > 0.55 ? dark : light;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// A tint at 10% accent reads as "quietly accented" on both a white and a dark
|
|
2285
|
+
// surface without competing with the full-strength accent moment.
|
|
2286
|
+
const ACCENT_TINT_WEIGHT = 0.9;
|
|
2287
|
+
// How far `primaryStrong` travels toward the text colour. Deepens the accent on
|
|
2288
|
+
// a light theme and lightens it on a dark one, which is the right direction for
|
|
2289
|
+
// a gradient's far stop in both.
|
|
2290
|
+
const ACCENT_STRONG_WEIGHT = 0.22;
|
|
2291
|
+
const ACCENT_TEXT_MIN_CONTRAST = 4.5;
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
|
|
2295
|
+
* resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
|
|
2296
|
+
*
|
|
2297
|
+
* `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
|
|
2298
|
+
* text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
|
|
2299
|
+
* is legible for EVERY tenant accent rather than only for accents that happen
|
|
2300
|
+
* to be dark enough. Returns `null` when the inputs aren't usable hex, letting
|
|
2301
|
+
* the caller keep the default tokens.
|
|
2302
|
+
*/
|
|
2303
|
+
function deriveAccentTints(primary, surface, onSurface) {
|
|
2304
|
+
if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
|
|
2308
|
+
let onPrimarySoft = primary;
|
|
2309
|
+
for (let t = 0; t <= 0.9; t += 0.05) {
|
|
2310
|
+
onPrimarySoft = mixHex(primary, onSurface, t);
|
|
2311
|
+
if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
|
|
2312
|
+
break;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
return {
|
|
2316
|
+
primarySoft,
|
|
2317
|
+
onPrimarySoft,
|
|
2318
|
+
primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
2324
|
+
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
2325
|
+
* top-left, y down). Single source for the SDK's `<Gradient>` primitive and
|
|
2326
|
+
* the compiler's app-background backdrop, so a widget gradient and the app
|
|
2327
|
+
* gradient read the same angle identically (CLAUDE.md §3).
|
|
2328
|
+
*/
|
|
2329
|
+
function gradientAngleToVector(angle) {
|
|
2330
|
+
const deg = Number.isFinite(angle) ? angle : 180;
|
|
2331
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
2332
|
+
const dx = Math.cos(rad);
|
|
2333
|
+
const dy = Math.sin(rad);
|
|
2334
|
+
return {
|
|
2335
|
+
start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
|
|
2336
|
+
end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
|
|
2341
|
+
// persists 8-digit component colours; the host must not drop what it accepted.
|
|
2342
|
+
const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
2346
|
+
*
|
|
2347
|
+
* ONE validator for a value arriving by two routes — the theme token and the
|
|
2348
|
+
* author's never-coerced per-instance `props.style` — so `set_theme` and the
|
|
2349
|
+
* renderer cannot disagree about the same value (CLAUDE.md §3). Both stops are
|
|
2350
|
+
* required: a one-stop gradient would paint a fill the author never chose. The
|
|
2351
|
+
* hex grammar is also the injection guard — these stops are interpolated into
|
|
2352
|
+
* CSS and into generated export source.
|
|
2353
|
+
*/
|
|
2354
|
+
function normaliseComponentGradient(raw) {
|
|
2355
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
2356
|
+
const from = typeof raw.from === "string" ? raw.from.trim() : "";
|
|
2357
|
+
const to = typeof raw.to === "string" ? raw.to.trim() : "";
|
|
2358
|
+
if (!GRADIENT_HEX_RE.test(from) || !GRADIENT_HEX_RE.test(to)) return null;
|
|
2359
|
+
const { defaultAngle } = THEME_COMPONENT_GRADIENT;
|
|
2360
|
+
// Read once — a second read of an accessor could return a different value and
|
|
2361
|
+
// land NaN in a style.
|
|
2362
|
+
const angle = raw.angle;
|
|
2363
|
+
const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
|
|
2364
|
+
return { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2125
2367
|
module.exports = {
|
|
2126
2368
|
CONTRACT,
|
|
2127
2369
|
isHookAllowed,
|
|
2128
2370
|
requiredContextKeys,
|
|
2371
|
+
isHexColor,
|
|
2372
|
+
mixHex,
|
|
2373
|
+
contrastRatio,
|
|
2374
|
+
readableTextColor,
|
|
2375
|
+
deriveAccentTints,
|
|
2376
|
+
gradientAngleToVector,
|
|
2377
|
+
normaliseComponentGradient,
|
|
2129
2378
|
widgetTranslationPrefix,
|
|
2130
2379
|
widgetTranslationKey,
|
|
2131
2380
|
sharedTranslationPrefix,
|
package/dist/contract.js
CHANGED
|
@@ -8,10 +8,56 @@
|
|
|
8
8
|
// Keep edits in lockstep between `contract.cjs` and `contract.js`. The
|
|
9
9
|
// SDK build script copies both into `dist/`.
|
|
10
10
|
|
|
11
|
+
// REQ-AI-AGENT-DESIGN (sc-3696): the ONE cross-platform elevation vocabulary.
|
|
12
|
+
// `shadow*` applies on iOS + react-native-web, `elevation` drives Android.
|
|
13
|
+
// Consumed by `_shared/cardStyle.js` SHADOW_PRESETS (web + baked built-ins),
|
|
14
|
+
// the compiler's generated layout `elevationStyles`, and widgets reading
|
|
15
|
+
// `useTheme().elevation` — so a widget shadow and a container shadow match.
|
|
16
|
+
const ELEVATION = Object.freeze({
|
|
17
|
+
none: Object.freeze({}),
|
|
18
|
+
sm: Object.freeze({
|
|
19
|
+
shadowColor: "#000",
|
|
20
|
+
shadowOffset: Object.freeze({ width: 0, height: 1 }),
|
|
21
|
+
shadowOpacity: 0.08,
|
|
22
|
+
shadowRadius: 2,
|
|
23
|
+
elevation: 2,
|
|
24
|
+
}),
|
|
25
|
+
md: Object.freeze({
|
|
26
|
+
shadowColor: "#000",
|
|
27
|
+
shadowOffset: Object.freeze({ width: 0, height: 2 }),
|
|
28
|
+
shadowOpacity: 0.12,
|
|
29
|
+
shadowRadius: 4,
|
|
30
|
+
elevation: 4,
|
|
31
|
+
}),
|
|
32
|
+
lg: Object.freeze({
|
|
33
|
+
shadowColor: "#000",
|
|
34
|
+
shadowOffset: Object.freeze({ width: 0, height: 4 }),
|
|
35
|
+
shadowOpacity: 0.16,
|
|
36
|
+
shadowRadius: 8,
|
|
37
|
+
elevation: 8,
|
|
38
|
+
}),
|
|
39
|
+
xl: Object.freeze({
|
|
40
|
+
shadowColor: "#000",
|
|
41
|
+
shadowOffset: Object.freeze({ width: 0, height: 8 }),
|
|
42
|
+
shadowOpacity: 0.2,
|
|
43
|
+
shadowRadius: 12,
|
|
44
|
+
elevation: 12,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
11
48
|
const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
12
49
|
colors: Object.freeze({
|
|
13
50
|
primary: "#3b82f6",
|
|
14
51
|
onPrimary: "#ffffff",
|
|
52
|
+
// REQ-AI-AGENT-DESIGN (sc-3696): the accent's quiet tiers. `primarySoft` is
|
|
53
|
+
// a tint of the accent over the surface (chips, secondary buttons, progress
|
|
54
|
+
// tracks); `onPrimarySoft` is contrast-guaranteed against it;
|
|
55
|
+
// `primaryStrong` is the accent's deeper end for a gradient's far stop.
|
|
56
|
+
// All three are re-derived per tenant by `deriveAccentTints` — these
|
|
57
|
+
// literals are the derivation applied to the default palette.
|
|
58
|
+
primarySoft: "#ebf3fe",
|
|
59
|
+
onPrimarySoft: "#3168c2",
|
|
60
|
+
primaryStrong: "#326bc8",
|
|
15
61
|
secondary: "#10b981",
|
|
16
62
|
onSecondary: "#ffffff",
|
|
17
63
|
surface: "#ffffff",
|
|
@@ -24,6 +70,7 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
24
70
|
warning: "#d97706",
|
|
25
71
|
info: "#0284c7",
|
|
26
72
|
}),
|
|
73
|
+
elevation: ELEVATION,
|
|
27
74
|
spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
|
|
28
75
|
radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
|
|
29
76
|
typography: Object.freeze({
|
|
@@ -60,6 +107,17 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
60
107
|
// cannot drift between what Mason may emit and what a host actually applies.
|
|
61
108
|
const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
|
|
62
109
|
|
|
110
|
+
// sc-3727 — the `gradient` token's value shape: two hex stops plus a CSS-degree
|
|
111
|
+
// angle, the same grammar as `themeConfig.backgroundGradient` minus the radial
|
|
112
|
+
// variant (a component fill projects through `<Gradient>`, which is linear-only
|
|
113
|
+
// on both hosts). Declared once so the coercion, the Studio control and the
|
|
114
|
+
// planner prompt agree on the bounds.
|
|
115
|
+
const THEME_COMPONENT_GRADIENT = Object.freeze({
|
|
116
|
+
angleMin: 0,
|
|
117
|
+
angleMax: 359,
|
|
118
|
+
defaultAngle: 180,
|
|
119
|
+
});
|
|
120
|
+
|
|
63
121
|
// The card-surface field names shared by every widget that paints its own card
|
|
64
122
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
65
123
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -68,6 +126,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
|
|
|
68
126
|
radius: "cardRadius",
|
|
69
127
|
padding: "cardPadding",
|
|
70
128
|
shadow: "shadow",
|
|
129
|
+
gradient: "cardGradient",
|
|
71
130
|
});
|
|
72
131
|
|
|
73
132
|
// A form widget's submit button — the `button` scope reaches it through the
|
|
@@ -75,6 +134,7 @@ const CARD_SURFACE_FIELDS = Object.freeze({
|
|
|
75
134
|
const FORM_SUBMIT_FIELDS = Object.freeze({
|
|
76
135
|
background: "submitBackground",
|
|
77
136
|
textColor: "submitTextColor",
|
|
137
|
+
gradient: "submitGradient",
|
|
78
138
|
});
|
|
79
139
|
|
|
80
140
|
const THEME_COMPONENTS = Object.freeze({
|
|
@@ -87,6 +147,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
87
147
|
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.sm" }),
|
|
88
148
|
fontSize: Object.freeze({ type: "size", min: 8, max: 96, uiDefault: "typography.sizes.sm" }),
|
|
89
149
|
shadow: Object.freeze({ type: "shadow" }),
|
|
150
|
+
gradient: Object.freeze({ type: "gradient" }),
|
|
90
151
|
}),
|
|
91
152
|
targets: Object.freeze({
|
|
92
153
|
"appstudio.button": Object.freeze({
|
|
@@ -96,6 +157,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
96
157
|
radius: "radius",
|
|
97
158
|
fontSize: "fontSize",
|
|
98
159
|
shadow: "shadow",
|
|
160
|
+
gradient: "gradient",
|
|
99
161
|
}),
|
|
100
162
|
"appstudio.form-input": FORM_SUBMIT_FIELDS,
|
|
101
163
|
"appstudio.form-builder": FORM_SUBMIT_FIELDS,
|
|
@@ -109,6 +171,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
109
171
|
radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.md" }),
|
|
110
172
|
padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.md" }),
|
|
111
173
|
shadow: Object.freeze({ type: "shadow" }),
|
|
174
|
+
gradient: Object.freeze({ type: "gradient" }),
|
|
112
175
|
}),
|
|
113
176
|
targets: Object.freeze({
|
|
114
177
|
"appstudio.user": CARD_SURFACE_FIELDS,
|
|
@@ -119,6 +182,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
119
182
|
"appstudio.notifications": CARD_SURFACE_FIELDS,
|
|
120
183
|
"appstudio.form-input": CARD_SURFACE_FIELDS,
|
|
121
184
|
"appstudio.form-builder": CARD_SURFACE_FIELDS,
|
|
185
|
+
"appstudio.user-management": CARD_SURFACE_FIELDS,
|
|
122
186
|
}),
|
|
123
187
|
}),
|
|
124
188
|
text: Object.freeze({
|
|
@@ -376,7 +440,13 @@ const HOOKS = [
|
|
|
376
440
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
377
441
|
"ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
|
|
378
442
|
"to the created file row or throws the wire error; a 404 means the " +
|
|
379
|
-
"destination folder denied a write (REQ-FSH canWrite gate)."
|
|
443
|
+
"destination folder denied a write (REQ-FSH canWrite gate). " +
|
|
444
|
+
"ALWAYS pass spaceType explicitly — omitting it falls back to " +
|
|
445
|
+
"'project', which is unreadable by a logged-out visitor. Choose it by " +
|
|
446
|
+
"who must SEE the file: 'public' for content the app displays to " +
|
|
447
|
+
"anyone browsing (product, catalogue, gallery, cover images), " +
|
|
448
|
+
"'project' for content restricted to signed-in workspace users, " +
|
|
449
|
+
"'personal' for a file private to the uploading app user.",
|
|
380
450
|
returnShape: {
|
|
381
451
|
upload: "(file, { folderId? }) => Promise<FilestoreFile>",
|
|
382
452
|
uploading: "boolean",
|
|
@@ -958,6 +1028,16 @@ const PRIMITIVES = [
|
|
|
958
1028
|
rnComponent: null,
|
|
959
1029
|
docsUrl: null,
|
|
960
1030
|
},
|
|
1031
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
|
|
1032
|
+
// no gradient at all, which is why generated widgets read flatter than the
|
|
1033
|
+
// competition; this is the ONE cross-platform way to paint one.
|
|
1034
|
+
{
|
|
1035
|
+
name: "Gradient",
|
|
1036
|
+
description:
|
|
1037
|
+
"Linear-gradient surface. `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={{ borderRadius: theme.radii.lg, padding: theme.spacing.md }}>…</Gradient>`. A View that paints a gradient behind its children — accepts every View style prop, so it replaces the `View` you would otherwise give a flat `backgroundColor`. `colors` takes two or more theme colour roles (never raw hex); `angle` is CSS degrees (0 = to top, 90 = to right, default 180 = to bottom). Web paints a CSS `linear-gradient`; native delegates to expo-linear-gradient with the same angle projection, so the two hosts match. Use it for at most ONE element per widget (the hero action or a stat header) — text on it uses `colors.onPrimary`.",
|
|
1038
|
+
rnComponent: "expo-linear-gradient",
|
|
1039
|
+
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
1040
|
+
},
|
|
961
1041
|
];
|
|
962
1042
|
|
|
963
1043
|
const CATEGORIES = [
|
|
@@ -1573,7 +1653,7 @@ const VETTED_IMPORTS = [
|
|
|
1573
1653
|
platforms: ["web", "native"],
|
|
1574
1654
|
category: "drawing",
|
|
1575
1655
|
description:
|
|
1576
|
-
"Linear-gradient fill (<LinearGradient colors={[...]} />).
|
|
1656
|
+
"Linear-gradient fill (<LinearGradient colors={[...]} />). Prefer the SDK's <Gradient> primitive: it wraps this on native and CSS on web, so it resolves in an AI-agent widget (which is transpiled, not bundled) where a bare import of this package does not. Reach for the package directly only from a BUNDLED widget needing a gradient shape this primitive can't express.",
|
|
1577
1657
|
},
|
|
1578
1658
|
{
|
|
1579
1659
|
specifier: "lottie-react-native",
|
|
@@ -2087,7 +2167,21 @@ const CONTRACT = deepFreeze({
|
|
|
2087
2167
|
// (`@colixsystems/widget-sdk/host`), never the author surface. Additive: no
|
|
2088
2168
|
// export changed signature and a theme with no `components` key resolves to
|
|
2089
2169
|
// an empty override, rendering identically to before.
|
|
2090
|
-
|
|
2170
|
+
// 1.46.0: additive (sc-3727) — a `gradient` token type on the per-component
|
|
2171
|
+
// vocabulary. The `button` and `card` scopes each gain a `gradient` token
|
|
2172
|
+
// whose value is `{ from: "#hex", to: "#hex", angle: 0-359 }` — the
|
|
2173
|
+
// `backgroundGradient` grammar minus the radial variant, because a component
|
|
2174
|
+
// fill paints through the `<Gradient>` primitive, which is linear on both
|
|
2175
|
+
// hosts. `themeComponentGradient` publishes the angle bounds + default so the
|
|
2176
|
+
// coercion, the Studio control and the planner prompt cannot disagree. The
|
|
2177
|
+
// token binds to per-instance style fields the target widgets read
|
|
2178
|
+
// (`gradient` on Button, `cardGradient` on every card surface,
|
|
2179
|
+
// `submitGradient` on a form's submit button), so the theme value is a
|
|
2180
|
+
// DEFAULT an author overrides per instance — or suppresses with an explicit
|
|
2181
|
+
// null, which is how one button stays flat while the rest are gradiented.
|
|
2182
|
+
// Additive: no export changed signature and a theme with no gradient token
|
|
2183
|
+
// renders exactly as before.
|
|
2184
|
+
version: "1.46.0",
|
|
2091
2185
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2092
2186
|
hooks: HOOKS,
|
|
2093
2187
|
primitives: PRIMITIVES,
|
|
@@ -2100,6 +2194,7 @@ const CONTRACT = deepFreeze({
|
|
|
2100
2194
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
2101
2195
|
themeComponents: THEME_COMPONENTS,
|
|
2102
2196
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
2197
|
+
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
2103
2198
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
2104
2199
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
2105
2200
|
bannedApis: BANNED_APIS,
|
|
@@ -2122,10 +2217,164 @@ function requiredContextKeys() {
|
|
|
2122
2217
|
return [...keys];
|
|
2123
2218
|
}
|
|
2124
2219
|
|
|
2220
|
+
// ---------------------------------------------------------------------------
|
|
2221
|
+
// Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
|
|
2222
|
+
//
|
|
2223
|
+
// The colour maths every host applies on top of a tenant's picked palette.
|
|
2224
|
+
// It lives HERE, in the dual-format contract, because BOTH hosts need it and
|
|
2225
|
+
// both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
|
|
2226
|
+
// and the exported Expo app through the compiler's generated widget-theme
|
|
2227
|
+
// module (which already imports `CONTRACT` from this package). Previously each
|
|
2228
|
+
// host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
|
|
2229
|
+
// one source removes that drift (CLAUDE.md §3).
|
|
2230
|
+
// ---------------------------------------------------------------------------
|
|
2231
|
+
|
|
2232
|
+
const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
|
2233
|
+
|
|
2234
|
+
function isHexColor(value) {
|
|
2235
|
+
return typeof value === "string" && HEX_RE.test(value);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function hexChannels(hex) {
|
|
2239
|
+
let h = hex.slice(1);
|
|
2240
|
+
if (h.length === 3) {
|
|
2241
|
+
h = h
|
|
2242
|
+
.split("")
|
|
2243
|
+
.map((c) => c + c)
|
|
2244
|
+
.join("");
|
|
2245
|
+
}
|
|
2246
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/** Relative luminance (WCAG 2.1) of a hex colour. */
|
|
2250
|
+
function relativeLuminance(hex) {
|
|
2251
|
+
const [r, g, b] = hexChannels(hex).map((v) => v / 255);
|
|
2252
|
+
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
2253
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** WCAG contrast ratio between two hex colours. Always >= 1. */
|
|
2257
|
+
function contrastRatio(a, b) {
|
|
2258
|
+
const la = relativeLuminance(a);
|
|
2259
|
+
const lb = relativeLuminance(b);
|
|
2260
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
|
|
2264
|
+
function mixHex(a, b, t) {
|
|
2265
|
+
if (!isHexColor(a) || !isHexColor(b)) return a;
|
|
2266
|
+
const pa = hexChannels(a);
|
|
2267
|
+
const pb = hexChannels(b);
|
|
2268
|
+
const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
|
|
2269
|
+
const hex = (n) =>
|
|
2270
|
+
Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
|
|
2271
|
+
return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* Pick a readable text colour (`dark` or `light`) for a solid background.
|
|
2276
|
+
* A light background gets the dark option and vice versa, so `onPrimary` /
|
|
2277
|
+
* `onSecondary` follow whatever accent the tenant picked.
|
|
2278
|
+
*/
|
|
2279
|
+
function readableTextColor(hex, dark, light) {
|
|
2280
|
+
if (!isHexColor(hex)) return light;
|
|
2281
|
+
return relativeLuminance(hex) > 0.55 ? dark : light;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// A tint at 10% accent reads as "quietly accented" on both a white and a dark
|
|
2285
|
+
// surface without competing with the full-strength accent moment.
|
|
2286
|
+
const ACCENT_TINT_WEIGHT = 0.9;
|
|
2287
|
+
// How far `primaryStrong` travels toward the text colour. Deepens the accent on
|
|
2288
|
+
// a light theme and lightens it on a dark one, which is the right direction for
|
|
2289
|
+
// a gradient's far stop in both.
|
|
2290
|
+
const ACCENT_STRONG_WEIGHT = 0.22;
|
|
2291
|
+
const ACCENT_TEXT_MIN_CONTRAST = 4.5;
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
|
|
2295
|
+
* resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
|
|
2296
|
+
*
|
|
2297
|
+
* `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
|
|
2298
|
+
* text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
|
|
2299
|
+
* is legible for EVERY tenant accent rather than only for accents that happen
|
|
2300
|
+
* to be dark enough. Returns `null` when the inputs aren't usable hex, letting
|
|
2301
|
+
* the caller keep the default tokens.
|
|
2302
|
+
*/
|
|
2303
|
+
function deriveAccentTints(primary, surface, onSurface) {
|
|
2304
|
+
if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
|
|
2305
|
+
return null;
|
|
2306
|
+
}
|
|
2307
|
+
const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
|
|
2308
|
+
let onPrimarySoft = primary;
|
|
2309
|
+
for (let t = 0; t <= 0.9; t += 0.05) {
|
|
2310
|
+
onPrimarySoft = mixHex(primary, onSurface, t);
|
|
2311
|
+
if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
|
|
2312
|
+
break;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
return {
|
|
2316
|
+
primarySoft,
|
|
2317
|
+
onPrimarySoft,
|
|
2318
|
+
primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/**
|
|
2323
|
+
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
2324
|
+
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
2325
|
+
* top-left, y down). Single source for the SDK's `<Gradient>` primitive and
|
|
2326
|
+
* the compiler's app-background backdrop, so a widget gradient and the app
|
|
2327
|
+
* gradient read the same angle identically (CLAUDE.md §3).
|
|
2328
|
+
*/
|
|
2329
|
+
function gradientAngleToVector(angle) {
|
|
2330
|
+
const deg = Number.isFinite(angle) ? angle : 180;
|
|
2331
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
2332
|
+
const dx = Math.cos(rad);
|
|
2333
|
+
const dy = Math.sin(rad);
|
|
2334
|
+
return {
|
|
2335
|
+
start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
|
|
2336
|
+
end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// Alpha is allowed here (unlike HEX_RE) because the Mason build runner already
|
|
2341
|
+
// persists 8-digit component colours; the host must not drop what it accepted.
|
|
2342
|
+
const GRADIENT_HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
2346
|
+
*
|
|
2347
|
+
* ONE validator for a value arriving by two routes — the theme token and the
|
|
2348
|
+
* author's never-coerced per-instance `props.style` — so `set_theme` and the
|
|
2349
|
+
* renderer cannot disagree about the same value (CLAUDE.md §3). Both stops are
|
|
2350
|
+
* required: a one-stop gradient would paint a fill the author never chose. The
|
|
2351
|
+
* hex grammar is also the injection guard — these stops are interpolated into
|
|
2352
|
+
* CSS and into generated export source.
|
|
2353
|
+
*/
|
|
2354
|
+
function normaliseComponentGradient(raw) {
|
|
2355
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
2356
|
+
const from = typeof raw.from === "string" ? raw.from.trim() : "";
|
|
2357
|
+
const to = typeof raw.to === "string" ? raw.to.trim() : "";
|
|
2358
|
+
if (!GRADIENT_HEX_RE.test(from) || !GRADIENT_HEX_RE.test(to)) return null;
|
|
2359
|
+
const { defaultAngle } = THEME_COMPONENT_GRADIENT;
|
|
2360
|
+
// Read once — a second read of an accessor could return a different value and
|
|
2361
|
+
// land NaN in a style.
|
|
2362
|
+
const angle = raw.angle;
|
|
2363
|
+
const deg = Number.isFinite(angle) ? Math.round(angle) : defaultAngle;
|
|
2364
|
+
return { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2125
2367
|
export {
|
|
2126
2368
|
CONTRACT,
|
|
2127
2369
|
isHookAllowed,
|
|
2128
2370
|
requiredContextKeys,
|
|
2371
|
+
isHexColor,
|
|
2372
|
+
mixHex,
|
|
2373
|
+
contrastRatio,
|
|
2374
|
+
readableTextColor,
|
|
2375
|
+
deriveAccentTints,
|
|
2376
|
+
gradientAngleToVector,
|
|
2377
|
+
normaliseComponentGradient,
|
|
2129
2378
|
widgetTranslationPrefix,
|
|
2130
2379
|
widgetTranslationKey,
|
|
2131
2380
|
sharedTranslationPrefix,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — the `<Gradient>` colour-stop normaliser,
|
|
2
|
+
// shared by the web (./gradient.js) and native (./gradient.native.js) builds.
|
|
3
|
+
//
|
|
4
|
+
// Its own module because it must be platform-neutral: importing it from
|
|
5
|
+
// ./gradient.js would drag `react-native-web` into the native bundle.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalise the author's `colors` prop to at least two colour stops, or `null`
|
|
9
|
+
* when there is nothing paintable.
|
|
10
|
+
*
|
|
11
|
+
* A single stop is a legitimate authoring mistake (a "gradient" of one colour);
|
|
12
|
+
* doubling it renders a flat fill rather than throwing, which keeps a
|
|
13
|
+
* half-finished widget visible on the canvas instead of blanking it.
|
|
14
|
+
*/
|
|
15
|
+
export function gradientStops(colors) {
|
|
16
|
+
const list = (Array.isArray(colors) ? colors : []).filter(
|
|
17
|
+
(c) => typeof c === "string" && c.trim(),
|
|
18
|
+
);
|
|
19
|
+
if (list.length === 0) return null;
|
|
20
|
+
return list.length === 1 ? [list[0], list[0]] : list;
|
|
21
|
+
}
|
package/dist/gradient.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (web).
|
|
2
|
+
//
|
|
3
|
+
// Widgets had no gradient at all: `expo-linear-gradient` is a vetted import, but
|
|
4
|
+
// an AI-agent widget is only TRANSPILED (never bundled), so a bare
|
|
5
|
+
// `expo-linear-gradient` import has nothing to resolve against on the web
|
|
6
|
+
// Player. Exposing the gradient as an SDK primitive fixes that for every widget
|
|
7
|
+
// kind at once — the SDK is already host-shimmed, so authors import from the one
|
|
8
|
+
// entry point they always import from.
|
|
9
|
+
//
|
|
10
|
+
// Web paints a CSS `linear-gradient`. react-native-web (0.21) passes
|
|
11
|
+
// `backgroundImage` straight through to the DOM — it validates only against a
|
|
12
|
+
// shortform blocklist (`background`, `font`, …), and `backgroundImage` is not on
|
|
13
|
+
// it — so a plain `View` carries the gradient with no DOM escape hatch.
|
|
14
|
+
//
|
|
15
|
+
// The native counterpart (./gradient.native.js) delegates to
|
|
16
|
+
// expo-linear-gradient, projecting the SAME CSS angle through the SAME
|
|
17
|
+
// `gradientAngleToVector` from the contract. Keep the two in lockstep.
|
|
18
|
+
|
|
19
|
+
import React from "react";
|
|
20
|
+
import * as ReactNative from "react-native-web";
|
|
21
|
+
import { gradientStops } from "./gradient-stops.js";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ANGLE = 180;
|
|
24
|
+
|
|
25
|
+
export function Gradient({ colors, angle = DEFAULT_ANGLE, style, ...rest }) {
|
|
26
|
+
const stops = gradientStops(colors);
|
|
27
|
+
const deg = Number.isFinite(angle) ? angle : DEFAULT_ANGLE;
|
|
28
|
+
const gradientStyle = stops
|
|
29
|
+
? { backgroundImage: `linear-gradient(${deg}deg, ${stops.join(", ")})` }
|
|
30
|
+
: null;
|
|
31
|
+
return React.createElement(ReactNative.View, {
|
|
32
|
+
...rest,
|
|
33
|
+
style: [style, gradientStyle],
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (native).
|
|
2
|
+
//
|
|
3
|
+
// Delegates to expo-linear-gradient, which is pinned UNCONDITIONALLY in every
|
|
4
|
+
// export (BASE_DEPENDENCIES in backend/src/core/services/export-dependencies.ts,
|
|
5
|
+
// where the app-shell backdrop already uses it), so Metro always resolves it.
|
|
6
|
+
//
|
|
7
|
+
// The web counterpart (./gradient.js) paints a CSS `linear-gradient`. Both take
|
|
8
|
+
// the SAME CSS-degree `angle` and run it through the SAME
|
|
9
|
+
// `gradientAngleToVector` from the contract, so a widget authored once reads
|
|
10
|
+
// identically on both hosts (widget-parity skill).
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { LinearGradient } from "expo-linear-gradient";
|
|
14
|
+
import { gradientAngleToVector } from "./contract.js";
|
|
15
|
+
import { gradientStops } from "./gradient-stops.js";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_ANGLE = 180;
|
|
18
|
+
|
|
19
|
+
export function Gradient({ colors, angle = DEFAULT_ANGLE, style, ...rest }) {
|
|
20
|
+
const stops = gradientStops(colors);
|
|
21
|
+
if (!stops) return React.createElement(LinearGradient, { ...rest, style });
|
|
22
|
+
const { start, end } = gradientAngleToVector(
|
|
23
|
+
Number.isFinite(angle) ? angle : DEFAULT_ANGLE,
|
|
24
|
+
);
|
|
25
|
+
return React.createElement(LinearGradient, {
|
|
26
|
+
...rest,
|
|
27
|
+
colors: stops,
|
|
28
|
+
start,
|
|
29
|
+
end,
|
|
30
|
+
style,
|
|
31
|
+
});
|
|
32
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -12,7 +12,17 @@ export function resolveProps<T = Record<string, unknown>>(
|
|
|
12
12
|
props: unknown,
|
|
13
13
|
): T;
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// sc-3727: a `gradient` token's value, the one non-scalar token type.
|
|
16
|
+
export interface ThemeComponentGradient {
|
|
17
|
+
from: string;
|
|
18
|
+
to: string;
|
|
19
|
+
angle: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ThemeComponentStyle = Record<
|
|
23
|
+
string,
|
|
24
|
+
string | number | ThemeComponentGradient
|
|
25
|
+
>;
|
|
16
26
|
export type ThemeComponents = Record<string, ThemeComponentStyle>;
|
|
17
27
|
|
|
18
28
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -267,10 +267,26 @@ export interface WidgetManifest {
|
|
|
267
267
|
translations?: Record<string, { en: string } & Record<string, string>>;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/** A React Native shadow style object — one level of `ThemeTokens.elevation`. */
|
|
271
|
+
export interface ElevationLevel {
|
|
272
|
+
shadowColor?: string;
|
|
273
|
+
shadowOffset?: { width: number; height: number };
|
|
274
|
+
shadowOpacity?: number;
|
|
275
|
+
shadowRadius?: number;
|
|
276
|
+
/** Android elevation. */
|
|
277
|
+
elevation?: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
270
280
|
export interface ThemeTokens {
|
|
271
281
|
colors: {
|
|
272
282
|
primary: string;
|
|
273
283
|
onPrimary: string;
|
|
284
|
+
/** sc-3696: tint of the accent over the surface — chips, quiet buttons. */
|
|
285
|
+
primarySoft: string;
|
|
286
|
+
/** sc-3696: contrast-guaranteed text/icon colour on `primarySoft`. */
|
|
287
|
+
onPrimarySoft: string;
|
|
288
|
+
/** sc-3696: the accent's deeper end — a gradient's far stop. */
|
|
289
|
+
primaryStrong: string;
|
|
274
290
|
secondary: string;
|
|
275
291
|
onSecondary: string;
|
|
276
292
|
surface: string;
|
|
@@ -278,6 +294,14 @@ export interface ThemeTokens {
|
|
|
278
294
|
danger: string;
|
|
279
295
|
[k: string]: string;
|
|
280
296
|
};
|
|
297
|
+
/** sc-3696: the shared elevation vocabulary. Spread a level into a style. */
|
|
298
|
+
elevation: {
|
|
299
|
+
none: ElevationLevel;
|
|
300
|
+
sm: ElevationLevel;
|
|
301
|
+
md: ElevationLevel;
|
|
302
|
+
lg: ElevationLevel;
|
|
303
|
+
xl: ElevationLevel;
|
|
304
|
+
};
|
|
281
305
|
spacing: { xs: number; sm: number; md: number; lg: number; xl: number };
|
|
282
306
|
radii: { sm: number; md: number; lg: number; pill: number };
|
|
283
307
|
typography: {
|
|
@@ -1339,6 +1363,91 @@ export const Icon: (props: {
|
|
|
1339
1363
|
color?: string;
|
|
1340
1364
|
}) => any;
|
|
1341
1365
|
|
|
1366
|
+
/**
|
|
1367
|
+
* REQ-AI-AGENT-DESIGN (sc-3696) — linear-gradient surface. A `View` that paints
|
|
1368
|
+
* a gradient behind its children, so it replaces the `View` you would otherwise
|
|
1369
|
+
* give a flat `backgroundColor`. Web paints a CSS `linear-gradient`; native
|
|
1370
|
+
* delegates to expo-linear-gradient through the same angle projection.
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* <Gradient
|
|
1374
|
+
* colors={[theme.colors.primary, theme.colors.primaryStrong]}
|
|
1375
|
+
* angle={160}
|
|
1376
|
+
* style={{ borderRadius: theme.radii.lg, padding: theme.spacing.md }}
|
|
1377
|
+
* >
|
|
1378
|
+
* <Text style={{ color: theme.colors.onPrimary }}>Log a glass</Text>
|
|
1379
|
+
* </Gradient>
|
|
1380
|
+
*/
|
|
1381
|
+
export const Gradient: (props: {
|
|
1382
|
+
/** Two or more colour stops. Use theme colour roles, never raw hex. */
|
|
1383
|
+
colors: string[];
|
|
1384
|
+
/** CSS degrees: 0 = to top, 90 = to right. Defaults to 180 (to bottom). */
|
|
1385
|
+
angle?: number;
|
|
1386
|
+
style?: any;
|
|
1387
|
+
children?: ReactNode;
|
|
1388
|
+
}) => any;
|
|
1389
|
+
|
|
1390
|
+
// ------------------------------------------------------- theme derivation
|
|
1391
|
+
// sc-3696: the colour maths both hosts resolve `useTheme()` with. Exported so
|
|
1392
|
+
// the Player (frontend/src/services/widgetTheme.js) and the exported app's
|
|
1393
|
+
// generated theme module share ONE implementation instead of mirrored copies.
|
|
1394
|
+
|
|
1395
|
+
/** True when `value` is a 3- or 6-digit hex colour string. */
|
|
1396
|
+
export function isHexColor(value: unknown): boolean;
|
|
1397
|
+
|
|
1398
|
+
/** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
|
|
1399
|
+
export function mixHex(a: string, b: string, t: number): string;
|
|
1400
|
+
|
|
1401
|
+
/** WCAG contrast ratio between two hex colours. Always >= 1. */
|
|
1402
|
+
export function contrastRatio(a: string, b: string): number;
|
|
1403
|
+
|
|
1404
|
+
/** Pick `dark` or `light` — whichever is readable on the given background. */
|
|
1405
|
+
export function readableTextColor(
|
|
1406
|
+
hex: string,
|
|
1407
|
+
dark: string,
|
|
1408
|
+
light: string,
|
|
1409
|
+
): string;
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Derive the accent's quiet tiers from a resolved palette. `onPrimarySoft` is
|
|
1413
|
+
* stepped toward `onSurface` until it clears WCAG AA against the tint, so a chip
|
|
1414
|
+
* label is legible for every tenant accent. `null` when the inputs aren't hex.
|
|
1415
|
+
*/
|
|
1416
|
+
export function deriveAccentTints(
|
|
1417
|
+
primary: string,
|
|
1418
|
+
surface: string,
|
|
1419
|
+
onSurface: string,
|
|
1420
|
+
): {
|
|
1421
|
+
primarySoft: string;
|
|
1422
|
+
onPrimarySoft: string;
|
|
1423
|
+
primaryStrong: string;
|
|
1424
|
+
} | null;
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Map a CSS gradient angle (0 = to top, 90 = to right) to the `{ start, end }`
|
|
1428
|
+
* unit vectors expo-linear-gradient expects.
|
|
1429
|
+
*/
|
|
1430
|
+
export function gradientAngleToVector(angle: number): {
|
|
1431
|
+
start: { x: number; y: number };
|
|
1432
|
+
end: { x: number; y: number };
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
export interface ComponentGradient {
|
|
1436
|
+
from: string;
|
|
1437
|
+
to: string;
|
|
1438
|
+
angle: number;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Normalise a component `gradient` style value to `{ from, to, angle }`, or null
|
|
1443
|
+
* when it is unusable. Both stops are required; the angle wraps into 0-359.
|
|
1444
|
+
* Shared by the theme-token coercion and the widget render path, so the same
|
|
1445
|
+
* value cannot resolve two ways.
|
|
1446
|
+
*/
|
|
1447
|
+
export function normaliseComponentGradient(
|
|
1448
|
+
raw: unknown,
|
|
1449
|
+
): ComponentGradient | null;
|
|
1450
|
+
|
|
1342
1451
|
// Linter
|
|
1343
1452
|
export interface LintFinding {
|
|
1344
1453
|
rule: string;
|
package/dist/index.js
CHANGED
|
@@ -70,7 +70,24 @@ export {
|
|
|
70
70
|
Icon,
|
|
71
71
|
DateTimePicker,
|
|
72
72
|
FilePicker,
|
|
73
|
+
Gradient,
|
|
73
74
|
} from "./primitives.js";
|
|
74
75
|
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
75
|
-
|
|
76
|
+
// sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
|
|
77
|
+
// `useTheme()` with (the Player through widgetTheme.js, the export through its
|
|
78
|
+
// generated theme module), plus the gradient angle projection `<Gradient>`
|
|
79
|
+
// shares with the app-shell backdrop. Keep comments OUT of the export clause —
|
|
80
|
+
// the contract test's orphan-export scanner parses it with a regex.
|
|
81
|
+
export {
|
|
82
|
+
CONTRACT,
|
|
83
|
+
isHookAllowed,
|
|
84
|
+
requiredContextKeys,
|
|
85
|
+
isHexColor,
|
|
86
|
+
mixHex,
|
|
87
|
+
contrastRatio,
|
|
88
|
+
readableTextColor,
|
|
89
|
+
deriveAccentTints,
|
|
90
|
+
gradientAngleToVector,
|
|
91
|
+
normaliseComponentGradient,
|
|
92
|
+
} from "./contract.js";
|
|
76
93
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
package/dist/index.native.js
CHANGED
|
@@ -68,7 +68,24 @@ export {
|
|
|
68
68
|
Icon,
|
|
69
69
|
DateTimePicker,
|
|
70
70
|
FilePicker,
|
|
71
|
+
Gradient,
|
|
71
72
|
} from "./primitives.native.js";
|
|
72
73
|
export { lintSource, bannedIdentifiers } from "./linter.js";
|
|
73
|
-
|
|
74
|
+
// sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
|
|
75
|
+
// `useTheme()` with (the Player through widgetTheme.js, the export through its
|
|
76
|
+
// generated theme module), plus the gradient angle projection `<Gradient>`
|
|
77
|
+
// shares with the app-shell backdrop. Keep comments OUT of the export clause —
|
|
78
|
+
// the contract test's orphan-export scanner parses it with a regex.
|
|
79
|
+
export {
|
|
80
|
+
CONTRACT,
|
|
81
|
+
isHookAllowed,
|
|
82
|
+
requiredContextKeys,
|
|
83
|
+
isHexColor,
|
|
84
|
+
mixHex,
|
|
85
|
+
contrastRatio,
|
|
86
|
+
readableTextColor,
|
|
87
|
+
deriveAccentTints,
|
|
88
|
+
gradientAngleToVector,
|
|
89
|
+
normaliseComponentGradient,
|
|
90
|
+
} from "./contract.js";
|
|
74
91
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
package/dist/primitives.js
CHANGED
|
@@ -76,3 +76,7 @@ export { DateTimePicker } from "./datetimepicker.js";
|
|
|
76
76
|
// a vetted expo-document-picker pin lands. `isSupported` is a static
|
|
77
77
|
// boolean widgets branch on to hide the trigger on native.
|
|
78
78
|
export { FilePicker } from "./filepicker.js";
|
|
79
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (web). Paints a CSS
|
|
80
|
+
// `linear-gradient` on a View; the native build (./gradient.native.js) delegates
|
|
81
|
+
// to expo-linear-gradient through the same angle projection.
|
|
82
|
+
export { Gradient } from "./gradient.js";
|
|
@@ -36,3 +36,7 @@ export { DateTimePicker } from "./datetimepicker.native.js";
|
|
|
36
36
|
// equivalent yet, so this build renders a disabled trigger and exposes
|
|
37
37
|
// `FilePicker.isSupported = false` for widgets to branch on.
|
|
38
38
|
export { FilePicker } from "./filepicker.native.js";
|
|
39
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — `<Gradient>` (native). Delegates to
|
|
40
|
+
// expo-linear-gradient (pinned unconditionally in every export); the web build
|
|
41
|
+
// (./gradient.js) paints the equivalent CSS `linear-gradient`.
|
|
42
|
+
export { Gradient } from "./gradient.native.js";
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// widget reads. The Mason build runner validates `set_theme` against the SAME
|
|
24
24
|
// literal, so what a planner may emit and what a host applies cannot diverge.
|
|
25
25
|
|
|
26
|
-
const { CONTRACT } = require("./contract.cjs");
|
|
26
|
+
const { CONTRACT, normaliseComponentGradient } = require("./contract.cjs");
|
|
27
27
|
|
|
28
28
|
// 3-, 6- and 8-digit hex, matching what the build runner persists — the host
|
|
29
29
|
// must never drop a colour the runner already accepted.
|
|
@@ -47,6 +47,11 @@ function coerceToken(def, value) {
|
|
|
47
47
|
if (def.type === "shadow") {
|
|
48
48
|
return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
|
|
49
49
|
}
|
|
50
|
+
// sc-3727: the gradient value has its own normaliser on the contract, shared
|
|
51
|
+
// with the widget render path so both routes agree (CLAUDE.md §3).
|
|
52
|
+
if (def.type === "gradient") {
|
|
53
|
+
return normaliseComponentGradient(value) || undefined;
|
|
54
|
+
}
|
|
50
55
|
return undefined;
|
|
51
56
|
}
|
|
52
57
|
|
package/dist/theme-components.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// widget reads. The Mason build runner validates `set_theme` against the SAME
|
|
16
16
|
// literal, so what a planner may emit and what a host applies cannot diverge.
|
|
17
17
|
|
|
18
|
-
import { CONTRACT } from "./contract.js";
|
|
18
|
+
import { CONTRACT, normaliseComponentGradient } from "./contract.js";
|
|
19
19
|
|
|
20
20
|
// 3-, 6- and 8-digit hex, matching what the build runner persists — the host
|
|
21
21
|
// must never drop a colour the runner already accepted.
|
|
@@ -39,6 +39,11 @@ function coerceToken(def, value) {
|
|
|
39
39
|
if (def.type === "shadow") {
|
|
40
40
|
return CONTRACT.themeComponentShadows.includes(value) ? value : undefined;
|
|
41
41
|
}
|
|
42
|
+
// sc-3727: the gradient value has its own normaliser on the contract, shared
|
|
43
|
+
// with the widget render path so both routes agree (CLAUDE.md §3).
|
|
44
|
+
if (def.type === "gradient") {
|
|
45
|
+
return normaliseComponentGradient(value) || undefined;
|
|
46
|
+
}
|
|
42
47
|
return undefined;
|
|
43
48
|
}
|
|
44
49
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|