@colixsystems/widget-sdk 0.109.0 → 0.111.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 +26 -1
- package/dist/contract.cjs +25 -1
- package/dist/contract.js +25 -1
- package/dist/host.d.ts +37 -0
- package/dist/host.js +5 -0
- package/dist/index.js +8 -0
- package/dist/index.native.js +8 -0
- package/dist/navigation.cjs +94 -2
- package/dist/navigation.js +93 -1
- package/dist/property-schema.js +38 -0
- package/dist/spacing.js +118 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,7 +69,32 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
69
69
|
|
|
70
70
|
## Status
|
|
71
71
|
|
|
72
|
-
`v0.
|
|
72
|
+
`v0.111.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**.
|
|
73
|
+
|
|
74
|
+
### What's new in 0.111.0 (contract 1.85.0)
|
|
75
|
+
|
|
76
|
+
**Each side can be spaced on its own — the `spacing` property type (sc-6447).** Padding and margin were single numbers, so every inset applied to all four sides at once: a hero with generous top padding and none at the bottom, or a card held off only its left neighbour, had no expression. `cornerRadius` already offered each corner (0.104.0); padding and margin were the last four-valued members of the box model that did not.
|
|
77
|
+
|
|
78
|
+
Declare `{ type: "spacing", label: "Padding", validation: { min: 0, max: 64 } }` in your `propertySchema` or `styleSchema`. The Studio renders a slider with a typeable number that sets all four sides, plus a disclosure for setting each one. The authored value is `number | { top, right, bottom, left }` — the scalar form is unchanged, so every value stored before is still valid.
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
import { normaliseSpacing, spacingStyle } from "@colixsystems/widget-sdk";
|
|
82
|
+
|
|
83
|
+
const padding = normaliseSpacing(props.style?.padding, 0, 64);
|
|
84
|
+
return <View style={[styles.card, spacingStyle(padding, "padding")]} />;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`normaliseSpacing(value, fallback, max)` returns all four sides resolved and clamped; `spacingStyle(spacing, property, format)` emits the `padding`/`margin` shorthand when the sides agree and the four long-hand props when they differ (pass `` n => `${n}px` `` for the DOM). `mapSpacing` pushes each side through your own scaling, `isUniformSpacing` and `isZeroSpacing` round out the set. `CONTRACT.version` → `1.85.0`. Additive: every value accepted before is accepted now.
|
|
88
|
+
|
|
89
|
+
### What's new in 0.110.0 (contract 1.84.0)
|
|
90
|
+
|
|
91
|
+
**`resolveSidebarTokens` and `resolveTopBarTokens` — the rail's and the app bar's tokens, resolved once for both hosts (sc-6289).** The footer strip got a shared resolver in 1.71.0; the two chrome parts beside it did not, so their defaults lived inline in the web Player and again in the compiler — and had drifted. An unset `sidebar.backgroundColor` painted the app background in the Expo export where the Player painted it white, so a dark app shipped a dark drawer beside a white rail; an unset `topBar.textColor` painted the app name slate in the export where the Player has always used the brand colour.
|
|
92
|
+
|
|
93
|
+
Both resolvers now own their defaults, which are what the web Player renders, so the export follows the appearance the author approved in the Studio rather than the other way round. Unlike `resolveFooterTokens`, the colours are never `null` — a default that lives in the resolver cannot drift, and one that lived in each host already had. `borderColor` stays nullable: the colour is the divider's switch.
|
|
94
|
+
|
|
95
|
+
The top bar resolves **two** text colours. An unthemed bar paints its icons slate and its app name in the brand colour, and React Navigation's single `headerTintColor` cannot say both — so `tintColor` and `titleColor` are separate, and an authored `topBar.textColor` drives both. `show` is deliberately not among them: it depends on the menu type rather than the theme, and REQ-THEME-14 makes it a no-op on native.
|
|
96
|
+
|
|
97
|
+
Host-integration surface only — nothing a widget imports changed. `CONTRACT.version` → `1.84.0`.
|
|
73
98
|
|
|
74
99
|
### What's new in 0.108.0 (contract 1.82.0)
|
|
75
100
|
|
package/dist/contract.cjs
CHANGED
|
@@ -3362,7 +3362,31 @@ const CONTRACT = deepFreeze({
|
|
|
3362
3362
|
// additionally requires an `inheritAcl` RELATION column, which is what
|
|
3363
3363
|
// makes the stream a strict subset of what `list()` already returns.
|
|
3364
3364
|
// Backed by datastore-client 0.15.0. Omitting `scope` is unchanged.
|
|
3365
|
-
|
|
3365
|
+
// 1.84.0: additive (sc-6289) -- `resolveSidebarTokens` and
|
|
3366
|
+
// `resolveTopBarTokens` (host exports), the rail's and the app bar's
|
|
3367
|
+
// tokens beside the footer's. Their defaults used to be written separately
|
|
3368
|
+
// in each host and had drifted: an unset rail took the app background in
|
|
3369
|
+
// the export where the Player painted it white, and an unset header
|
|
3370
|
+
// painted the app name slate where the Player used the brand colour. The
|
|
3371
|
+
// resolvers own the defaults, so a host cannot keep its own. The top bar
|
|
3372
|
+
// resolves `tintColor` and `titleColor` separately because the web bar has
|
|
3373
|
+
// always painted its icons slate and its name in the brand colour, which
|
|
3374
|
+
// one `headerTintColor` cannot express; an authored `topBar.textColor`
|
|
3375
|
+
// drives both. `show` is deliberately absent -- it depends on the menu
|
|
3376
|
+
// type, not the theme, and is a no-op on native.
|
|
3377
|
+
//
|
|
3378
|
+
// 1.85.0: additive (sc-6447) — the `spacing` property type: a padding or
|
|
3379
|
+
// margin value is `number | { top, right, bottom, left }`, resolved by
|
|
3380
|
+
// `normaliseSpacing` and emitted by `spacingStyle(resolved, "padding" |
|
|
3381
|
+
// "margin")`. Padding and margin were the last four-valued members of the
|
|
3382
|
+
// box model an author could only set on all sides at once, while
|
|
3383
|
+
// `cornerRadius` (1.79.0) already offered each corner — so the same
|
|
3384
|
+
// master-plus-disclosure control now backs all three. The scalar form is
|
|
3385
|
+
// unchanged and still emits the shorthand, so an existing style renders
|
|
3386
|
+
// byte-identically. `mapSpacing` pushes each side through the responsive
|
|
3387
|
+
// and theme scaling the scalar already got; `isZeroSpacing` lets each box
|
|
3388
|
+
// property keep its own zero policy.
|
|
3389
|
+
version: "1.85.0",
|
|
3366
3390
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3367
3391
|
hooks: HOOKS,
|
|
3368
3392
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -3362,7 +3362,31 @@ const CONTRACT = deepFreeze({
|
|
|
3362
3362
|
// additionally requires an `inheritAcl` RELATION column, which is what
|
|
3363
3363
|
// makes the stream a strict subset of what `list()` already returns.
|
|
3364
3364
|
// Backed by datastore-client 0.15.0. Omitting `scope` is unchanged.
|
|
3365
|
-
|
|
3365
|
+
// 1.84.0: additive (sc-6289) -- `resolveSidebarTokens` and
|
|
3366
|
+
// `resolveTopBarTokens` (host exports), the rail's and the app bar's
|
|
3367
|
+
// tokens beside the footer's. Their defaults used to be written separately
|
|
3368
|
+
// in each host and had drifted: an unset rail took the app background in
|
|
3369
|
+
// the export where the Player painted it white, and an unset header
|
|
3370
|
+
// painted the app name slate where the Player used the brand colour. The
|
|
3371
|
+
// resolvers own the defaults, so a host cannot keep its own. The top bar
|
|
3372
|
+
// resolves `tintColor` and `titleColor` separately because the web bar has
|
|
3373
|
+
// always painted its icons slate and its name in the brand colour, which
|
|
3374
|
+
// one `headerTintColor` cannot express; an authored `topBar.textColor`
|
|
3375
|
+
// drives both. `show` is deliberately absent -- it depends on the menu
|
|
3376
|
+
// type, not the theme, and is a no-op on native.
|
|
3377
|
+
//
|
|
3378
|
+
// 1.85.0: additive (sc-6447) — the `spacing` property type: a padding or
|
|
3379
|
+
// margin value is `number | { top, right, bottom, left }`, resolved by
|
|
3380
|
+
// `normaliseSpacing` and emitted by `spacingStyle(resolved, "padding" |
|
|
3381
|
+
// "margin")`. Padding and margin were the last four-valued members of the
|
|
3382
|
+
// box model an author could only set on all sides at once, while
|
|
3383
|
+
// `cornerRadius` (1.79.0) already offered each corner — so the same
|
|
3384
|
+
// master-plus-disclosure control now backs all three. The scalar form is
|
|
3385
|
+
// unchanged and still emits the shorthand, so an existing style renders
|
|
3386
|
+
// byte-identically. `mapSpacing` pushes each side through the responsive
|
|
3387
|
+
// and theme scaling the scalar already got; `isZeroSpacing` lets each box
|
|
3388
|
+
// property keep its own zero policy.
|
|
3389
|
+
version: "1.85.0",
|
|
3366
3390
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3367
3391
|
hooks: HOOKS,
|
|
3368
3392
|
primitives: PRIMITIVES,
|
package/dist/host.d.ts
CHANGED
|
@@ -194,6 +194,43 @@ export interface FooterTokens {
|
|
|
194
194
|
*/
|
|
195
195
|
export function resolveFooterTokens(theme: unknown): FooterTokens;
|
|
196
196
|
|
|
197
|
+
/** The sidebar rail's resolved tokens. Unlike the footer's, the colours are
|
|
198
|
+
* never `null`: the default lives here so the two hosts cannot each keep a
|
|
199
|
+
* different one. `borderColor` is still nullable — no colour, no divider. */
|
|
200
|
+
export interface SidebarTokens {
|
|
201
|
+
backgroundColor: string;
|
|
202
|
+
textColor: string;
|
|
203
|
+
activeColor: string;
|
|
204
|
+
activeStyle: "filled" | "accent";
|
|
205
|
+
borderColor: string | null;
|
|
206
|
+
borderWidth: number | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Resolves the sidebar rail's tokens from a whole `theme_config`. Every default
|
|
211
|
+
* is what the web Player renders, so the export follows the Player rather than
|
|
212
|
+
* the other way round.
|
|
213
|
+
*/
|
|
214
|
+
export function resolveSidebarTokens(theme: unknown): SidebarTokens;
|
|
215
|
+
|
|
216
|
+
/** The top app bar's resolved tokens. `tintColor` and `titleColor` are separate
|
|
217
|
+
* because an unthemed bar paints its icons slate and its app name in the brand
|
|
218
|
+
* colour; an authored `topBar.textColor` drives both. */
|
|
219
|
+
export interface TopBarTokens {
|
|
220
|
+
backgroundColor: string;
|
|
221
|
+
tintColor: string;
|
|
222
|
+
titleColor: string;
|
|
223
|
+
borderColor: string | null;
|
|
224
|
+
borderWidth: number | null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolves the top app bar's tokens from a whole `theme_config`. `show` is not
|
|
229
|
+
* among them: it depends on the menu type, not the theme, and is a no-op on
|
|
230
|
+
* native.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveTopBarTokens(theme: unknown): TopBarTokens;
|
|
233
|
+
|
|
197
234
|
/** A value a widget may persist: a scalar, or a flat array of them. */
|
|
198
235
|
export type WidgetRouteScalar = string | number | boolean;
|
|
199
236
|
export type WidgetRouteValue = WidgetRouteScalar | WidgetRouteScalar[];
|
package/dist/host.js
CHANGED
|
@@ -50,11 +50,16 @@ export {
|
|
|
50
50
|
// the sidebar's so an untouched workspace keeps the appearance it has. It
|
|
51
51
|
// exists because the strip IS the menu under `bottom-tabs`, where the sidebar
|
|
52
52
|
// panel is hidden and those tokens can no longer be set at all.
|
|
53
|
+
// REQ-THEME-14: `resolveSidebarTokens` and `resolveTopBarTokens` are the same
|
|
54
|
+
// idea for the rail and the app bar, whose defaults used to live inline in each
|
|
55
|
+
// host and had already drifted apart.
|
|
53
56
|
export {
|
|
54
57
|
normaliseNavigation,
|
|
55
58
|
menuItemCap,
|
|
56
59
|
quickBarCap,
|
|
57
60
|
resolveFooterTokens,
|
|
61
|
+
resolveSidebarTokens,
|
|
62
|
+
resolveTopBarTokens,
|
|
58
63
|
} from "./navigation.js";
|
|
59
64
|
|
|
60
65
|
// sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,14 @@ export {
|
|
|
13
13
|
cornerRadiusStyle,
|
|
14
14
|
hasCornerRadius,
|
|
15
15
|
} from "./corner-radius.js";
|
|
16
|
+
export {
|
|
17
|
+
SPACING_KEYS,
|
|
18
|
+
normaliseSpacing,
|
|
19
|
+
isUniformSpacing,
|
|
20
|
+
isZeroSpacing,
|
|
21
|
+
mapSpacing,
|
|
22
|
+
spacingStyle,
|
|
23
|
+
} from "./spacing.js";
|
|
16
24
|
export {
|
|
17
25
|
WidgetContextProvider,
|
|
18
26
|
DatastoreError,
|
package/dist/index.native.js
CHANGED
|
@@ -13,6 +13,14 @@ export {
|
|
|
13
13
|
cornerRadiusStyle,
|
|
14
14
|
hasCornerRadius,
|
|
15
15
|
} from "./corner-radius.js";
|
|
16
|
+
export {
|
|
17
|
+
SPACING_KEYS,
|
|
18
|
+
normaliseSpacing,
|
|
19
|
+
isUniformSpacing,
|
|
20
|
+
isZeroSpacing,
|
|
21
|
+
mapSpacing,
|
|
22
|
+
spacingStyle,
|
|
23
|
+
} from "./spacing.js";
|
|
16
24
|
export {
|
|
17
25
|
WidgetContextProvider,
|
|
18
26
|
DatastoreError,
|
package/dist/navigation.cjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// the SAME literal, so what a planner may persist and what a host draws cannot
|
|
26
26
|
// diverge.
|
|
27
27
|
|
|
28
|
-
const { CONTRACT } = require("./contract.cjs");
|
|
28
|
+
const { CONTRACT, isHexColor } = require("./contract.cjs");
|
|
29
29
|
|
|
30
30
|
// Absent or unknown resolves here, so every app authored before menu types
|
|
31
31
|
// existed renders and compiles byte-identically.
|
|
@@ -114,6 +114,98 @@ function resolveFooterTokens(theme) {
|
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// REQ-THEME-14 — the rail's and the app bar's tokens, resolved once for both
|
|
118
|
+
// hosts.
|
|
119
|
+
//
|
|
120
|
+
// These return FULLY resolved values where `resolveFooterTokens` above returns
|
|
121
|
+
// raw-or-null, and the difference is deliberate. The footer's two hosts happen
|
|
122
|
+
// to agree on their separately-written defaults; the rail's and the bar's did
|
|
123
|
+
// not. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
|
|
124
|
+
// background in the export, and an unset `topBar.textColor` painted the brand
|
|
125
|
+
// primary on web and slate-700 in the export. A default that lives in the
|
|
126
|
+
// resolver cannot drift; one that lives in each host already had. The footer
|
|
127
|
+
// wants this same shape next — the same fix, not yet applied.
|
|
128
|
+
//
|
|
129
|
+
// Every default is what the WEB Player renders today, so the Player does not
|
|
130
|
+
// move and the export converges onto it. That is the direction the footer was
|
|
131
|
+
// converged in too (compiler.service's footerBg defaults to the web's #ffffff).
|
|
132
|
+
|
|
133
|
+
const DEFAULT_PRIMARY_COLOR = "#3b82f6";
|
|
134
|
+
// The web rail's and app bar's `bg-white`.
|
|
135
|
+
const DEFAULT_CHROME_SURFACE = "#ffffff";
|
|
136
|
+
// The web nav item's `text-slate-600`.
|
|
137
|
+
const DEFAULT_CHROME_TEXT = "#475569";
|
|
138
|
+
// The web mobile header's icon colour, `text-slate-700`.
|
|
139
|
+
const DEFAULT_TOP_BAR_TINT = "#334155";
|
|
140
|
+
|
|
141
|
+
// The contract's own guard, which already admits the 8-digit form a look uses
|
|
142
|
+
// to float a translucent rail (#RRGGBBAA).
|
|
143
|
+
function hexOrNull(value) {
|
|
144
|
+
return isHexColor(value) ? value : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hexOr(value, fallback) {
|
|
148
|
+
return hexOrNull(value) || fallback;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function brandPrimary(config) {
|
|
152
|
+
return hexOr(config.primaryColor, DEFAULT_PRIMARY_COLOR);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The sidebar rail's surface, labels, active item and opt-in divider.
|
|
157
|
+
*
|
|
158
|
+
* @param {unknown} theme — the whole `theme_config`; `primaryColor` is read
|
|
159
|
+
* because an unset active colour resolves to the brand.
|
|
160
|
+
* @returns {{ backgroundColor: string, textColor: string, activeColor: string,
|
|
161
|
+
* activeStyle: string, borderColor: string|null, borderWidth: number|null }}
|
|
162
|
+
*/
|
|
163
|
+
function resolveSidebarTokens(theme) {
|
|
164
|
+
const config = isPlainObject(theme) ? theme : {};
|
|
165
|
+
const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
|
|
166
|
+
const borderColor = hexOrNull(sidebar.borderColor);
|
|
167
|
+
return {
|
|
168
|
+
backgroundColor: hexOr(sidebar.backgroundColor, DEFAULT_CHROME_SURFACE),
|
|
169
|
+
textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
|
|
170
|
+
activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
|
|
171
|
+
activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
|
|
172
|
+
// REQ-THEME-LOOK: the colour is the switch — no colour, no line.
|
|
173
|
+
borderColor,
|
|
174
|
+
borderWidth: borderColor ? borderWidthOr(sidebar.borderWidth) : null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The top app bar's surface, its two text colours and its opt-in divider.
|
|
180
|
+
*
|
|
181
|
+
* `tintColor` and `titleColor` are SEPARATE because the web bar has always
|
|
182
|
+
* painted them differently when the author names nothing: the hamburger and
|
|
183
|
+
* icons take slate, the app name takes the brand. React Navigation's single
|
|
184
|
+
* `headerTintColor` could not express that, which is why the export used to
|
|
185
|
+
* paint the name slate. One token, two resolved fields, both hosts agree.
|
|
186
|
+
*
|
|
187
|
+
* `show` is deliberately absent: it depends on the menu TYPE, not the theme,
|
|
188
|
+
* and REQ-THEME-14 makes it a no-op on native — so it is not a shared token.
|
|
189
|
+
*
|
|
190
|
+
* @param {unknown} theme — the whole `theme_config`.
|
|
191
|
+
* @returns {{ backgroundColor: string, tintColor: string, titleColor: string,
|
|
192
|
+
* borderColor: string|null, borderWidth: number|null }}
|
|
193
|
+
*/
|
|
194
|
+
function resolveTopBarTokens(theme) {
|
|
195
|
+
const config = isPlainObject(theme) ? theme : {};
|
|
196
|
+
const topBar = isPlainObject(config.topBar) ? config.topBar : {};
|
|
197
|
+
const borderColor = hexOrNull(topBar.borderColor);
|
|
198
|
+
// An explicit colour drives BOTH slots; only the unset case splits.
|
|
199
|
+
const authored = hexOrNull(topBar.textColor);
|
|
200
|
+
return {
|
|
201
|
+
backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
|
|
202
|
+
tintColor: authored || DEFAULT_TOP_BAR_TINT,
|
|
203
|
+
titleColor: authored || brandPrimary(config),
|
|
204
|
+
borderColor,
|
|
205
|
+
borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
117
209
|
function borderWidthOr(...values) {
|
|
118
210
|
for (const value of values) {
|
|
119
211
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
@@ -121,4 +213,4 @@ function borderWidthOr(...values) {
|
|
|
121
213
|
return 1;
|
|
122
214
|
}
|
|
123
215
|
|
|
124
|
-
module.exports = { normaliseNavigation, menuItemCap, quickBarCap, resolveFooterTokens };
|
|
216
|
+
module.exports = { normaliseNavigation, menuItemCap, quickBarCap, resolveFooterTokens, resolveSidebarTokens, resolveTopBarTokens };
|
package/dist/navigation.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the SAME literal, so what a planner may persist and what a host draws cannot
|
|
17
17
|
// diverge.
|
|
18
18
|
|
|
19
|
-
import { CONTRACT } from "./contract.js";
|
|
19
|
+
import { CONTRACT, isHexColor } from "./contract.js";
|
|
20
20
|
|
|
21
21
|
// Absent or unknown resolves here, so every app authored before menu types
|
|
22
22
|
// existed renders and compiles byte-identically.
|
|
@@ -105,6 +105,98 @@ export function resolveFooterTokens(theme) {
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// REQ-THEME-14 — the rail's and the app bar's tokens, resolved once for both
|
|
109
|
+
// hosts.
|
|
110
|
+
//
|
|
111
|
+
// These return FULLY resolved values where `resolveFooterTokens` above returns
|
|
112
|
+
// raw-or-null, and the difference is deliberate. The footer's two hosts happen
|
|
113
|
+
// to agree on their separately-written defaults; the rail's and the bar's did
|
|
114
|
+
// not. An unset `sidebar.backgroundColor` painted #ffffff on web and the app
|
|
115
|
+
// background in the export, and an unset `topBar.textColor` painted the brand
|
|
116
|
+
// primary on web and slate-700 in the export. A default that lives in the
|
|
117
|
+
// resolver cannot drift; one that lives in each host already had. The footer
|
|
118
|
+
// wants this same shape next — the same fix, not yet applied.
|
|
119
|
+
//
|
|
120
|
+
// Every default is what the WEB Player renders today, so the Player does not
|
|
121
|
+
// move and the export converges onto it. That is the direction the footer was
|
|
122
|
+
// converged in too (compiler.service's footerBg defaults to the web's #ffffff).
|
|
123
|
+
|
|
124
|
+
const DEFAULT_PRIMARY_COLOR = "#3b82f6";
|
|
125
|
+
// The web rail's and app bar's `bg-white`.
|
|
126
|
+
const DEFAULT_CHROME_SURFACE = "#ffffff";
|
|
127
|
+
// The web nav item's `text-slate-600`.
|
|
128
|
+
const DEFAULT_CHROME_TEXT = "#475569";
|
|
129
|
+
// The web mobile header's icon colour, `text-slate-700`.
|
|
130
|
+
const DEFAULT_TOP_BAR_TINT = "#334155";
|
|
131
|
+
|
|
132
|
+
// The contract's own guard, which already admits the 8-digit form a look uses
|
|
133
|
+
// to float a translucent rail (#RRGGBBAA).
|
|
134
|
+
function hexOrNull(value) {
|
|
135
|
+
return isHexColor(value) ? value : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hexOr(value, fallback) {
|
|
139
|
+
return hexOrNull(value) || fallback;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function brandPrimary(config) {
|
|
143
|
+
return hexOr(config.primaryColor, DEFAULT_PRIMARY_COLOR);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The sidebar rail's surface, labels, active item and opt-in divider.
|
|
148
|
+
*
|
|
149
|
+
* @param {unknown} theme — the whole `theme_config`; `primaryColor` is read
|
|
150
|
+
* because an unset active colour resolves to the brand.
|
|
151
|
+
* @returns {{ backgroundColor: string, textColor: string, activeColor: string,
|
|
152
|
+
* activeStyle: string, borderColor: string|null, borderWidth: number|null }}
|
|
153
|
+
*/
|
|
154
|
+
export function resolveSidebarTokens(theme) {
|
|
155
|
+
const config = isPlainObject(theme) ? theme : {};
|
|
156
|
+
const sidebar = isPlainObject(config.sidebar) ? config.sidebar : {};
|
|
157
|
+
const borderColor = hexOrNull(sidebar.borderColor);
|
|
158
|
+
return {
|
|
159
|
+
backgroundColor: hexOr(sidebar.backgroundColor, DEFAULT_CHROME_SURFACE),
|
|
160
|
+
textColor: hexOr(sidebar.textColor, DEFAULT_CHROME_TEXT),
|
|
161
|
+
activeColor: hexOr(sidebar.activeColor, brandPrimary(config)),
|
|
162
|
+
activeStyle: sidebar.activeStyle === "accent" ? "accent" : "filled",
|
|
163
|
+
// REQ-THEME-LOOK: the colour is the switch — no colour, no line.
|
|
164
|
+
borderColor,
|
|
165
|
+
borderWidth: borderColor ? borderWidthOr(sidebar.borderWidth) : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The top app bar's surface, its two text colours and its opt-in divider.
|
|
171
|
+
*
|
|
172
|
+
* `tintColor` and `titleColor` are SEPARATE because the web bar has always
|
|
173
|
+
* painted them differently when the author names nothing: the hamburger and
|
|
174
|
+
* icons take slate, the app name takes the brand. React Navigation's single
|
|
175
|
+
* `headerTintColor` could not express that, which is why the export used to
|
|
176
|
+
* paint the name slate. One token, two resolved fields, both hosts agree.
|
|
177
|
+
*
|
|
178
|
+
* `show` is deliberately absent: it depends on the menu TYPE, not the theme,
|
|
179
|
+
* and REQ-THEME-14 makes it a no-op on native — so it is not a shared token.
|
|
180
|
+
*
|
|
181
|
+
* @param {unknown} theme — the whole `theme_config`.
|
|
182
|
+
* @returns {{ backgroundColor: string, tintColor: string, titleColor: string,
|
|
183
|
+
* borderColor: string|null, borderWidth: number|null }}
|
|
184
|
+
*/
|
|
185
|
+
export function resolveTopBarTokens(theme) {
|
|
186
|
+
const config = isPlainObject(theme) ? theme : {};
|
|
187
|
+
const topBar = isPlainObject(config.topBar) ? config.topBar : {};
|
|
188
|
+
const borderColor = hexOrNull(topBar.borderColor);
|
|
189
|
+
// An explicit colour drives BOTH slots; only the unset case splits.
|
|
190
|
+
const authored = hexOrNull(topBar.textColor);
|
|
191
|
+
return {
|
|
192
|
+
backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
|
|
193
|
+
tintColor: authored || DEFAULT_TOP_BAR_TINT,
|
|
194
|
+
titleColor: authored || brandPrimary(config),
|
|
195
|
+
borderColor,
|
|
196
|
+
borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
108
200
|
function borderWidthOr(...values) {
|
|
109
201
|
for (const value of values) {
|
|
110
202
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
package/dist/property-schema.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Drives the schema-driven Properties Panel and validates persisted page JSON.
|
|
3
3
|
|
|
4
4
|
import { CORNER_RADIUS_KEYS } from "./corner-radius.js";
|
|
5
|
+
import { SPACING_KEYS } from "./spacing.js";
|
|
5
6
|
|
|
6
7
|
const VALID_TYPES = new Set([
|
|
7
8
|
"string", "number", "boolean",
|
|
@@ -57,6 +58,13 @@ const VALID_TYPES = new Set([
|
|
|
57
58
|
// which both hosts spell identically. Plain numbers, so tenant-copy needs
|
|
58
59
|
// no remap.
|
|
59
60
|
"cornerRadius",
|
|
61
|
+
// REQ-LAY-17 (sc-6447): `spacing` is a padding/margin picker. Its value is
|
|
62
|
+
// `number | { top, right, bottom, left }` — a scalar spaces all four sides,
|
|
63
|
+
// the object spaces each independently. The Studio renders a slider +
|
|
64
|
+
// typeable number with a per-side disclosure; a widget turns the value into
|
|
65
|
+
// style props with `spacingStyle(normaliseSpacing(v), "padding")`, which both
|
|
66
|
+
// hosts spell identically. Plain numbers, so tenant-copy needs no remap.
|
|
67
|
+
"spacing",
|
|
60
68
|
"expression", "eventBinding",
|
|
61
69
|
"object", "array",
|
|
62
70
|
]);
|
|
@@ -192,6 +200,36 @@ function coerceLeaf(def, value, path, errors) {
|
|
|
192
200
|
}
|
|
193
201
|
return value;
|
|
194
202
|
}
|
|
203
|
+
case "spacing": {
|
|
204
|
+
// REQ-LAY-17: a scalar spaces all four sides; an object spaces each.
|
|
205
|
+
// Every side is optional so a half-set object stays valid while the
|
|
206
|
+
// author is still adjusting — an unset side falls back to the scalar.
|
|
207
|
+
const { min = 0, max } = def.validation || {};
|
|
208
|
+
const checkSide = (n, at) => {
|
|
209
|
+
if (typeof n !== "number" || Number.isNaN(n)) {
|
|
210
|
+
errors.push(`${at}: expected number`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (n < min) errors.push(`${at}: must be >= ${min}`);
|
|
214
|
+
if (max !== undefined && n > max) errors.push(`${at}: must be <= ${max}`);
|
|
215
|
+
};
|
|
216
|
+
if (typeof value === "number") {
|
|
217
|
+
checkSide(value, path);
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
if (!isPlainObject(value)) {
|
|
221
|
+
errors.push(`${path}: expected number or per-side object`);
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
for (const [k, n] of Object.entries(value)) {
|
|
225
|
+
if (!SPACING_KEYS.includes(k)) {
|
|
226
|
+
errors.push(`${path}.${k}: unknown side`);
|
|
227
|
+
} else if (n !== undefined && n !== null) {
|
|
228
|
+
checkSide(n, `${path}.${k}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
195
233
|
case "select":
|
|
196
234
|
if (Array.isArray(def.enum) && !def.enum.some((e) => e.value === value)) {
|
|
197
235
|
errors.push(`${path}: value not in enum`);
|
package/dist/spacing.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// REQ-LAY-17 (sc-6447): the per-side spacing vocabulary. ONE normaliser and ONE
|
|
2
|
+
// style emitter for the web Player, the Builder canvas, the exported Expo app,
|
|
3
|
+
// and any custom widget declaring a `padding`/`margin` field — so the four can
|
|
4
|
+
// never disagree about what a spacing value means.
|
|
5
|
+
//
|
|
6
|
+
// The authored value is `number | { top, right, bottom, left }`. The scalar form
|
|
7
|
+
// is what every existing style holds, so it stays first-class rather than being
|
|
8
|
+
// migrated away: widening the shape beats adding a second key beside it
|
|
9
|
+
// (CLAUDE.md §3). This mirrors `corner-radius.js` deliberately — padding/margin
|
|
10
|
+
// and cornerRadius are the four-valued members of one box model, and an author
|
|
11
|
+
// meets the same affordance in both.
|
|
12
|
+
//
|
|
13
|
+
// Both hosts spell the long-hand props identically (`paddingTop` / `marginTop`
|
|
14
|
+
// &co in React inline style AND in React Native), which is why one emitter
|
|
15
|
+
// serves both.
|
|
16
|
+
|
|
17
|
+
export const SPACING_KEYS = Object.freeze(["top", "right", "bottom", "left"]);
|
|
18
|
+
|
|
19
|
+
// The CSS/RN suffix per side. `padding` + "Top" and `margin` + "Top" are both
|
|
20
|
+
// valid on web and native, so the property name is the caller's to choose.
|
|
21
|
+
const LONGHAND_SUFFIX = Object.freeze({
|
|
22
|
+
top: "Top",
|
|
23
|
+
right: "Right",
|
|
24
|
+
bottom: "Bottom",
|
|
25
|
+
left: "Left",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function clampSide(value, fallback, max) {
|
|
29
|
+
// Unset (undefined/null/"") falls back; an explicit 0 is honoured — that
|
|
30
|
+
// distinction is what makes "no padding on just this side" expressible.
|
|
31
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
32
|
+
const n = Number(value);
|
|
33
|
+
if (!Number.isFinite(n)) return fallback;
|
|
34
|
+
// Truncates, where `corner-radius.js` rounds: padding and margin have always
|
|
35
|
+
// truncated (`clampInt` / `clampMargin`), and changing that would shift every
|
|
36
|
+
// existing fractional value by a pixel.
|
|
37
|
+
return Math.min(Math.max(Math.trunc(n), 0), max);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve an authored spacing to its four sides.
|
|
42
|
+
*
|
|
43
|
+
* @param {number|object|null|undefined} value the authored `number | {sides}`
|
|
44
|
+
* @param {number} [fallback] the value each unset side takes
|
|
45
|
+
* @param {number} [max] the upper clamp, matching the field's declared max
|
|
46
|
+
* @returns {{top:number, right:number, bottom:number, left:number}}
|
|
47
|
+
*/
|
|
48
|
+
export function normaliseSpacing(value, fallback = 0, max = 64) {
|
|
49
|
+
const base = clampSide(
|
|
50
|
+
typeof value === "number" || typeof value === "string" ? value : undefined,
|
|
51
|
+
clampSide(fallback, 0, max),
|
|
52
|
+
max,
|
|
53
|
+
);
|
|
54
|
+
const sides = value && typeof value === "object" ? value : null;
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const key of SPACING_KEYS) {
|
|
57
|
+
out[key] = clampSide(sides ? sides[key] : undefined, base, max);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True when all four sides resolve to the same number. */
|
|
63
|
+
export function isUniformSpacing(spacing) {
|
|
64
|
+
if (!spacing) return true;
|
|
65
|
+
const { top } = spacing;
|
|
66
|
+
return SPACING_KEYS.every((key) => spacing[key] === top);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True when every side resolves to 0 — i.e. the value asks for no spacing. */
|
|
70
|
+
export function isZeroSpacing(spacing) {
|
|
71
|
+
return !spacing || SPACING_KEYS.every((key) => !spacing[key]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map every side through `fn`, keeping the resolved shape. Used to push each
|
|
76
|
+
* side through the responsive/theme scaling the scalar form already got, so a
|
|
77
|
+
* per-side value scales down on a phone exactly like a uniform one.
|
|
78
|
+
*
|
|
79
|
+
* @param {object|null} spacing output of {@link normaliseSpacing}
|
|
80
|
+
* @param {(n: number) => number} fn
|
|
81
|
+
* @returns {object|null}
|
|
82
|
+
*/
|
|
83
|
+
export function mapSpacing(spacing, fn) {
|
|
84
|
+
if (!spacing) return null;
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const key of SPACING_KEYS) {
|
|
87
|
+
const next = Number(fn(spacing[key]));
|
|
88
|
+
out[key] = Number.isFinite(next) ? next : spacing[key];
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The style props for a resolved spacing, in the spelling BOTH hosts accept.
|
|
95
|
+
* A uniform value emits the shorthand so an untouched style is byte-identical
|
|
96
|
+
* to what the scalar form produced; only a genuinely mixed value pays for the
|
|
97
|
+
* four long-hand props.
|
|
98
|
+
*
|
|
99
|
+
* This emitter is MECHANICAL — it always emits, including an all-zero value.
|
|
100
|
+
* The zero policy differs per box property (a container's `padding: 0` has
|
|
101
|
+
* always been written, while a zero `margin` has always emitted nothing), so it
|
|
102
|
+
* belongs with each caller via {@link isZeroSpacing}, not baked in here.
|
|
103
|
+
*
|
|
104
|
+
* @param {object|null} spacing output of {@link normaliseSpacing}
|
|
105
|
+
* @param {"padding"|"margin"} [property] which box property to spell
|
|
106
|
+
* @param {(n: number) => any} [format] wraps each number — the DOM needs "12px"
|
|
107
|
+
* @returns {object|null}
|
|
108
|
+
*/
|
|
109
|
+
export function spacingStyle(spacing, property = "padding", format) {
|
|
110
|
+
if (!spacing) return null;
|
|
111
|
+
const wrap = format || ((n) => n);
|
|
112
|
+
if (isUniformSpacing(spacing)) return { [property]: wrap(spacing.top) };
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const key of SPACING_KEYS) {
|
|
115
|
+
out[`${property}${LONGHAND_SUFFIX[key]}`] = wrap(spacing[key]);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.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",
|