@colixsystems/widget-sdk 0.85.1 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -7
- package/dist/contract.cjs +227 -13
- package/dist/contract.js +227 -13
- package/dist/host.d.ts +90 -1
- package/dist/host.js +19 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +69 -0
- package/dist/linter.js +87 -0
- package/dist/theme-components.cjs +101 -5
- package/dist/theme-components.js +100 -4
- package/dist/toast-host.js +193 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,11 +17,11 @@ 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, 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. |
|
|
20
|
+
| **CORE** | `useTheme()` | `{ colors, elevation, spacing, spacingScale, radii, typography, components, widgetStyles }` | `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** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
|
|
22
22
|
| **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. |
|
|
23
23
|
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
|
|
24
|
-
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external
|
|
24
|
+
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
|
|
25
25
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
26
26
|
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
27
27
|
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. |
|
|
@@ -33,7 +33,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
33
33
|
| **CORE** | `useSectionEmpty(isEmpty)` | `void` | `ctx.section.reportEmpty` — no scope. Declares that the widget has NO content to show, so the host drops its layout slot instead of reserving space (and its parent's `gap`) for it. Returning `null` is not enough: the host wraps every widget in an entrance element, so a widget rendering nothing still leaves an empty box the parent stack gaps around. For a CONDITIONALLY ABSENT section (a per-record child collection with no rows for this record), never to suppress a genuine empty state. Stays mounted while collapsed, so passing `false` brings it back. Authoring surfaces never collapse. No-op on a host that doesn't implement it. |
|
|
34
34
|
| **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
|
|
35
35
|
| **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
|
|
36
|
-
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast`
|
|
36
|
+
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
|
|
37
37
|
| **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
|
|
38
38
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
39
39
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
@@ -61,7 +61,56 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
61
61
|
|
|
62
62
|
## Status
|
|
63
63
|
|
|
64
|
-
`v0.
|
|
64
|
+
`v0.87.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
65
|
+
|
|
66
|
+
### What's new in 0.87.0 (contract 1.61.1)
|
|
67
|
+
|
|
68
|
+
**Widget toasts are actually rendered now — both hosts wire `ctx.toast` (sc-4939).**
|
|
69
|
+
|
|
70
|
+
- **The host half of `useToast()` shipped.** The hook has existed since 0.15.0 and
|
|
71
|
+
the AI widget agent has always been told to confirm a write with
|
|
72
|
+
`showToast({ kind: "success", … })` — but no host ever populated the
|
|
73
|
+
`WidgetContext.toast` slot. So the web variant dispatched an
|
|
74
|
+
`appstudio:widget-toast` CustomEvent that nothing listened for, and native fell
|
|
75
|
+
through to `console.log`. Every write confirmation an app raised was invisible:
|
|
76
|
+
a user tapped Save and got nothing back. The web Player and the exported Expo
|
|
77
|
+
app now both paint a workspace-themed stack, so a confirmation you raise is a
|
|
78
|
+
confirmation the user sees.
|
|
79
|
+
- **New host exports (`@colixsystems/widget-sdk/host`)** — `createToastController()`
|
|
80
|
+
(the queue, the auto-dismiss timing, newest-first stacking, injectable timers)
|
|
81
|
+
and `resolveToastTokens(theme, kind)` (the themed values a toast is painted
|
|
82
|
+
with, `error` mapping to the theme's `danger` role), plus `normalizeToastKind`
|
|
83
|
+
and `TOAST_DEFAULTS`. Both hosts drive these, so only the JSX differs and the
|
|
84
|
+
two cannot drift. This entry point is host integration, not the author API —
|
|
85
|
+
a widget author still just calls `useToast()`.
|
|
86
|
+
- **No author-facing change.** No export, signature, hook or manifest field
|
|
87
|
+
moved; a widget already calling `showToast` is unchanged and simply becomes
|
|
88
|
+
visible. `CONTRACT.version` → `1.61.1` for the corrected `useToast` /
|
|
89
|
+
`widgetContextShape.toast` descriptions, which used to imply a host might not
|
|
90
|
+
render the toast at all.
|
|
91
|
+
- **An authoring preview still wires nothing.** The Studio canvas leaves the slot
|
|
92
|
+
unset on purpose — a confirmation belongs to the running app, not to
|
|
93
|
+
design-time — so `showToast` is a no-op there, as `navigation` and `events`
|
|
94
|
+
already are.
|
|
95
|
+
|
|
96
|
+
### What's new in 0.86.0 (contract unchanged)
|
|
97
|
+
|
|
98
|
+
**New linter rule `measured-width-ignores-padding` — a measured width includes the measuring element's own padding (sc-4913).**
|
|
99
|
+
|
|
100
|
+
- **`measured-width-ignores-padding` (severity `warning`, non-blocking).** A widget that puts `onLayout` on an element which sets its own `padding` (or `paddingHorizontal`/`Left`/`Right`), and then sizes grid cells from the measured number, is flagged. `onLayout` reports the element's **frame** width and padding sits inside that frame, so the space the children really get is `width - paddingLeft - paddingRight`. Cells sized to fill the raw measurement overflow the content box, the last one wraps, and the widget ships a whole empty column of whitespace beside its cards — on both the web Player and the native Expo export, with nothing in the console. Author fix: spread `onLayout` on an **unpadded** element (keep the padding on a parent, or measure an inner `<View>` inside the padded root) so the number you hold is the usable width. Better still for content-sized cells: skip the measurement entirely and wrap with flex — a `{ flexDirection: "row", flexWrap: "wrap", gap }` row whose cards take `{ flexGrow: 1, flexBasis: CARD_MIN }` splits the row's real content width itself and can never leave a leftover band.
|
|
101
|
+
- **Why a warning.** The rule fires only when the measured value feeds sizing arithmetic — a padded box measured just to pick a wide/narrow form (`isNarrowWidth(width)`) is off by one padding pair and stays silent. A widget that already subtracts its own padding by hand matches too, because no text scan can verify the subtraction; that is deliberate — the remediation is correct for it as well and retires the arithmetic. Comments are not scanned, so documenting the anti-pattern is safe.
|
|
102
|
+
|
|
103
|
+
`CONTRACT` is unchanged (no new field), and no export changed signature.
|
|
104
|
+
|
|
105
|
+
### What's new in 0.86.0 (contract 1.61.0)
|
|
106
|
+
|
|
107
|
+
- **The workspace theme now reaches the elements an app is built from.** Three things that used to be unreachable are now themeable: an element inside YOUR widget, the structural card container a page is made of, and any style field whose name the platform does not know. For a widget author the practical change is that **your `styleSchema` is the contract**: every field you declare becomes a knob the workspace owner can set once for the whole app, so declare the fields that describe your widget's appearance and give them clear `label`s and `ui.group`s — those labels are what the owner reads.
|
|
108
|
+
- **A field name you invented is as reachable as a canonical one.** A theme may carry values keyed by your widget's manifest id and then by your own field names, so `panelFill` is adjustable app-wide exactly like `cardBackground`. Separately, the unambiguous card names (`cardBackground`, `cardBorderColor`, `cardRadius`, `cardPadding`, `cardGradient`) bind by NAME to any widget that declares them, so naming a genuine card surface canonically opts it into the workspace's Cards controls for free.
|
|
109
|
+
- **`useTheme().colors` describes the surface your widget SITS ON, not the page.** A layout container that paints its own background re-derives the surface roles for everything inside it, so reading `colors.onSurface` for your text is readable whether your widget lands on the page, in a dark hero, or in a light card nested inside that hero. Nothing to opt into.
|
|
110
|
+
- **Precedence, unchanged in spirit.** Contract default → workspace palette → component scope → per-widget-type value → the app author's per-instance `props.style`. Most specific wins, and the Properties Panel is still the final word. Your widget reads `props.style` exactly as before and never learns which layer supplied a value.
|
|
111
|
+
- **A colour may carry OPACITY.** `isHexColor` accepts the 8-digit `#RRGGBBAA` form alongside 3 and 6 digits, so a theme colour with an alpha reaches `useTheme()` with its transparency intact. It used to be rejected and the host dropped the key outright, which is why a translucent page background never reached the dark-surface derivation and every panel fell back to white.
|
|
112
|
+
- **The colour maths ignores alpha, on purpose.** `hexChannels` reads the R/G/B pair and skips any alpha, so contrast, readable text and the derived accent tints reason about the opaque colour. None of them can composite without knowing the backdrop, which a token table does not have — so transparency lives in the VALUE your widget renders, not in the decision about whether that colour reads as light or dark.
|
|
113
|
+
- **`CONTRACT.version` → `1.61.0`** (additive: `themeTokens.spacingScale` + `widgetStyles` and their bounds, `themeComponents.card.universalFields`, and the `normaliseWidgetStyles` / `deriveSurfaceTokens` host exports). No author-facing export changed signature, and a theme that sets none of it resolves exactly as before.
|
|
65
114
|
|
|
66
115
|
### What's new in 0.85.1 (contract 1.60.1)
|
|
67
116
|
|
|
@@ -195,6 +244,7 @@ useEffect(() => {
|
|
|
195
244
|
|
|
196
245
|
**The theme can restyle ONE component type — buttons, cards or text — without moving the global palette (sc-1497).** A workspace theme may now carry `themeConfig.components` (`{ button, card, text }`), and the host resolves each scope onto the `styleSchema` fields the target widgets already read. **Nothing changes for a widget author:** you keep reading `props.style` / `useWidgetStyle()`, and an author's per-instance value still wins over a theme token — the theme is the app-wide default underneath it.
|
|
197
246
|
|
|
247
|
+
- **`useTheme()` gains `spacingScale`.** The app-wide spacing multiplier (default `1`) the workspace sets from Theme Settings. HOST-OWNED for layout: the host already scales every container's `padding` / `gap` / `margin` by it, so do not re-apply it to anything the host laid out. Read it only when your widget draws spacing of its own and you want that to breathe with the rest of the app — multiply your own paddings by it and leave radii and font sizes alone.
|
|
198
248
|
- **`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.
|
|
199
249
|
- **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.
|
|
200
250
|
- **`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 `{}`.
|
|
@@ -574,7 +624,8 @@ The "split-implementation + vetted package list" pivot.
|
|
|
574
624
|
|
|
575
625
|
### What's new in 0.11.0
|
|
576
626
|
|
|
577
|
-
- **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
|
|
627
|
+
- **`useNavigation()` is wired.** Returns the host-provided navigation surface `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page-to-page navigation. Missing methods degrade to no-ops on the Studio canvas preview. Additive.
|
|
628
|
+
- **`openLink(link)` follows a link whose shape you do NOT control** — a value off a datastore row, a notification's `link`, anything author- or user-supplied. It resolves the string through the host's shared resolver and then acts: an in-app page routes internally, an off-app `http(s)` URL opens outside, and anything unsafe (`javascript:`, `data:`, scheme-relative `//host`, a scheme split by a control character) is refused. Returns `true` when it acted. **Prefer it over `Linking.openURL` for untrusted values** — `Linking.openURL` performs whatever it is handed, so passing it a stored string is how a `javascript:` URL reaches the browser. Reach for `goTo(pageId)` when you already know the page, and `Linking.openURL` only for a URL your own code constructed.
|
|
578
629
|
- **`usePageContext()` reads the page's DECLARED parameters.** When a page declares parameters (Page Settings → Parameters), the host resolves them ONCE before any widget renders and fetches each `record` parameter's row for the whole page. `params` holds the values coerced to their declared types (a `number` parameter is a number, not the string `useRouteParams()` returns); `records` maps each `record` parameter to its already-loaded row — read it rather than issuing the same request from every widget. A required parameter that is absent, malformed, or whose record does not resolve never reaches the widget: the host renders one page-level state instead. Both bags are empty on a page that declares nothing, so fall back to `useRouteParams()` for a page you do not control. Additive (v0.77.0).
|
|
579
630
|
- **`useRouteParams()` reads the nav params.** Returns `currentRoute.params` — the bag a `goTo(pageId, params)` carried to this page. The flat accessor for master→detail: navigate with `goTo(detailPageId, { recordId: row.id })`, then read `const { recordId } = useRouteParams()`. It is an OBJECT — read a param off it, never call it. Empty object when the page was opened without params. Additive (v0.61.0).
|
|
580
631
|
- **`Linking` primitive re-exported.** `Linking.openURL(url)` opens an external URL with the OS handler — web (`react-native-web`) maps to `window.open` / `location.href`; native hands off to the system. Use this for external URLs; use `useNavigation().goTo(pageId)` for internal pages.
|
|
@@ -649,7 +700,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
649
700
|
|
|
650
701
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
651
702
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
652
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute }` for internal page navigation
|
|
703
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
|
|
653
704
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
654
705
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
655
706
|
- `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
|
|
@@ -671,7 +722,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
671
722
|
- **Respond to touch.** Give every `Pressable` a pressed state via the function-style `style={({ pressed }) => [base, pressed && { opacity: 0.7 }]}`.
|
|
672
723
|
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`.
|
|
673
724
|
- **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).
|
|
674
|
-
- **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.
|
|
725
|
+
- **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. The frame is your decision, never the picture's: size it for the role (a 40–56 square avatar, a 72–96 square row thumbnail, a `16 / 9` card cover, a 160–240 tall band) and let `resizeMode="cover"` crop the photo into it — photos arrive at every size and ratio, so one left to its own proportions breaks the layout. Keep `contain` for art whose whole subject must stay visible (a logo, a diagram), inside a fixed frame.
|
|
675
726
|
- **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.
|
|
676
727
|
|
|
677
728
|
**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.
|
package/dist/contract.cjs
CHANGED
|
@@ -72,6 +72,9 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
72
72
|
}),
|
|
73
73
|
elevation: ELEVATION,
|
|
74
74
|
spacing: Object.freeze({ xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }),
|
|
75
|
+
// REQ-THEME-LOOK: multiplies every layout spacing value at render. 1 is
|
|
76
|
+
// unchanged, so a theme that never sets it renders exactly as before.
|
|
77
|
+
spacingScale: 1,
|
|
75
78
|
radii: Object.freeze({ sm: 4, md: 8, lg: 16, pill: 9999 }),
|
|
76
79
|
typography: Object.freeze({
|
|
77
80
|
fontFamily:
|
|
@@ -86,6 +89,12 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
86
89
|
// host so ONE channel carries them to the Player and the export. Empty by
|
|
87
90
|
// default — an unconfigured theme resolves to no component overrides.
|
|
88
91
|
components: Object.freeze({}),
|
|
92
|
+
// REQ-THEME-ELEMENT: the tenant's per-WIDGET-TYPE style values, keyed by
|
|
93
|
+
// manifest id. `components` above restyles a whole scope through a shared
|
|
94
|
+
// vocabulary, which only reaches a widget the vocabulary knows about; this
|
|
95
|
+
// reaches ANY widget by naming it, using that widget's OWN styleSchema field
|
|
96
|
+
// names. Empty by default.
|
|
97
|
+
widgetStyles: Object.freeze({}),
|
|
89
98
|
});
|
|
90
99
|
|
|
91
100
|
// REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
|
|
@@ -130,6 +139,32 @@ const THEME_COMPONENT_GRADIENT = Object.freeze({
|
|
|
130
139
|
defaultAngle: 180,
|
|
131
140
|
});
|
|
132
141
|
|
|
142
|
+
// REQ-THEME-LOOK: the app-wide SPACING MULTIPLIER. Layout spacing lives on the
|
|
143
|
+
// NODES -- a container states its own `padding` / `gap` / `margin` -- so a plain
|
|
144
|
+
// theme key could never make an existing app breathe: every node already
|
|
145
|
+
// carried a value and there was no default left to change. This scales those
|
|
146
|
+
// node values at RENDER time instead, on both hosts, which is why it moves a
|
|
147
|
+
// page that was authored long ago. SPACING only -- never radii, font sizes or
|
|
148
|
+
// minHeight -- so a look keeps its shape while its air moves, and the author’s
|
|
149
|
+
// relative proportions survive: a tight table stays tighter than the card
|
|
150
|
+
// beside it.
|
|
151
|
+
// REQ-THEME-ELEMENT: bounds on the per-widget-type style map. `theme_config` is
|
|
152
|
+
// an unbounded JSON bag that an UNAUTHENTICATED GET /tenant/config returns on
|
|
153
|
+
// every cold Player start and that the compiler bakes verbatim into the native
|
|
154
|
+
// export -- so a map that grows with the widget catalog needs a stated ceiling.
|
|
155
|
+
// Declared here so the coercer, the Studio control and the planner prompt agree.
|
|
156
|
+
const THEME_WIDGET_STYLES = Object.freeze({
|
|
157
|
+
// One entry per widget TYPE, not per instance, so this is generous.
|
|
158
|
+
maxWidgets: 200,
|
|
159
|
+
// Matches the styleSchema field cap the widget agent is held to.
|
|
160
|
+
maxFieldsPerWidget: 12,
|
|
161
|
+
});
|
|
162
|
+
const THEME_SPACING_SCALE = Object.freeze({
|
|
163
|
+
min: 0.5,
|
|
164
|
+
max: 2,
|
|
165
|
+
default: 1,
|
|
166
|
+
});
|
|
167
|
+
|
|
133
168
|
// The card-surface field names shared by every widget that paints its own card
|
|
134
169
|
// (frontend/src/components/widgets/_shared/cardStyle.js CARD_STYLE_SCHEMA).
|
|
135
170
|
const CARD_SURFACE_FIELDS = Object.freeze({
|
|
@@ -159,6 +194,23 @@ const FORM_SUBMIT_FIELDS = Object.freeze({
|
|
|
159
194
|
gradient: "submitGradient",
|
|
160
195
|
});
|
|
161
196
|
|
|
197
|
+
// REQ-THEME-WIDGET: the card fields whose NAMES are unambiguous, so they bind to
|
|
198
|
+
// ANY widget that reads them -- including a Mason-generated one, whose id can
|
|
199
|
+
// never appear in a hand-maintained allowlist. That allowlist is why "make the
|
|
200
|
+
// cards darker" reached the nine built-ins and nothing else.
|
|
201
|
+
//
|
|
202
|
+
// `shadow` is deliberately ABSENT: its name is bare and shared with the button
|
|
203
|
+
// scope, so binding it by name would cross the scopes. The bare names stay on
|
|
204
|
+
// the allowlist for exactly that reason -- `appstudio.image` also reads a
|
|
205
|
+
// `background` field, and the button scope must not leak into it.
|
|
206
|
+
const CARD_UNIVERSAL_FIELDS = Object.freeze({
|
|
207
|
+
background: "cardBackground",
|
|
208
|
+
borderColor: "cardBorderColor",
|
|
209
|
+
radius: "cardRadius",
|
|
210
|
+
padding: "cardPadding",
|
|
211
|
+
gradient: "cardGradient",
|
|
212
|
+
});
|
|
213
|
+
|
|
162
214
|
const THEME_COMPONENTS = Object.freeze({
|
|
163
215
|
button: Object.freeze({
|
|
164
216
|
label: "Buttons",
|
|
@@ -187,6 +239,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
187
239
|
}),
|
|
188
240
|
card: Object.freeze({
|
|
189
241
|
label: "Cards",
|
|
242
|
+
universalFields: CARD_UNIVERSAL_FIELDS,
|
|
190
243
|
tokens: Object.freeze({
|
|
191
244
|
background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
|
|
192
245
|
borderColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
|
|
@@ -243,8 +296,18 @@ const HOOKS = [
|
|
|
243
296
|
signature: "useTheme()",
|
|
244
297
|
returnShape: {
|
|
245
298
|
colors:
|
|
246
|
-
"{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info }"
|
|
299
|
+
"{ primary, onPrimary, secondary, onSecondary, surface, onSurface, surfaceMuted, onSurfaceMuted, border, danger, success, warning, info } — " +
|
|
300
|
+
"REQ-THEME-SURFACE: the surface group (surface / surfaceMuted / onSurface / " +
|
|
301
|
+
"onSurfaceMuted / border) describes the surface your widget SITS ON, not the " +
|
|
302
|
+
"page: a container painting its own background re-derives them for its " +
|
|
303
|
+
"subtree. Read them and your text is readable wherever the widget lands; " +
|
|
304
|
+
"there is nothing to opt into.",
|
|
247
305
|
spacing: "{ xs, sm, md, lg, xl }",
|
|
306
|
+
spacingScale:
|
|
307
|
+
"number — the app-wide spacing multiplier (REQ-THEME-LOOK, default 1). " +
|
|
308
|
+
"HOST-OWNED: the host already scales layout spacing by it. Read it only " +
|
|
309
|
+
"if your widget draws its own internal spacing and wants to breathe with " +
|
|
310
|
+
"the rest of the app.",
|
|
248
311
|
radii: "{ sm, md, lg, pill }",
|
|
249
312
|
typography: "{ fontFamily, sizes: { xs, sm, md, lg, xl, xxl } }",
|
|
250
313
|
components:
|
|
@@ -436,6 +499,7 @@ const HOOKS = [
|
|
|
436
499
|
signature: "useNavigation()",
|
|
437
500
|
returnShape: {
|
|
438
501
|
goTo: "(pageId: string, params?: object) => void",
|
|
502
|
+
openLink: "(link: string) => boolean",
|
|
439
503
|
goBack: "() => void",
|
|
440
504
|
push: "(pageId: string, params?: object) => void",
|
|
441
505
|
replace: "(pageId: string, params?: object) => void",
|
|
@@ -1091,10 +1155,12 @@ const HOOKS = [
|
|
|
1091
1155
|
description:
|
|
1092
1156
|
"Surfaces a short auto-dismissing notification. Returns { showToast }. " +
|
|
1093
1157
|
"showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
|
|
1094
|
-
"
|
|
1095
|
-
"
|
|
1096
|
-
"
|
|
1097
|
-
"
|
|
1158
|
+
"renders a workspace-themed toast. Both shipping hosts paint it — the " +
|
|
1159
|
+
"web Player and the exported Expo app — so a confirmation you raise IS " +
|
|
1160
|
+
"seen by the user; use it to confirm every write. The widget never owns " +
|
|
1161
|
+
"the toast UI, so never build your own banner or call Alert.alert for a " +
|
|
1162
|
+
"routine save. An authoring preview (the Studio canvas) wires no " +
|
|
1163
|
+
"renderer, and there the call is simply a no-op.",
|
|
1098
1164
|
returnShape: {
|
|
1099
1165
|
showToast: "({ kind, message }) => void",
|
|
1100
1166
|
},
|
|
@@ -1658,9 +1724,10 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1658
1724
|
description:
|
|
1659
1725
|
"Optional host toast slot. { showToast({ kind, message }): void }. " +
|
|
1660
1726
|
"The host populates this to render workspace-themed notifications " +
|
|
1661
|
-
"from any widget that calls useToast().
|
|
1662
|
-
"
|
|
1663
|
-
"and
|
|
1727
|
+
"from any widget that calls useToast(). Both rendering hosts wire it " +
|
|
1728
|
+
"(the web Player and the compiler's native WidgetHost); authoring " +
|
|
1729
|
+
"surfaces omit it, and the SDK then falls back to dispatching an " +
|
|
1730
|
+
"'appstudio:widget-toast' CustomEvent on web and console.log on native.",
|
|
1664
1731
|
required: false,
|
|
1665
1732
|
fields: { showToast: "function" },
|
|
1666
1733
|
},
|
|
@@ -2635,6 +2702,28 @@ const CONTRACT = deepFreeze({
|
|
|
2635
2702
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2636
2703
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2637
2704
|
// the Developer guide and the agent prompt reading one source.
|
|
2705
|
+
// 1.52.0: additive (REQ-THEME-LOOK) -- `themeTokens.spacingScale` (default 1)
|
|
2706
|
+
// and `themeSpacingScale`, its clamp bounds. A spacing control in the
|
|
2707
|
+
// Studio could reach nothing that mattered, because layout spacing is
|
|
2708
|
+
// authored ONTO each node (`padding` / `gap` / `margin`) rather than
|
|
2709
|
+
// inherited from the theme -- so no theme key could make a built app
|
|
2710
|
+
// breathe. Both hosts now multiply a node’s resolved spacing by this scale
|
|
2711
|
+
// at render. SPACING only: radii, font sizes and minHeight are untouched,
|
|
2712
|
+
// so a look keeps its shape and the author’s proportions hold. Additive:
|
|
2713
|
+
// no export changed signature and a theme without the key resolves to 1,
|
|
2714
|
+
// rendering identically to before.
|
|
2715
|
+
// 1.53.0: fix (REQ-THEME-17) -- `isHexColor` accepts the 8-digit
|
|
2716
|
+
// `#RRGGBBAA` form. The Studio has put an opacity row on every colour
|
|
2717
|
+
// field since sc-4158, so a brand colour or a page background routinely
|
|
2718
|
+
// arrives with alpha -- and this predicate rejecting it made
|
|
2719
|
+
// `themeConfigToTokens` DROP the key, so a translucent dark background
|
|
2720
|
+
// stopped reaching deriveSurfaceTokens and every panel fell back to white.
|
|
2721
|
+
// A second, alpha-aware predicate already existed beside it for gradients,
|
|
2722
|
+
// which is why the asymmetry kept being rediscovered surface by surface;
|
|
2723
|
+
// there is now one. `hexChannels` ignores the alpha pair, so contrast and
|
|
2724
|
+
// the derived tints still reason about the opaque colour while the value
|
|
2725
|
+
// keeps its transparency. Widening only: every input accepted before is
|
|
2726
|
+
// accepted now, and unchanged.
|
|
2638
2727
|
// 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
|
|
2639
2728
|
// `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
|
|
2640
2729
|
// themselves out at a fixed size — UserManagement's rows alone carried
|
|
@@ -2666,7 +2755,36 @@ const CONTRACT = deepFreeze({
|
|
|
2666
2755
|
// Developer guide have always said, so a widget written against the
|
|
2667
2756
|
// declared shape destructured a function and threw on first interaction.
|
|
2668
2757
|
// Declared as a bare callable now; no runtime behaviour changed.
|
|
2669
|
-
|
|
2758
|
+
// 1.61.0: additive (REQ-THEME-SURFACE / -CARD / -WIDGET / -ELEMENT) -- the
|
|
2759
|
+
// theme reaches the elements an app is built from. Four things it could
|
|
2760
|
+
// not touch before, and one mechanism each:
|
|
2761
|
+
// * `deriveSurfaceTokens` (host export) -- the surface/text/border set is
|
|
2762
|
+
// derived per PAINTED SURFACE, not once per page, so an UNSET text
|
|
2763
|
+
// colour is readable inside a coloured container. It was a hand-mirrored
|
|
2764
|
+
// copy per host; now one symmetric implementation (a light fill resolves
|
|
2765
|
+
// to the light tokens instead of returning null).
|
|
2766
|
+
// * `themeComponents.card.universalFields` -- the card scope's unambiguous
|
|
2767
|
+
// field NAMES bind to any widget that declares them, so a widget whose id
|
|
2768
|
+
// can never appear in a hand-maintained allowlist still follows "Cards".
|
|
2769
|
+
// The bare names (`background`, `textColor`, `color`, `fontSize`,
|
|
2770
|
+
// `shadow`) keep the allowlist: they mean different things per scope.
|
|
2771
|
+
// `applyThemeComponentStyle` takes an optional 4th `styleSchema` so the
|
|
2772
|
+
// name-bound fields land only on a widget that reads them.
|
|
2773
|
+
// * `themeTokens.widgetStyles` + `themeWidgetStyles` bounds +
|
|
2774
|
+
// `normaliseWidgetStyles` (host export) -- app-wide values keyed by
|
|
2775
|
+
// widget MANIFEST ID and then by that widget's OWN styleSchema field
|
|
2776
|
+
// names, so `panelFill` is as reachable as `cardBackground`: no shared
|
|
2777
|
+
// vocabulary, no allowlist, no naming requirement. Validated
|
|
2778
|
+
// structurally, because the key space is a workspace's widget catalog
|
|
2779
|
+
// rather than this contract, and bounded because `theme_config` is
|
|
2780
|
+
// unbounded, read unauthenticated on every cold Player start, and baked
|
|
2781
|
+
// verbatim into the native export.
|
|
2782
|
+
// Precedence: contract default -> palette -> `components.<scope>` ->
|
|
2783
|
+
// `widgetStyles[manifestId]` -> the author's per-instance `props.style`.
|
|
2784
|
+
// Naming one widget is strictly more specific than restyling a scope, and
|
|
2785
|
+
// the Properties Panel stays the final word. Additive throughout: a theme
|
|
2786
|
+
// that sets none of it resolves exactly as before.
|
|
2787
|
+
version: "1.61.1",
|
|
2670
2788
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2671
2789
|
hooks: HOOKS,
|
|
2672
2790
|
primitives: PRIMITIVES,
|
|
@@ -2681,6 +2799,8 @@ const CONTRACT = deepFreeze({
|
|
|
2681
2799
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
2682
2800
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
|
2683
2801
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
2802
|
+
themeSpacingScale: THEME_SPACING_SCALE,
|
|
2803
|
+
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
2684
2804
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
2685
2805
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
2686
2806
|
bannedApis: BANNED_APIS,
|
|
@@ -2720,12 +2840,31 @@ function requiredContextKeys() {
|
|
|
2720
2840
|
// one source removes that drift (CLAUDE.md §3).
|
|
2721
2841
|
// ---------------------------------------------------------------------------
|
|
2722
2842
|
|
|
2723
|
-
|
|
2843
|
+
// REQ-THEME-17 / sc-4158 — 3, 6 or 8 digits. The 8-digit form carries alpha,
|
|
2844
|
+
// and this predicate accepting it is load-bearing: the Studio puts an opacity
|
|
2845
|
+
// row on every colour field, so `primaryColor` and `backgroundColor` routinely
|
|
2846
|
+
// arrive as `#RRGGBBAA`. While this rejected them, `themeConfigToTokens` DROPPED
|
|
2847
|
+
// the key outright — a translucent page background stopped reaching
|
|
2848
|
+
// deriveSurfaceTokens and every panel in the app fell back to white.
|
|
2849
|
+
//
|
|
2850
|
+
// There used to be a second, alpha-aware predicate beside this one for
|
|
2851
|
+
// gradients, which is how the asymmetry kept being rediscovered: each new
|
|
2852
|
+
// alpha-carrying surface met the strict one first. There is now only this.
|
|
2853
|
+
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
2724
2854
|
|
|
2725
2855
|
function isHexColor(value) {
|
|
2726
2856
|
return typeof value === "string" && HEX_RE.test(value);
|
|
2727
2857
|
}
|
|
2728
2858
|
|
|
2859
|
+
/**
|
|
2860
|
+
* The R/G/B bytes, with any alpha pair IGNORED.
|
|
2861
|
+
*
|
|
2862
|
+
* Deliberate: everything downstream of this is luminance maths — contrast,
|
|
2863
|
+
* readable text, derived tints — and none of it can composite without knowing
|
|
2864
|
+
* the backdrop, which a token table does not have. Alpha survives in the VALUE
|
|
2865
|
+
* (the host hands `#RRGGBBAA` straight to the renderer); it just takes no part
|
|
2866
|
+
* in deciding whether a colour reads as light or dark.
|
|
2867
|
+
*/
|
|
2729
2868
|
function hexChannels(hex) {
|
|
2730
2869
|
let h = hex.slice(1);
|
|
2731
2870
|
if (h.length === 3) {
|
|
@@ -2810,6 +2949,54 @@ function deriveAccentTints(primary, surface, onSurface) {
|
|
|
2810
2949
|
};
|
|
2811
2950
|
}
|
|
2812
2951
|
|
|
2952
|
+
// REQ-THEME-SURFACE: how far a derived surface travels toward white, and the
|
|
2953
|
+
// light-on-dark text pair a dark surface carries.
|
|
2954
|
+
const SURFACE_LIFT = Object.freeze({ surface: 0.1, surfaceMuted: 0.05, border: 0.18 });
|
|
2955
|
+
const DARK_SURFACE_TEXT = Object.freeze({
|
|
2956
|
+
onSurface: "#f8fafc",
|
|
2957
|
+
onSurfaceMuted: "#cbd5e1",
|
|
2958
|
+
});
|
|
2959
|
+
|
|
2960
|
+
/**
|
|
2961
|
+
* REQ-THEME-DARK: the coherent surface / text / border set one background wants.
|
|
2962
|
+
* A dark background lifts its panels toward white and goes light-on-dark; a
|
|
2963
|
+
* light one resolves to the contract's own light tokens.
|
|
2964
|
+
*
|
|
2965
|
+
* REQ-THEME-SURFACE: applied to the PAGE background and — because every surface
|
|
2966
|
+
* a page is built from may paint its own fill — to a CONTAINER background too.
|
|
2967
|
+
* That is what makes an UNSET text colour readable inside a dark card on a light
|
|
2968
|
+
* page, which is in turn what lets a built app follow its theme instead of
|
|
2969
|
+
* carrying a baked-in hex for every heading.
|
|
2970
|
+
*
|
|
2971
|
+
* Returns all five keys together, never a subset: layering a dark card's text
|
|
2972
|
+
* colour over a light ancestor's surface is exactly how light-on-light happens.
|
|
2973
|
+
* `null` only when the colour is unusable, letting the caller keep what it had.
|
|
2974
|
+
*/
|
|
2975
|
+
function deriveSurfaceTokens(backgroundColor) {
|
|
2976
|
+
if (!isHexColor(backgroundColor)) return null;
|
|
2977
|
+
// "Dark" = the background wants light text (same luminance test as the
|
|
2978
|
+
// on-color contrast picker).
|
|
2979
|
+
const wantsLightText =
|
|
2980
|
+
readableTextColor(backgroundColor, "__dark__", "__light__") === "__light__";
|
|
2981
|
+
if (!wantsLightText) {
|
|
2982
|
+
const base = DEFAULT_THEME_TOKENS.colors;
|
|
2983
|
+
return {
|
|
2984
|
+
surface: base.surface,
|
|
2985
|
+
surfaceMuted: base.surfaceMuted,
|
|
2986
|
+
onSurface: base.onSurface,
|
|
2987
|
+
onSurfaceMuted: base.onSurfaceMuted,
|
|
2988
|
+
border: base.border,
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
return {
|
|
2992
|
+
surface: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surface),
|
|
2993
|
+
surfaceMuted: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surfaceMuted),
|
|
2994
|
+
onSurface: DARK_SURFACE_TEXT.onSurface,
|
|
2995
|
+
onSurfaceMuted: DARK_SURFACE_TEXT.onSurfaceMuted,
|
|
2996
|
+
border: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.border),
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
|
|
2813
3000
|
/**
|
|
2814
3001
|
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
2815
3002
|
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
@@ -2828,9 +3015,10 @@ function gradientAngleToVector(angle) {
|
|
|
2828
3015
|
};
|
|
2829
3016
|
}
|
|
2830
3017
|
|
|
2831
|
-
//
|
|
2832
|
-
//
|
|
2833
|
-
|
|
3018
|
+
// Gradients used to need their own alpha-aware predicate because HEX_RE was not.
|
|
3019
|
+
// HEX_RE is now, so this is the same check by another name — kept only as a
|
|
3020
|
+
// local alias so the call sites below read as before.
|
|
3021
|
+
const GRADIENT_HEX_RE = HEX_RE;
|
|
2834
3022
|
|
|
2835
3023
|
/**
|
|
2836
3024
|
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
@@ -2855,8 +3043,33 @@ function normaliseComponentGradient(raw) {
|
|
|
2855
3043
|
return { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
2856
3044
|
}
|
|
2857
3045
|
|
|
3046
|
+
// REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
|
|
3047
|
+
// because BOTH hosts multiply node spacing by it and a disagreement here would
|
|
3048
|
+
// re-space an exported page against the Player. Anything absent, non-finite or
|
|
3049
|
+
// out of band resolves to 1 (unchanged) rather than throwing -- `theme_config`
|
|
3050
|
+
// is a JSON bag a workspace admin can PUT verbatim.
|
|
3051
|
+
function clampSpacingScale(value) {
|
|
3052
|
+
// Absent means UNSET, not "as tight as possible" — Number(null) and Number("")
|
|
3053
|
+
// are both 0, which would otherwise clamp an untouched theme to the minimum.
|
|
3054
|
+
if (value === null || value === undefined || value === "") {
|
|
3055
|
+
return THEME_SPACING_SCALE.default;
|
|
3056
|
+
}
|
|
3057
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
3058
|
+
if (!Number.isFinite(n)) return THEME_SPACING_SCALE.default;
|
|
3059
|
+
return Math.min(Math.max(n, THEME_SPACING_SCALE.min), THEME_SPACING_SCALE.max);
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// REQ-THEME-LOOK: apply that scale to ONE spacing value. Rounded to whole
|
|
3063
|
+
// pixels so a scaled gap stays on the same pixel grid as an unscaled one.
|
|
3064
|
+
function scaleSpacing(value, scale) {
|
|
3065
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
3066
|
+
return Math.round(value * clampSpacingScale(scale));
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2858
3069
|
module.exports = {
|
|
2859
3070
|
CONTRACT,
|
|
3071
|
+
clampSpacingScale,
|
|
3072
|
+
scaleSpacing,
|
|
2860
3073
|
isHookAllowed,
|
|
2861
3074
|
requiredContextKeys,
|
|
2862
3075
|
isHexColor,
|
|
@@ -2864,6 +3077,7 @@ module.exports = {
|
|
|
2864
3077
|
contrastRatio,
|
|
2865
3078
|
readableTextColor,
|
|
2866
3079
|
deriveAccentTints,
|
|
3080
|
+
deriveSurfaceTokens,
|
|
2867
3081
|
gradientAngleToVector,
|
|
2868
3082
|
normaliseComponentGradient,
|
|
2869
3083
|
formatMoneyIn,
|