@colixsystems/widget-sdk 0.67.0 → 0.68.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 +7 -5
- package/dist/contract.cjs +191 -2
- package/dist/contract.js +191 -2
- package/dist/gradient-stops.js +21 -0
- package/dist/gradient.js +35 -0
- package/dist/gradient.native.js +32 -0
- package/dist/hooks.js +35 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +17 -1
- package/dist/index.native.js +17 -1
- package/dist/primitives.js +4 -0
- package/dist/primitives.native.js +4 -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, 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) |
|
|
@@ -514,17 +514,19 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
514
514
|
|
|
515
515
|
**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
516
|
|
|
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.
|
|
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 — `radii.lg` for cards and hero surfaces, `radii.md` for controls nested inside one. 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 full-strength `colors.primary` (with `colors.onPrimary` for text on it) for the single most important action or metric.
|
|
519
519
|
- **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.
|
|
520
|
+
- **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.
|
|
521
|
+
- **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.
|
|
522
|
+
- **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
523
|
- **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
524
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
523
525
|
- **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
526
|
- **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
527
|
- **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
528
|
|
|
527
|
-
**Honest ceilings:** the styling surface is React Native style objects, not full CSS.
|
|
529
|
+
**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
530
|
|
|
529
531
|
## Managing app users from a widget
|
|
530
532
|
|
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({
|
|
@@ -376,7 +423,13 @@ const HOOKS = [
|
|
|
376
423
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
377
424
|
"ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
|
|
378
425
|
"to the created file row or throws the wire error; a 404 means the " +
|
|
379
|
-
"destination folder denied a write (REQ-FSH canWrite gate)."
|
|
426
|
+
"destination folder denied a write (REQ-FSH canWrite gate). " +
|
|
427
|
+
"ALWAYS pass spaceType explicitly — omitting it falls back to " +
|
|
428
|
+
"'project', which is unreadable by a logged-out visitor. Choose it by " +
|
|
429
|
+
"who must SEE the file: 'public' for content the app displays to " +
|
|
430
|
+
"anyone browsing (product, catalogue, gallery, cover images), " +
|
|
431
|
+
"'project' for content restricted to signed-in workspace users, " +
|
|
432
|
+
"'personal' for a file private to the uploading app user.",
|
|
380
433
|
returnShape: {
|
|
381
434
|
upload: "(file, { folderId? }) => Promise<FilestoreFile>",
|
|
382
435
|
uploading: "boolean",
|
|
@@ -958,6 +1011,16 @@ const PRIMITIVES = [
|
|
|
958
1011
|
rnComponent: null,
|
|
959
1012
|
docsUrl: null,
|
|
960
1013
|
},
|
|
1014
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
|
|
1015
|
+
// no gradient at all, which is why generated widgets read flatter than the
|
|
1016
|
+
// competition; this is the ONE cross-platform way to paint one.
|
|
1017
|
+
{
|
|
1018
|
+
name: "Gradient",
|
|
1019
|
+
description:
|
|
1020
|
+
"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`.",
|
|
1021
|
+
rnComponent: "expo-linear-gradient",
|
|
1022
|
+
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
1023
|
+
},
|
|
961
1024
|
];
|
|
962
1025
|
|
|
963
1026
|
const CATEGORIES = [
|
|
@@ -1573,7 +1636,7 @@ const VETTED_IMPORTS = [
|
|
|
1573
1636
|
platforms: ["web", "native"],
|
|
1574
1637
|
category: "drawing",
|
|
1575
1638
|
description:
|
|
1576
|
-
"Linear-gradient fill (<LinearGradient colors={[...]} />).
|
|
1639
|
+
"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
1640
|
},
|
|
1578
1641
|
{
|
|
1579
1642
|
specifier: "lottie-react-native",
|
|
@@ -2122,10 +2185,136 @@ function requiredContextKeys() {
|
|
|
2122
2185
|
return [...keys];
|
|
2123
2186
|
}
|
|
2124
2187
|
|
|
2188
|
+
// ---------------------------------------------------------------------------
|
|
2189
|
+
// Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
|
|
2190
|
+
//
|
|
2191
|
+
// The colour maths every host applies on top of a tenant's picked palette.
|
|
2192
|
+
// It lives HERE, in the dual-format contract, because BOTH hosts need it and
|
|
2193
|
+
// both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
|
|
2194
|
+
// and the exported Expo app through the compiler's generated widget-theme
|
|
2195
|
+
// module (which already imports `CONTRACT` from this package). Previously each
|
|
2196
|
+
// host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
|
|
2197
|
+
// one source removes that drift (CLAUDE.md §3).
|
|
2198
|
+
// ---------------------------------------------------------------------------
|
|
2199
|
+
|
|
2200
|
+
const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
|
2201
|
+
|
|
2202
|
+
function isHexColor(value) {
|
|
2203
|
+
return typeof value === "string" && HEX_RE.test(value);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
function hexChannels(hex) {
|
|
2207
|
+
let h = hex.slice(1);
|
|
2208
|
+
if (h.length === 3) {
|
|
2209
|
+
h = h
|
|
2210
|
+
.split("")
|
|
2211
|
+
.map((c) => c + c)
|
|
2212
|
+
.join("");
|
|
2213
|
+
}
|
|
2214
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** Relative luminance (WCAG 2.1) of a hex colour. */
|
|
2218
|
+
function relativeLuminance(hex) {
|
|
2219
|
+
const [r, g, b] = hexChannels(hex).map((v) => v / 255);
|
|
2220
|
+
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
2221
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** WCAG contrast ratio between two hex colours. Always >= 1. */
|
|
2225
|
+
function contrastRatio(a, b) {
|
|
2226
|
+
const la = relativeLuminance(a);
|
|
2227
|
+
const lb = relativeLuminance(b);
|
|
2228
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
|
|
2232
|
+
function mixHex(a, b, t) {
|
|
2233
|
+
if (!isHexColor(a) || !isHexColor(b)) return a;
|
|
2234
|
+
const pa = hexChannels(a);
|
|
2235
|
+
const pb = hexChannels(b);
|
|
2236
|
+
const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
|
|
2237
|
+
const hex = (n) =>
|
|
2238
|
+
Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
|
|
2239
|
+
return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* Pick a readable text colour (`dark` or `light`) for a solid background.
|
|
2244
|
+
* A light background gets the dark option and vice versa, so `onPrimary` /
|
|
2245
|
+
* `onSecondary` follow whatever accent the tenant picked.
|
|
2246
|
+
*/
|
|
2247
|
+
function readableTextColor(hex, dark, light) {
|
|
2248
|
+
if (!isHexColor(hex)) return light;
|
|
2249
|
+
return relativeLuminance(hex) > 0.55 ? dark : light;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// A tint at 10% accent reads as "quietly accented" on both a white and a dark
|
|
2253
|
+
// surface without competing with the full-strength accent moment.
|
|
2254
|
+
const ACCENT_TINT_WEIGHT = 0.9;
|
|
2255
|
+
// How far `primaryStrong` travels toward the text colour. Deepens the accent on
|
|
2256
|
+
// a light theme and lightens it on a dark one, which is the right direction for
|
|
2257
|
+
// a gradient's far stop in both.
|
|
2258
|
+
const ACCENT_STRONG_WEIGHT = 0.22;
|
|
2259
|
+
const ACCENT_TEXT_MIN_CONTRAST = 4.5;
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
|
|
2263
|
+
* resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
|
|
2264
|
+
*
|
|
2265
|
+
* `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
|
|
2266
|
+
* text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
|
|
2267
|
+
* is legible for EVERY tenant accent rather than only for accents that happen
|
|
2268
|
+
* to be dark enough. Returns `null` when the inputs aren't usable hex, letting
|
|
2269
|
+
* the caller keep the default tokens.
|
|
2270
|
+
*/
|
|
2271
|
+
function deriveAccentTints(primary, surface, onSurface) {
|
|
2272
|
+
if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
|
|
2273
|
+
return null;
|
|
2274
|
+
}
|
|
2275
|
+
const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
|
|
2276
|
+
let onPrimarySoft = primary;
|
|
2277
|
+
for (let t = 0; t <= 0.9; t += 0.05) {
|
|
2278
|
+
onPrimarySoft = mixHex(primary, onSurface, t);
|
|
2279
|
+
if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
|
|
2280
|
+
break;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
primarySoft,
|
|
2285
|
+
onPrimarySoft,
|
|
2286
|
+
primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
2292
|
+
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
2293
|
+
* top-left, y down). Single source for the SDK's `<Gradient>` primitive and
|
|
2294
|
+
* the compiler's app-background backdrop, so a widget gradient and the app
|
|
2295
|
+
* gradient read the same angle identically (CLAUDE.md §3).
|
|
2296
|
+
*/
|
|
2297
|
+
function gradientAngleToVector(angle) {
|
|
2298
|
+
const deg = Number.isFinite(angle) ? angle : 180;
|
|
2299
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
2300
|
+
const dx = Math.cos(rad);
|
|
2301
|
+
const dy = Math.sin(rad);
|
|
2302
|
+
return {
|
|
2303
|
+
start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
|
|
2304
|
+
end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2125
2308
|
module.exports = {
|
|
2126
2309
|
CONTRACT,
|
|
2127
2310
|
isHookAllowed,
|
|
2128
2311
|
requiredContextKeys,
|
|
2312
|
+
isHexColor,
|
|
2313
|
+
mixHex,
|
|
2314
|
+
contrastRatio,
|
|
2315
|
+
readableTextColor,
|
|
2316
|
+
deriveAccentTints,
|
|
2317
|
+
gradientAngleToVector,
|
|
2129
2318
|
widgetTranslationPrefix,
|
|
2130
2319
|
widgetTranslationKey,
|
|
2131
2320
|
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({
|
|
@@ -376,7 +423,13 @@ const HOOKS = [
|
|
|
376
423
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
377
424
|
"ctx.filestore.files.upload. `upload(file, { folderId? })` resolves " +
|
|
378
425
|
"to the created file row or throws the wire error; a 404 means the " +
|
|
379
|
-
"destination folder denied a write (REQ-FSH canWrite gate)."
|
|
426
|
+
"destination folder denied a write (REQ-FSH canWrite gate). " +
|
|
427
|
+
"ALWAYS pass spaceType explicitly — omitting it falls back to " +
|
|
428
|
+
"'project', which is unreadable by a logged-out visitor. Choose it by " +
|
|
429
|
+
"who must SEE the file: 'public' for content the app displays to " +
|
|
430
|
+
"anyone browsing (product, catalogue, gallery, cover images), " +
|
|
431
|
+
"'project' for content restricted to signed-in workspace users, " +
|
|
432
|
+
"'personal' for a file private to the uploading app user.",
|
|
380
433
|
returnShape: {
|
|
381
434
|
upload: "(file, { folderId? }) => Promise<FilestoreFile>",
|
|
382
435
|
uploading: "boolean",
|
|
@@ -958,6 +1011,16 @@ const PRIMITIVES = [
|
|
|
958
1011
|
rnComponent: null,
|
|
959
1012
|
docsUrl: null,
|
|
960
1013
|
},
|
|
1014
|
+
// REQ-AI-AGENT-DESIGN (sc-3696) — the gradient surface. Widgets previously had
|
|
1015
|
+
// no gradient at all, which is why generated widgets read flatter than the
|
|
1016
|
+
// competition; this is the ONE cross-platform way to paint one.
|
|
1017
|
+
{
|
|
1018
|
+
name: "Gradient",
|
|
1019
|
+
description:
|
|
1020
|
+
"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`.",
|
|
1021
|
+
rnComponent: "expo-linear-gradient",
|
|
1022
|
+
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
1023
|
+
},
|
|
961
1024
|
];
|
|
962
1025
|
|
|
963
1026
|
const CATEGORIES = [
|
|
@@ -1573,7 +1636,7 @@ const VETTED_IMPORTS = [
|
|
|
1573
1636
|
platforms: ["web", "native"],
|
|
1574
1637
|
category: "drawing",
|
|
1575
1638
|
description:
|
|
1576
|
-
"Linear-gradient fill (<LinearGradient colors={[...]} />).
|
|
1639
|
+
"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
1640
|
},
|
|
1578
1641
|
{
|
|
1579
1642
|
specifier: "lottie-react-native",
|
|
@@ -2122,10 +2185,136 @@ function requiredContextKeys() {
|
|
|
2122
2185
|
return [...keys];
|
|
2123
2186
|
}
|
|
2124
2187
|
|
|
2188
|
+
// ---------------------------------------------------------------------------
|
|
2189
|
+
// Theme derivation (REQ-THEME-DARK + REQ-AI-AGENT-DESIGN)
|
|
2190
|
+
//
|
|
2191
|
+
// The colour maths every host applies on top of a tenant's picked palette.
|
|
2192
|
+
// It lives HERE, in the dual-format contract, because BOTH hosts need it and
|
|
2193
|
+
// both can reach it: the web Player through `frontend/src/services/widgetTheme.js`
|
|
2194
|
+
// and the exported Expo app through the compiler's generated widget-theme
|
|
2195
|
+
// module (which already imports `CONTRACT` from this package). Previously each
|
|
2196
|
+
// host carried its own hand-mirrored copy of `mixHex` / `readableTextColor` —
|
|
2197
|
+
// one source removes that drift (CLAUDE.md §3).
|
|
2198
|
+
// ---------------------------------------------------------------------------
|
|
2199
|
+
|
|
2200
|
+
const HEX_RE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
|
|
2201
|
+
|
|
2202
|
+
function isHexColor(value) {
|
|
2203
|
+
return typeof value === "string" && HEX_RE.test(value);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
function hexChannels(hex) {
|
|
2207
|
+
let h = hex.slice(1);
|
|
2208
|
+
if (h.length === 3) {
|
|
2209
|
+
h = h
|
|
2210
|
+
.split("")
|
|
2211
|
+
.map((c) => c + c)
|
|
2212
|
+
.join("");
|
|
2213
|
+
}
|
|
2214
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/** Relative luminance (WCAG 2.1) of a hex colour. */
|
|
2218
|
+
function relativeLuminance(hex) {
|
|
2219
|
+
const [r, g, b] = hexChannels(hex).map((v) => v / 255);
|
|
2220
|
+
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
2221
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
/** WCAG contrast ratio between two hex colours. Always >= 1. */
|
|
2225
|
+
function contrastRatio(a, b) {
|
|
2226
|
+
const la = relativeLuminance(a);
|
|
2227
|
+
const lb = relativeLuminance(b);
|
|
2228
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/** Linear sRGB mix of two hex colours (`t` 0 = a, 1 = b). Bad input → `a`. */
|
|
2232
|
+
function mixHex(a, b, t) {
|
|
2233
|
+
if (!isHexColor(a) || !isHexColor(b)) return a;
|
|
2234
|
+
const pa = hexChannels(a);
|
|
2235
|
+
const pb = hexChannels(b);
|
|
2236
|
+
const channel = (i) => Math.round(pa[i] + (pb[i] - pa[i]) * t);
|
|
2237
|
+
const hex = (n) =>
|
|
2238
|
+
Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0");
|
|
2239
|
+
return `#${hex(channel(0))}${hex(channel(1))}${hex(channel(2))}`;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* Pick a readable text colour (`dark` or `light`) for a solid background.
|
|
2244
|
+
* A light background gets the dark option and vice versa, so `onPrimary` /
|
|
2245
|
+
* `onSecondary` follow whatever accent the tenant picked.
|
|
2246
|
+
*/
|
|
2247
|
+
function readableTextColor(hex, dark, light) {
|
|
2248
|
+
if (!isHexColor(hex)) return light;
|
|
2249
|
+
return relativeLuminance(hex) > 0.55 ? dark : light;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// A tint at 10% accent reads as "quietly accented" on both a white and a dark
|
|
2253
|
+
// surface without competing with the full-strength accent moment.
|
|
2254
|
+
const ACCENT_TINT_WEIGHT = 0.9;
|
|
2255
|
+
// How far `primaryStrong` travels toward the text colour. Deepens the accent on
|
|
2256
|
+
// a light theme and lightens it on a dark one, which is the right direction for
|
|
2257
|
+
// a gradient's far stop in both.
|
|
2258
|
+
const ACCENT_STRONG_WEIGHT = 0.22;
|
|
2259
|
+
const ACCENT_TEXT_MIN_CONTRAST = 4.5;
|
|
2260
|
+
|
|
2261
|
+
/**
|
|
2262
|
+
* REQ-AI-AGENT-DESIGN (sc-3696): derive the accent's quiet tiers from the
|
|
2263
|
+
* resolved palette. Returns `{ primarySoft, onPrimarySoft, primaryStrong }`.
|
|
2264
|
+
*
|
|
2265
|
+
* `onPrimarySoft` is not a fixed mix: the accent is stepped toward the surface's
|
|
2266
|
+
* text colour until it clears WCAG AA (4.5:1) against the tint, so a chip label
|
|
2267
|
+
* is legible for EVERY tenant accent rather than only for accents that happen
|
|
2268
|
+
* to be dark enough. Returns `null` when the inputs aren't usable hex, letting
|
|
2269
|
+
* the caller keep the default tokens.
|
|
2270
|
+
*/
|
|
2271
|
+
function deriveAccentTints(primary, surface, onSurface) {
|
|
2272
|
+
if (!isHexColor(primary) || !isHexColor(surface) || !isHexColor(onSurface)) {
|
|
2273
|
+
return null;
|
|
2274
|
+
}
|
|
2275
|
+
const primarySoft = mixHex(primary, surface, ACCENT_TINT_WEIGHT);
|
|
2276
|
+
let onPrimarySoft = primary;
|
|
2277
|
+
for (let t = 0; t <= 0.9; t += 0.05) {
|
|
2278
|
+
onPrimarySoft = mixHex(primary, onSurface, t);
|
|
2279
|
+
if (contrastRatio(onPrimarySoft, primarySoft) >= ACCENT_TEXT_MIN_CONTRAST) {
|
|
2280
|
+
break;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
return {
|
|
2284
|
+
primarySoft,
|
|
2285
|
+
onPrimarySoft,
|
|
2286
|
+
primaryStrong: mixHex(primary, onSurface, ACCENT_STRONG_WEIGHT),
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
2292
|
+
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
2293
|
+
* top-left, y down). Single source for the SDK's `<Gradient>` primitive and
|
|
2294
|
+
* the compiler's app-background backdrop, so a widget gradient and the app
|
|
2295
|
+
* gradient read the same angle identically (CLAUDE.md §3).
|
|
2296
|
+
*/
|
|
2297
|
+
function gradientAngleToVector(angle) {
|
|
2298
|
+
const deg = Number.isFinite(angle) ? angle : 180;
|
|
2299
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
2300
|
+
const dx = Math.cos(rad);
|
|
2301
|
+
const dy = Math.sin(rad);
|
|
2302
|
+
return {
|
|
2303
|
+
start: { x: 0.5 - dx / 2, y: 0.5 - dy / 2 },
|
|
2304
|
+
end: { x: 0.5 + dx / 2, y: 0.5 + dy / 2 },
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2125
2308
|
export {
|
|
2126
2309
|
CONTRACT,
|
|
2127
2310
|
isHookAllowed,
|
|
2128
2311
|
requiredContextKeys,
|
|
2312
|
+
isHexColor,
|
|
2313
|
+
mixHex,
|
|
2314
|
+
contrastRatio,
|
|
2315
|
+
readableTextColor,
|
|
2316
|
+
deriveAccentTints,
|
|
2317
|
+
gradientAngleToVector,
|
|
2129
2318
|
widgetTranslationPrefix,
|
|
2130
2319
|
widgetTranslationKey,
|
|
2131
2320
|
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/hooks.js
CHANGED
|
@@ -593,6 +593,12 @@ function toDatastoreError(err) {
|
|
|
593
593
|
});
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
// sc-1579 — consecutive renders with a different serialized query before
|
|
597
|
+
// `useDatastoreQuery` calls the loop out. High enough that no real interaction
|
|
598
|
+
// reaches it (a settled fetch resets the count), low enough to fire instantly
|
|
599
|
+
// on a genuine loop.
|
|
600
|
+
const VOLATILE_QUERY_RENDER_LIMIT = 10;
|
|
601
|
+
|
|
596
602
|
/**
|
|
597
603
|
* Stateful datastore query hook. Returns { data, loading, error, refetch }.
|
|
598
604
|
*
|
|
@@ -682,6 +688,35 @@ export function useDatastoreQuery(table, query) {
|
|
|
682
688
|
return null;
|
|
683
689
|
}
|
|
684
690
|
})();
|
|
691
|
+
|
|
692
|
+
// sc-1579 — a query argument built from a volatile value (`Date.now()`,
|
|
693
|
+
// `new Date()`, `Math.random()`) serialises differently every render, so the
|
|
694
|
+
// effect below re-fetches forever and the widget never settles. A genuine
|
|
695
|
+
// edit (a typed filter) settles within one render because the re-render the
|
|
696
|
+
// fetch triggers reuses the same argument; a volatile one never does. That
|
|
697
|
+
// gap is the signal — no timers, no heuristics. Warned once per instance,
|
|
698
|
+
// and in production too: the Studio canvas and the widget agent's render
|
|
699
|
+
// smoke check both run production builds, which is where this must surface.
|
|
700
|
+
const volatileKeyRef = useRef({ key: queryKey, changes: 0, warned: false });
|
|
701
|
+
const keyWatch = volatileKeyRef.current;
|
|
702
|
+
if (keyWatch.key === queryKey) {
|
|
703
|
+
keyWatch.changes = 0;
|
|
704
|
+
} else {
|
|
705
|
+
keyWatch.key = queryKey;
|
|
706
|
+
keyWatch.changes += 1;
|
|
707
|
+
if (keyWatch.changes >= VOLATILE_QUERY_RENDER_LIMIT && !keyWatch.warned) {
|
|
708
|
+
keyWatch.warned = true;
|
|
709
|
+
// eslint-disable-next-line no-console
|
|
710
|
+
console.warn(
|
|
711
|
+
`useDatastoreQuery(${JSON.stringify(table)}): the query argument changed on ` +
|
|
712
|
+
`${keyWatch.changes} consecutive renders, so this widget is re-fetching in a ` +
|
|
713
|
+
"loop. Something in the query is regenerated every render — usually a date " +
|
|
714
|
+
"or random value. Compute it once outside render (or with useMemo) and pass " +
|
|
715
|
+
"the stable value in.",
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
685
720
|
useEffect(() => {
|
|
686
721
|
doFetch();
|
|
687
722
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
package/dist/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,75 @@ 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
|
+
|
|
1342
1435
|
// Linter
|
|
1343
1436
|
export interface LintFinding {
|
|
1344
1437
|
rule: string;
|
package/dist/index.js
CHANGED
|
@@ -70,7 +70,23 @@ 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
|
+
} from "./contract.js";
|
|
76
92
|
export { normalizeLucideIconName } from "./lucideIconName.js";
|
package/dist/index.native.js
CHANGED
|
@@ -68,7 +68,23 @@ 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
|
+
} from "./contract.js";
|
|
74
90
|
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";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.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-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"
|