@colixsystems/widget-sdk 0.86.0 → 0.88.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 +83 -7
- package/dist/contract.cjs +238 -21
- package/dist/contract.js +238 -21
- package/dist/hooks.js +177 -73
- package/dist/host.d.ts +90 -1
- package/dist/host.js +19 -0
- package/dist/index.d.ts +27 -2
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +32 -0
- package/dist/linter.js +32 -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/dist/contract.js
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",
|
|
@@ -650,7 +714,7 @@ const HOOKS = [
|
|
|
650
714
|
signedAt: "string | null",
|
|
651
715
|
verdict: "{ valid, checks, content_status, ... } | null",
|
|
652
716
|
loading: "boolean",
|
|
653
|
-
error: "PermissionError | null",
|
|
717
|
+
error: "PermissionError | null // { code, message, retryable }",
|
|
654
718
|
initiate: "() => Promise<{ signature_id, qr, auto_start_token, status }>",
|
|
655
719
|
refresh: "() => Promise<void>",
|
|
656
720
|
cancel: "() => Promise<void>",
|
|
@@ -760,9 +824,12 @@ const HOOKS = [
|
|
|
760
824
|
name: "useDatastoreMutation",
|
|
761
825
|
signature: "useDatastoreMutation(tableId)",
|
|
762
826
|
returnShape: {
|
|
763
|
-
create:
|
|
764
|
-
|
|
765
|
-
|
|
827
|
+
create:
|
|
828
|
+
"(record) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
829
|
+
update:
|
|
830
|
+
"(id, partial) => Promise<Record> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
831
|
+
delete:
|
|
832
|
+
"(id) => Promise<void> // rejects with DatastoreError { code, message, retryable } — render the message when retryable is false, never 'try again'",
|
|
766
833
|
},
|
|
767
834
|
requiredContextSlice: ["datastore.records"],
|
|
768
835
|
scopes: ["datastore.write:*"],
|
|
@@ -878,7 +945,7 @@ const HOOKS = [
|
|
|
878
945
|
returnShape: {
|
|
879
946
|
users: "Array<{ id, name, email?, role, is_active }> // snake_case rows; unwrapped from { data, meta }",
|
|
880
947
|
loading: "boolean",
|
|
881
|
-
error: "DirectoryError | null",
|
|
948
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
882
949
|
refetch: "() => Promise<void>",
|
|
883
950
|
invite:
|
|
884
951
|
"({ email, name, group_ids? }) => Promise<Invite> // rejects with DirectoryError",
|
|
@@ -907,7 +974,7 @@ const HOOKS = [
|
|
|
907
974
|
returnShape: {
|
|
908
975
|
groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
|
|
909
976
|
loading: "boolean",
|
|
910
|
-
error: "DirectoryError | null",
|
|
977
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
911
978
|
refetch: "() => Promise<void>",
|
|
912
979
|
create:
|
|
913
980
|
"({ name }) => Promise<Group> // rejects with DirectoryError",
|
|
@@ -946,7 +1013,7 @@ const HOOKS = [
|
|
|
946
1013
|
message: "string | null",
|
|
947
1014
|
loading: "boolean",
|
|
948
1015
|
statusLoading: "boolean",
|
|
949
|
-
error: "DirectoryError | null",
|
|
1016
|
+
error: "DirectoryError | null // { code, message, retryable }",
|
|
950
1017
|
startLink: "() => Promise<{ order_ref, qr, auto_start_token, status }>",
|
|
951
1018
|
refresh: "() => Promise<void>",
|
|
952
1019
|
cancel: "() => Promise<void>",
|
|
@@ -1028,7 +1095,7 @@ const HOOKS = [
|
|
|
1028
1095
|
permissions:
|
|
1029
1096
|
"Array<{ id, user_id, group_id, can_read, can_write, can_delete, can_grant }> // snake_case rows; unwrapped from { data, meta }",
|
|
1030
1097
|
loading: "boolean",
|
|
1031
|
-
error: "PermissionError | null",
|
|
1098
|
+
error: "PermissionError | null // { code, message, retryable }",
|
|
1032
1099
|
grant:
|
|
1033
1100
|
"({ user_id?, group_id?, can_read?, can_write?, can_delete?, can_grant? }) => Promise<RecordPermission> // rejects with PermissionError",
|
|
1034
1101
|
revoke:
|
|
@@ -1091,10 +1158,12 @@ const HOOKS = [
|
|
|
1091
1158
|
description:
|
|
1092
1159
|
"Surfaces a short auto-dismissing notification. Returns { showToast }. " +
|
|
1093
1160
|
"showToast({ kind: 'success' | 'error' | 'info' | 'warning', message }) " +
|
|
1094
|
-
"
|
|
1095
|
-
"
|
|
1096
|
-
"
|
|
1097
|
-
"
|
|
1161
|
+
"renders a workspace-themed toast. Both shipping hosts paint it — the " +
|
|
1162
|
+
"web Player and the exported Expo app — so a confirmation you raise IS " +
|
|
1163
|
+
"seen by the user; use it to confirm every write. The widget never owns " +
|
|
1164
|
+
"the toast UI, so never build your own banner or call Alert.alert for a " +
|
|
1165
|
+
"routine save. An authoring preview (the Studio canvas) wires no " +
|
|
1166
|
+
"renderer, and there the call is simply a no-op.",
|
|
1098
1167
|
returnShape: {
|
|
1099
1168
|
showToast: "({ kind, message }) => void",
|
|
1100
1169
|
},
|
|
@@ -1658,9 +1727,10 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1658
1727
|
description:
|
|
1659
1728
|
"Optional host toast slot. { showToast({ kind, message }): void }. " +
|
|
1660
1729
|
"The host populates this to render workspace-themed notifications " +
|
|
1661
|
-
"from any widget that calls useToast().
|
|
1662
|
-
"
|
|
1663
|
-
"and
|
|
1730
|
+
"from any widget that calls useToast(). Both rendering hosts wire it " +
|
|
1731
|
+
"(the web Player and the compiler's native WidgetHost); authoring " +
|
|
1732
|
+
"surfaces omit it, and the SDK then falls back to dispatching an " +
|
|
1733
|
+
"'appstudio:widget-toast' CustomEvent on web and console.log on native.",
|
|
1664
1734
|
required: false,
|
|
1665
1735
|
fields: { showToast: "function" },
|
|
1666
1736
|
},
|
|
@@ -2635,6 +2705,28 @@ const CONTRACT = deepFreeze({
|
|
|
2635
2705
|
// public endpoint instead, which skips the cache, the metering and the
|
|
2636
2706
|
// workspace's provider. Publishing the host list here keeps the linter,
|
|
2637
2707
|
// the Developer guide and the agent prompt reading one source.
|
|
2708
|
+
// 1.52.0: additive (REQ-THEME-LOOK) -- `themeTokens.spacingScale` (default 1)
|
|
2709
|
+
// and `themeSpacingScale`, its clamp bounds. A spacing control in the
|
|
2710
|
+
// Studio could reach nothing that mattered, because layout spacing is
|
|
2711
|
+
// authored ONTO each node (`padding` / `gap` / `margin`) rather than
|
|
2712
|
+
// inherited from the theme -- so no theme key could make a built app
|
|
2713
|
+
// breathe. Both hosts now multiply a node’s resolved spacing by this scale
|
|
2714
|
+
// at render. SPACING only: radii, font sizes and minHeight are untouched,
|
|
2715
|
+
// so a look keeps its shape and the author’s proportions hold. Additive:
|
|
2716
|
+
// no export changed signature and a theme without the key resolves to 1,
|
|
2717
|
+
// rendering identically to before.
|
|
2718
|
+
// 1.53.0: fix (REQ-THEME-17) -- `isHexColor` accepts the 8-digit
|
|
2719
|
+
// `#RRGGBBAA` form. The Studio has put an opacity row on every colour
|
|
2720
|
+
// field since sc-4158, so a brand colour or a page background routinely
|
|
2721
|
+
// arrives with alpha -- and this predicate rejecting it made
|
|
2722
|
+
// `themeConfigToTokens` DROP the key, so a translucent dark background
|
|
2723
|
+
// stopped reaching deriveSurfaceTokens and every panel fell back to white.
|
|
2724
|
+
// A second, alpha-aware predicate already existed beside it for gradients,
|
|
2725
|
+
// which is why the asymmetry kept being rediscovered surface by surface;
|
|
2726
|
+
// there is now one. `hexChannels` ignores the alpha pair, so contrast and
|
|
2727
|
+
// the derived tints still reason about the opaque colour while the value
|
|
2728
|
+
// keeps its transparency. Widening only: every input accepted before is
|
|
2729
|
+
// accepted now, and unchanged.
|
|
2638
2730
|
// 1.54.0: additive (sc-4399, epic 4395) — `useContainerWidth()` +
|
|
2639
2731
|
// `isNarrowWidth(width)` / `NARROW_WIDTH_PX`. Built-in widgets laid
|
|
2640
2732
|
// themselves out at a fixed size — UserManagement's rows alone carried
|
|
@@ -2666,7 +2758,36 @@ const CONTRACT = deepFreeze({
|
|
|
2666
2758
|
// Developer guide have always said, so a widget written against the
|
|
2667
2759
|
// declared shape destructured a function and threw on first interaction.
|
|
2668
2760
|
// Declared as a bare callable now; no runtime behaviour changed.
|
|
2669
|
-
|
|
2761
|
+
// 1.61.0: additive (REQ-THEME-SURFACE / -CARD / -WIDGET / -ELEMENT) -- the
|
|
2762
|
+
// theme reaches the elements an app is built from. Four things it could
|
|
2763
|
+
// not touch before, and one mechanism each:
|
|
2764
|
+
// * `deriveSurfaceTokens` (host export) -- the surface/text/border set is
|
|
2765
|
+
// derived per PAINTED SURFACE, not once per page, so an UNSET text
|
|
2766
|
+
// colour is readable inside a coloured container. It was a hand-mirrored
|
|
2767
|
+
// copy per host; now one symmetric implementation (a light fill resolves
|
|
2768
|
+
// to the light tokens instead of returning null).
|
|
2769
|
+
// * `themeComponents.card.universalFields` -- the card scope's unambiguous
|
|
2770
|
+
// field NAMES bind to any widget that declares them, so a widget whose id
|
|
2771
|
+
// can never appear in a hand-maintained allowlist still follows "Cards".
|
|
2772
|
+
// The bare names (`background`, `textColor`, `color`, `fontSize`,
|
|
2773
|
+
// `shadow`) keep the allowlist: they mean different things per scope.
|
|
2774
|
+
// `applyThemeComponentStyle` takes an optional 4th `styleSchema` so the
|
|
2775
|
+
// name-bound fields land only on a widget that reads them.
|
|
2776
|
+
// * `themeTokens.widgetStyles` + `themeWidgetStyles` bounds +
|
|
2777
|
+
// `normaliseWidgetStyles` (host export) -- app-wide values keyed by
|
|
2778
|
+
// widget MANIFEST ID and then by that widget's OWN styleSchema field
|
|
2779
|
+
// names, so `panelFill` is as reachable as `cardBackground`: no shared
|
|
2780
|
+
// vocabulary, no allowlist, no naming requirement. Validated
|
|
2781
|
+
// structurally, because the key space is a workspace's widget catalog
|
|
2782
|
+
// rather than this contract, and bounded because `theme_config` is
|
|
2783
|
+
// unbounded, read unauthenticated on every cold Player start, and baked
|
|
2784
|
+
// verbatim into the native export.
|
|
2785
|
+
// Precedence: contract default -> palette -> `components.<scope>` ->
|
|
2786
|
+
// `widgetStyles[manifestId]` -> the author's per-instance `props.style`.
|
|
2787
|
+
// Naming one widget is strictly more specific than restyling a scope, and
|
|
2788
|
+
// the Properties Panel stays the final word. Additive throughout: a theme
|
|
2789
|
+
// that sets none of it resolves exactly as before.
|
|
2790
|
+
version: "1.62.0",
|
|
2670
2791
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2671
2792
|
hooks: HOOKS,
|
|
2672
2793
|
primitives: PRIMITIVES,
|
|
@@ -2681,6 +2802,8 @@ const CONTRACT = deepFreeze({
|
|
|
2681
2802
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
2682
2803
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
|
2683
2804
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
2805
|
+
themeSpacingScale: THEME_SPACING_SCALE,
|
|
2806
|
+
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
2684
2807
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
2685
2808
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
|
2686
2809
|
bannedApis: BANNED_APIS,
|
|
@@ -2720,12 +2843,31 @@ function requiredContextKeys() {
|
|
|
2720
2843
|
// one source removes that drift (CLAUDE.md §3).
|
|
2721
2844
|
// ---------------------------------------------------------------------------
|
|
2722
2845
|
|
|
2723
|
-
|
|
2846
|
+
// REQ-THEME-17 / sc-4158 — 3, 6 or 8 digits. The 8-digit form carries alpha,
|
|
2847
|
+
// and this predicate accepting it is load-bearing: the Studio puts an opacity
|
|
2848
|
+
// row on every colour field, so `primaryColor` and `backgroundColor` routinely
|
|
2849
|
+
// arrive as `#RRGGBBAA`. While this rejected them, `themeConfigToTokens` DROPPED
|
|
2850
|
+
// the key outright — a translucent page background stopped reaching
|
|
2851
|
+
// deriveSurfaceTokens and every panel in the app fell back to white.
|
|
2852
|
+
//
|
|
2853
|
+
// There used to be a second, alpha-aware predicate beside this one for
|
|
2854
|
+
// gradients, which is how the asymmetry kept being rediscovered: each new
|
|
2855
|
+
// alpha-carrying surface met the strict one first. There is now only this.
|
|
2856
|
+
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
2724
2857
|
|
|
2725
2858
|
function isHexColor(value) {
|
|
2726
2859
|
return typeof value === "string" && HEX_RE.test(value);
|
|
2727
2860
|
}
|
|
2728
2861
|
|
|
2862
|
+
/**
|
|
2863
|
+
* The R/G/B bytes, with any alpha pair IGNORED.
|
|
2864
|
+
*
|
|
2865
|
+
* Deliberate: everything downstream of this is luminance maths — contrast,
|
|
2866
|
+
* readable text, derived tints — and none of it can composite without knowing
|
|
2867
|
+
* the backdrop, which a token table does not have. Alpha survives in the VALUE
|
|
2868
|
+
* (the host hands `#RRGGBBAA` straight to the renderer); it just takes no part
|
|
2869
|
+
* in deciding whether a colour reads as light or dark.
|
|
2870
|
+
*/
|
|
2729
2871
|
function hexChannels(hex) {
|
|
2730
2872
|
let h = hex.slice(1);
|
|
2731
2873
|
if (h.length === 3) {
|
|
@@ -2810,6 +2952,54 @@ function deriveAccentTints(primary, surface, onSurface) {
|
|
|
2810
2952
|
};
|
|
2811
2953
|
}
|
|
2812
2954
|
|
|
2955
|
+
// REQ-THEME-SURFACE: how far a derived surface travels toward white, and the
|
|
2956
|
+
// light-on-dark text pair a dark surface carries.
|
|
2957
|
+
const SURFACE_LIFT = Object.freeze({ surface: 0.1, surfaceMuted: 0.05, border: 0.18 });
|
|
2958
|
+
const DARK_SURFACE_TEXT = Object.freeze({
|
|
2959
|
+
onSurface: "#f8fafc",
|
|
2960
|
+
onSurfaceMuted: "#cbd5e1",
|
|
2961
|
+
});
|
|
2962
|
+
|
|
2963
|
+
/**
|
|
2964
|
+
* REQ-THEME-DARK: the coherent surface / text / border set one background wants.
|
|
2965
|
+
* A dark background lifts its panels toward white and goes light-on-dark; a
|
|
2966
|
+
* light one resolves to the contract's own light tokens.
|
|
2967
|
+
*
|
|
2968
|
+
* REQ-THEME-SURFACE: applied to the PAGE background and — because every surface
|
|
2969
|
+
* a page is built from may paint its own fill — to a CONTAINER background too.
|
|
2970
|
+
* That is what makes an UNSET text colour readable inside a dark card on a light
|
|
2971
|
+
* page, which is in turn what lets a built app follow its theme instead of
|
|
2972
|
+
* carrying a baked-in hex for every heading.
|
|
2973
|
+
*
|
|
2974
|
+
* Returns all five keys together, never a subset: layering a dark card's text
|
|
2975
|
+
* colour over a light ancestor's surface is exactly how light-on-light happens.
|
|
2976
|
+
* `null` only when the colour is unusable, letting the caller keep what it had.
|
|
2977
|
+
*/
|
|
2978
|
+
function deriveSurfaceTokens(backgroundColor) {
|
|
2979
|
+
if (!isHexColor(backgroundColor)) return null;
|
|
2980
|
+
// "Dark" = the background wants light text (same luminance test as the
|
|
2981
|
+
// on-color contrast picker).
|
|
2982
|
+
const wantsLightText =
|
|
2983
|
+
readableTextColor(backgroundColor, "__dark__", "__light__") === "__light__";
|
|
2984
|
+
if (!wantsLightText) {
|
|
2985
|
+
const base = DEFAULT_THEME_TOKENS.colors;
|
|
2986
|
+
return {
|
|
2987
|
+
surface: base.surface,
|
|
2988
|
+
surfaceMuted: base.surfaceMuted,
|
|
2989
|
+
onSurface: base.onSurface,
|
|
2990
|
+
onSurfaceMuted: base.onSurfaceMuted,
|
|
2991
|
+
border: base.border,
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
return {
|
|
2995
|
+
surface: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surface),
|
|
2996
|
+
surfaceMuted: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.surfaceMuted),
|
|
2997
|
+
onSurface: DARK_SURFACE_TEXT.onSurface,
|
|
2998
|
+
onSurfaceMuted: DARK_SURFACE_TEXT.onSurfaceMuted,
|
|
2999
|
+
border: mixHex(backgroundColor, "#ffffff", SURFACE_LIFT.border),
|
|
3000
|
+
};
|
|
3001
|
+
}
|
|
3002
|
+
|
|
2813
3003
|
/**
|
|
2814
3004
|
* Map a CSS-style gradient angle (0 = to top, 90 = to right) to the
|
|
2815
3005
|
* `{ start, end }` unit vectors `expo-linear-gradient` expects (origin at
|
|
@@ -2828,9 +3018,10 @@ function gradientAngleToVector(angle) {
|
|
|
2828
3018
|
};
|
|
2829
3019
|
}
|
|
2830
3020
|
|
|
2831
|
-
//
|
|
2832
|
-
//
|
|
2833
|
-
|
|
3021
|
+
// Gradients used to need their own alpha-aware predicate because HEX_RE was not.
|
|
3022
|
+
// HEX_RE is now, so this is the same check by another name — kept only as a
|
|
3023
|
+
// local alias so the call sites below read as before.
|
|
3024
|
+
const GRADIENT_HEX_RE = HEX_RE;
|
|
2834
3025
|
|
|
2835
3026
|
/**
|
|
2836
3027
|
* sc-3727 — normalise a component `gradient` to `{ from, to, angle }`, or `null`.
|
|
@@ -2855,8 +3046,33 @@ function normaliseComponentGradient(raw) {
|
|
|
2855
3046
|
return { from, to, angle: ((deg % 360) + 360) % 360 };
|
|
2856
3047
|
}
|
|
2857
3048
|
|
|
3049
|
+
// REQ-THEME-LOOK: coerce a theme's spacing scale into the usable band. Shared
|
|
3050
|
+
// because BOTH hosts multiply node spacing by it and a disagreement here would
|
|
3051
|
+
// re-space an exported page against the Player. Anything absent, non-finite or
|
|
3052
|
+
// out of band resolves to 1 (unchanged) rather than throwing -- `theme_config`
|
|
3053
|
+
// is a JSON bag a workspace admin can PUT verbatim.
|
|
3054
|
+
function clampSpacingScale(value) {
|
|
3055
|
+
// Absent means UNSET, not "as tight as possible" — Number(null) and Number("")
|
|
3056
|
+
// are both 0, which would otherwise clamp an untouched theme to the minimum.
|
|
3057
|
+
if (value === null || value === undefined || value === "") {
|
|
3058
|
+
return THEME_SPACING_SCALE.default;
|
|
3059
|
+
}
|
|
3060
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
3061
|
+
if (!Number.isFinite(n)) return THEME_SPACING_SCALE.default;
|
|
3062
|
+
return Math.min(Math.max(n, THEME_SPACING_SCALE.min), THEME_SPACING_SCALE.max);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
// REQ-THEME-LOOK: apply that scale to ONE spacing value. Rounded to whole
|
|
3066
|
+
// pixels so a scaled gap stays on the same pixel grid as an unscaled one.
|
|
3067
|
+
function scaleSpacing(value, scale) {
|
|
3068
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return value;
|
|
3069
|
+
return Math.round(value * clampSpacingScale(scale));
|
|
3070
|
+
}
|
|
3071
|
+
|
|
2858
3072
|
export {
|
|
2859
3073
|
CONTRACT,
|
|
3074
|
+
clampSpacingScale,
|
|
3075
|
+
scaleSpacing,
|
|
2860
3076
|
isHookAllowed,
|
|
2861
3077
|
requiredContextKeys,
|
|
2862
3078
|
isHexColor,
|
|
@@ -2864,6 +3080,7 @@ export {
|
|
|
2864
3080
|
contrastRatio,
|
|
2865
3081
|
readableTextColor,
|
|
2866
3082
|
deriveAccentTints,
|
|
3083
|
+
deriveSurfaceTokens,
|
|
2867
3084
|
gradientAngleToVector,
|
|
2868
3085
|
normaliseComponentGradient,
|
|
2869
3086
|
formatMoneyIn,
|