@colixsystems/widget-sdk 0.110.0 → 0.112.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 +55 -5
- package/dist/cli.js +75 -22
- package/dist/contract.cjs +13 -1
- package/dist/contract.js +13 -1
- package/dist/index.js +8 -0
- package/dist/index.native.js +8 -0
- package/dist/linter.cjs +167 -1
- package/dist/linter.js +161 -0
- package/dist/property-schema.js +38 -0
- package/dist/spacing.js +118 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -69,7 +69,50 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
69
69
|
|
|
70
70
|
## Status
|
|
71
71
|
|
|
72
|
-
`v0.
|
|
72
|
+
`v0.112.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.112.0 (contract unchanged at 1.85.0)
|
|
75
|
+
|
|
76
|
+
**Two linter rules make the styling contract checkable, and `lintStyleWiring` joins the linter export (sc-6455).** Every visual value a widget writes reaches the app's owner through one of exactly two channels — a **theme token** (`useTheme()`), which is the app-wide default and follows a look change, or a **`styleSchema` field** read off `props.style`, which the Studio offers per instance in the widget editor *and* app-wide under **Design → Widget appearance**. A literal reaches neither: it outranks the theme permanently and no control on either surface can move it, so the owner finds a corner of their app they cannot restyle. Until now that rule was documentation only.
|
|
77
|
+
|
|
78
|
+
- **`no-hardcoded-design`** flags a colour literal (`#rgb` / `#rrggbb` / `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()`), a `fontFamily` string literal, or a numeric `fontSize`. A `fontSize` resolved off a theme token or a style field (`style.valueSize ?? 18`) is *not* flagged — a literal `default` beside a declared field is the contract working. Raw `padding` / `margin` / `borderRadius` numbers are deliberately out of scope: measured layout legitimately carries them, so a spacing rule would be noise.
|
|
79
|
+
- **`style-field-unread`** flags a `styleSchema` field whose name appears nowhere in the widget's source — a dead control the author moves to no effect. It runs over the WHOLE bundle, not per file, so a split-impl widget that reads a field in `widget.web.jsx` and not in `widget.native.jsx` is correctly counted as wired.
|
|
80
|
+
|
|
81
|
+
A value that genuinely cannot be a token — a categorical series palette, a video letterbox, a scannable QR plate — is licensed with a preceding comment. **The reason is mandatory**; a bare marker licenses nothing. A marker on its own line covers the whole statement below it (bracket-balanced, so one marker covers a multi-line palette); a trailing marker covers only its own line.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
// appstudio-design-ok: categorical series identity cannot come from one accent
|
|
85
|
+
const SERIES = ["#ff6b5b", "#3b82f6", "#10b981"];
|
|
86
|
+
|
|
87
|
+
const letterbox = { backgroundColor: "#000" }; // appstudio-design-ok: video letterbox
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Both rules are **warning** severity, so `appstudio-widget lint` reports them and still exits 0 — you decide when to act on them. They are **blocking** for the AI widget agent, which publishes with no human in the loop.
|
|
91
|
+
|
|
92
|
+
`lint` now takes a whole bundle, and `--manifest` enables the wiring check:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { lintStyleWiring } from "@colixsystems/widget-sdk/linter";
|
|
100
|
+
const report = lintStyleWiring(manifest, { "widget.jsx": source });
|
|
101
|
+
```
|
|
102
|
+
### What's new in 0.111.0 (contract 1.85.0)
|
|
103
|
+
|
|
104
|
+
**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.
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
import { normaliseSpacing, spacingStyle } from "@colixsystems/widget-sdk";
|
|
110
|
+
|
|
111
|
+
const padding = normaliseSpacing(props.style?.padding, 0, 64);
|
|
112
|
+
return <View style={[styles.card, spacingStyle(padding, "padding")]} />;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`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.
|
|
73
116
|
|
|
74
117
|
### What's new in 0.110.0 (contract 1.84.0)
|
|
75
118
|
|
|
@@ -1277,16 +1320,23 @@ that renders on one platform and blanks on the other.
|
|
|
1277
1320
|
## Linter
|
|
1278
1321
|
|
|
1279
1322
|
```sh
|
|
1280
|
-
npx appstudio-widget lint path/to/widget.
|
|
1323
|
+
npx appstudio-widget lint path/to/widget.jsx
|
|
1324
|
+
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
|
|
1281
1325
|
```
|
|
1282
1326
|
|
|
1283
|
-
Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios).
|
|
1327
|
+
Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios) and for the styling rules below. **Only error-severity findings change the exit code**; warnings print and exit 0.
|
|
1284
1328
|
|
|
1285
1329
|
```js
|
|
1286
|
-
import { lintSource } from "@colixsystems/widget-sdk/linter";
|
|
1287
|
-
const report = lintSource(source);
|
|
1330
|
+
import { lintSource, lintStyleWiring } from "@colixsystems/widget-sdk/linter";
|
|
1331
|
+
const report = lintSource(source, { manifest });
|
|
1332
|
+
// Bundle-level — pass every file the widget ships:
|
|
1333
|
+
const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
|
|
1288
1334
|
```
|
|
1289
1335
|
|
|
1336
|
+
Two rules keep a widget's look reachable from the Studio (sc-6455). `no-hardcoded-design` flags a colour literal, a `fontFamily` string, or a numeric `fontSize` — values that outrank the theme permanently and that no control can move. `style-field-unread` flags a `styleSchema` field the source never reads, which renders a control that does nothing. Both are warnings for a human author and blocking for the AI widget agent. License a genuinely un-tokenizable value with a preceding `// appstudio-design-ok: <reason>` (the reason is required); see *What's new in 0.110.0*.
|
|
1337
|
+
|
|
1338
|
+
Pass `--manifest` to enable `style-field-unread` — it needs the manifest, and it needs every source at once so a split-impl widget's per-host field reads are seen together.
|
|
1339
|
+
|
|
1290
1340
|
## Local dev loop (`appstudio-widget dev`)
|
|
1291
1341
|
|
|
1292
1342
|
Author a marketplace widget with live reload instead of the publish → submit →
|
package/dist/cli.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// CLI entry for `appstudio-widget`.
|
|
3
|
-
// appstudio-widget lint <path
|
|
3
|
+
// appstudio-widget lint <file.jsx...> [--manifest path] — validate a widget bundle
|
|
4
4
|
// appstudio-widget dev <entry.jsx> [--port N] [--manifest path]
|
|
5
5
|
// — REQ-WSDK-DEVKIT local dev server
|
|
6
6
|
|
|
7
7
|
import { readFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
9
10
|
import { argv, exit, stderr, stdout } from "node:process";
|
|
10
|
-
import { lintSource } from "./linter.js";
|
|
11
|
+
import { lintSource, lintStyleWiring } from "./linter.js";
|
|
11
12
|
import { startDevServer } from "./devserver.js";
|
|
12
13
|
|
|
13
14
|
function usage() {
|
|
14
15
|
stderr.write(
|
|
15
16
|
"Usage:\n" +
|
|
16
|
-
" appstudio-widget lint <path
|
|
17
|
+
" appstudio-widget lint <file.jsx...> [--manifest <path>]\n" +
|
|
18
|
+
" Pass every file the widget ships plus --manifest to also check " +
|
|
19
|
+
"that each styleSchema field is actually read.\n" +
|
|
17
20
|
" appstudio-widget dev <entry.jsx|widget-dir> [--port <n>] [--manifest <path>]\n" +
|
|
18
21
|
" A directory containing widget.json runs in multi-file mode " +
|
|
19
22
|
"(REQ-WSDK-DEVKIT v2): the dev server reads the canonical web entry, " +
|
|
@@ -49,34 +52,84 @@ function parseFlags(args) {
|
|
|
49
52
|
return { flags, positionals };
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
async function loadManifest(path) {
|
|
56
|
+
const abs = resolve(path);
|
|
57
|
+
if (abs.endsWith(".json")) return JSON.parse(readFileSync(abs, "utf8"));
|
|
58
|
+
const mod = await import(pathToFileURL(abs).href);
|
|
59
|
+
return mod.default || mod.manifest || mod;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeFindings(findings, stream) {
|
|
63
|
+
for (const f of findings) {
|
|
64
|
+
const severity = f.severity === "warning" ? "warning" : "error";
|
|
65
|
+
const where = f.line ? ` line ${f.line}` : "";
|
|
66
|
+
stream.write(
|
|
67
|
+
` ${severity} [${f.rule}]${where}: ${f.label}\n ${f.snippet}\n`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// sc-6455 — lint a BUNDLE, not just a file. The dead-control gate has to see
|
|
73
|
+
// every source at once: a split-impl widget legitimately reads a style field
|
|
74
|
+
// in widget.web.jsx and not in widget.native.jsx, and checking one file alone
|
|
75
|
+
// would call that field dead. Pass --manifest to enable it; a single file
|
|
76
|
+
// with no manifest lints exactly as it always did.
|
|
77
|
+
async function runLint(rest) {
|
|
78
|
+
const { flags, positionals } = parseFlags(rest);
|
|
79
|
+
if (positionals.length === 0) usage();
|
|
80
|
+
const files = {};
|
|
81
|
+
for (const p of positionals) {
|
|
82
|
+
const filePath = resolve(p);
|
|
83
|
+
try {
|
|
84
|
+
files[filePath] = readFileSync(filePath, "utf8");
|
|
85
|
+
} catch (err) {
|
|
86
|
+
stderr.write(`Could not read ${filePath}: ${err.message}\n`);
|
|
87
|
+
exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let manifest = null;
|
|
91
|
+
if (typeof flags.manifest === "string") {
|
|
92
|
+
try {
|
|
93
|
+
manifest = await loadManifest(flags.manifest);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
stderr.write(
|
|
96
|
+
`Could not read manifest ${flags.manifest}: ${err.message}\n`,
|
|
97
|
+
);
|
|
98
|
+
exit(1);
|
|
99
|
+
}
|
|
61
100
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
101
|
+
const label = positionals.join(", ");
|
|
102
|
+
const perFile = Object.entries(files).map(([filePath, source]) => [
|
|
103
|
+
filePath,
|
|
104
|
+
lintSource(source, manifest ? { manifest } : undefined),
|
|
105
|
+
]);
|
|
106
|
+
const wiring = manifest
|
|
107
|
+
? lintStyleWiring(manifest, files)
|
|
108
|
+
: { ok: true, findings: [] };
|
|
109
|
+
const all = perFile
|
|
110
|
+
.flatMap(([, r]) => r.findings)
|
|
111
|
+
.concat(wiring.findings);
|
|
112
|
+
if (all.length === 0) {
|
|
113
|
+
stdout.write(`${label}: clean\n`);
|
|
65
114
|
exit(0);
|
|
66
115
|
}
|
|
67
116
|
// sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
|
|
68
117
|
// for warnings, so the CLI printed "clean" and dropped them. A warning nobody
|
|
69
118
|
// sees is pointless. Report every finding; only errors change the exit code.
|
|
70
|
-
const
|
|
119
|
+
const ok = perFile.every(([, r]) => r.ok) && wiring.ok;
|
|
120
|
+
const errors = all.filter((f) => f.severity !== "warning").length;
|
|
71
121
|
const stream = ok ? stdout : stderr;
|
|
72
122
|
stream.write(
|
|
73
|
-
`${
|
|
123
|
+
`${label}: ${errors} error(s), ${all.length - errors} warning(s)\n`,
|
|
74
124
|
);
|
|
75
|
-
for (const
|
|
76
|
-
|
|
77
|
-
stream.write(
|
|
78
|
-
|
|
79
|
-
|
|
125
|
+
for (const [filePath, r] of perFile) {
|
|
126
|
+
if (r.findings.length === 0) continue;
|
|
127
|
+
if (perFile.length > 1) stream.write(`${filePath}:\n`);
|
|
128
|
+
writeFindings(r.findings, stream);
|
|
129
|
+
}
|
|
130
|
+
if (wiring.findings.length > 0) {
|
|
131
|
+
stream.write(`manifest.styleSchema:\n`);
|
|
132
|
+
writeFindings(wiring.findings, stream);
|
|
80
133
|
}
|
|
81
134
|
exit(ok ? 0 : 1);
|
|
82
135
|
}
|
package/dist/contract.cjs
CHANGED
|
@@ -3374,7 +3374,19 @@ const CONTRACT = deepFreeze({
|
|
|
3374
3374
|
// one `headerTintColor` cannot express; an authored `topBar.textColor`
|
|
3375
3375
|
// drives both. `show` is deliberately absent -- it depends on the menu
|
|
3376
3376
|
// type, not the theme, and is a no-op on native.
|
|
3377
|
-
|
|
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",
|
|
3378
3390
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3379
3391
|
hooks: HOOKS,
|
|
3380
3392
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -3374,7 +3374,19 @@ const CONTRACT = deepFreeze({
|
|
|
3374
3374
|
// one `headerTintColor` cannot express; an authored `topBar.textColor`
|
|
3375
3375
|
// drives both. `show` is deliberately absent -- it depends on the menu
|
|
3376
3376
|
// type, not the theme, and is a no-op on native.
|
|
3377
|
-
|
|
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",
|
|
3378
3390
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3379
3391
|
hooks: HOOKS,
|
|
3380
3392
|
primitives: PRIMITIVES,
|
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/linter.cjs
CHANGED
|
@@ -1234,6 +1234,165 @@ function narrowManifestForFile(manifest, filename) {
|
|
|
1234
1234
|
return manifest;
|
|
1235
1235
|
}
|
|
1236
1236
|
|
|
1237
|
+
// sc-6455 — the design-token gate. A widget's look must come from the theme
|
|
1238
|
+
// (`useTheme()`) or from a `styleSchema` field the author can move. A literal
|
|
1239
|
+
// colour or font pinned in the source outranks BOTH permanently, so neither
|
|
1240
|
+
// the workspace theme nor Design -> Widget appearance can ever reach it.
|
|
1241
|
+
//
|
|
1242
|
+
// Deliberately narrow: colour literals, a `fontFamily` string, and a bare
|
|
1243
|
+
// numeric `fontSize`. Raw padding/margin/borderRadius numbers are NOT flagged
|
|
1244
|
+
// — measured layout legitimately carries them (a `flexBasis` cell width the
|
|
1245
|
+
// designer skill itself teaches), so a spacing rule would be noise that
|
|
1246
|
+
// devalues the three unambiguous ones.
|
|
1247
|
+
const _DESIGN_HEX_RE =
|
|
1248
|
+
/#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-fA-F])/;
|
|
1249
|
+
const _DESIGN_FUNC_COLOR_RE = /\b(?:rgba?|hsla?)\s*\(/;
|
|
1250
|
+
const _DESIGN_FONT_FAMILY_RE = /\bfontFamily\s*:\s*["'`]\s*\S/;
|
|
1251
|
+
const _DESIGN_FONT_SIZE_RE = /\bfontSize\s*:\s*-?\d/;
|
|
1252
|
+
// A line that already reads the theme or the author's style object is
|
|
1253
|
+
// resolving a DECLARED fallback, not pinning a look — `style.valueSize ?? 18`
|
|
1254
|
+
// is exactly what a styleSchema `default` is for. Colour literals are held to
|
|
1255
|
+
// the stricter bar: `theme.colors` always has a role to fall back to.
|
|
1256
|
+
const _DESIGN_TOKEN_REF_RE = /\btheme\s*[.?[]|\bstyle\s*[.?[]|\bprops\.style\b/;
|
|
1257
|
+
const _DESIGN_OK_RE = /appstudio-design-ok\s*:\s*\S/;
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Lines licensed by an `// appstudio-design-ok: <reason>` marker.
|
|
1261
|
+
*
|
|
1262
|
+
* The reason is mandatory — a bare marker licenses nothing. A TRAILING marker
|
|
1263
|
+
* covers only its own line; a marker on its OWN line covers the statement
|
|
1264
|
+
* that follows it, bracket-balanced, so a multi-line categorical palette needs
|
|
1265
|
+
* one marker rather than forty. Balance is counted on brace-blanked code so a
|
|
1266
|
+
* bracket inside a string cannot unbalance the span.
|
|
1267
|
+
*/
|
|
1268
|
+
function _designExemptLines(source) {
|
|
1269
|
+
const lines = source.split(/\r?\n/);
|
|
1270
|
+
const balance = _stripNonCode(source).split(/\r?\n/);
|
|
1271
|
+
const exempt = new Set();
|
|
1272
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1273
|
+
if (!_DESIGN_OK_RE.test(lines[i])) continue;
|
|
1274
|
+
exempt.add(i + 1);
|
|
1275
|
+
if (!/^\s*(?:\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1276
|
+
let depth = 0;
|
|
1277
|
+
let started = false;
|
|
1278
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
1279
|
+
exempt.add(j + 1);
|
|
1280
|
+
const text = balance[j] || "";
|
|
1281
|
+
if (!started && text.trim() === "") continue;
|
|
1282
|
+
for (const ch of text) {
|
|
1283
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1284
|
+
depth += 1;
|
|
1285
|
+
started = true;
|
|
1286
|
+
} else if (ch === ")" || ch === "]" || ch === "}") {
|
|
1287
|
+
depth -= 1;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (started && depth <= 0) break;
|
|
1291
|
+
if (!started) break;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
return exempt;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function _hardcodedDesignRules(source) {
|
|
1298
|
+
// Comments blanked, string CONTENT kept — a colour literal IS a string.
|
|
1299
|
+
const code = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
1300
|
+
const sourceLines = source.split(/\r?\n/);
|
|
1301
|
+
const exempt = _designExemptLines(source);
|
|
1302
|
+
const findings = [];
|
|
1303
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
1304
|
+
if (exempt.has(i + 1)) continue;
|
|
1305
|
+
const line = code[i];
|
|
1306
|
+
let what = null;
|
|
1307
|
+
if (_DESIGN_HEX_RE.test(line) || _DESIGN_FUNC_COLOR_RE.test(line)) {
|
|
1308
|
+
what = "a colour literal";
|
|
1309
|
+
} else if (_DESIGN_FONT_FAMILY_RE.test(line)) {
|
|
1310
|
+
what = "a font-family name";
|
|
1311
|
+
} else if (
|
|
1312
|
+
_DESIGN_FONT_SIZE_RE.test(line) &&
|
|
1313
|
+
!_DESIGN_TOKEN_REF_RE.test(line)
|
|
1314
|
+
) {
|
|
1315
|
+
what = "a pixel font size";
|
|
1316
|
+
}
|
|
1317
|
+
if (!what) continue;
|
|
1318
|
+
findings.push({
|
|
1319
|
+
rule: "no-hardcoded-design",
|
|
1320
|
+
severity: "warning",
|
|
1321
|
+
label:
|
|
1322
|
+
`${what} pinned in the source is beyond the reach of BOTH the ` +
|
|
1323
|
+
`workspace theme and the author's Style controls. Read it from ` +
|
|
1324
|
+
`useTheme() (theme.colors.* / theme.typography.*), or declare a ` +
|
|
1325
|
+
`styleSchema field and apply props.style.<field>. License a value ` +
|
|
1326
|
+
`that truly cannot be a token — a series palette, a video ` +
|
|
1327
|
+
`letterbox, a QR plate — with a preceding ` +
|
|
1328
|
+
`"// appstudio-design-ok: <reason>".`,
|
|
1329
|
+
line: i + 1,
|
|
1330
|
+
snippet: (sourceLines[i] || "").trim().slice(0, 200),
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
return findings;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* sc-6455 — a `styleSchema` field the bundle never mentions is a DEAD
|
|
1338
|
+
* control: the Studio renders it in the widget editor and again on
|
|
1339
|
+
* Design -> Widget appearance, the author moves it, and nothing happens.
|
|
1340
|
+
*
|
|
1341
|
+
* Bundle-level, NOT per-file. A split-impl widget legitimately reads a field
|
|
1342
|
+
* in `widget.web.jsx` and not in `widget.native.jsx`, so a per-file scan
|
|
1343
|
+
* would flag every one of them — which is also why this cannot live inside
|
|
1344
|
+
* `lintSource`.
|
|
1345
|
+
*
|
|
1346
|
+
* Deliberately conservative: the field counts as wired when its NAME appears
|
|
1347
|
+
* anywhere in any file's code, so `style.cardRadius`, `style["cardRadius"]`
|
|
1348
|
+
* and `const { cardRadius } = style` all satisfy it. That leaves a field
|
|
1349
|
+
* mentioned but misapplied uncaught, and catches the one that reaches an
|
|
1350
|
+
* author — declared, then forgotten.
|
|
1351
|
+
*
|
|
1352
|
+
* @param {object} manifest widget manifest (reads `styleSchema` only)
|
|
1353
|
+
* @param {object|string[]|string} files bundle sources — a `{ name: source }`
|
|
1354
|
+
* map, an array of sources, or one source string
|
|
1355
|
+
* @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
|
|
1356
|
+
*/
|
|
1357
|
+
function lintStyleWiring(manifest, files) {
|
|
1358
|
+
const empty = { ok: true, findings: [] };
|
|
1359
|
+
const schema = manifest && manifest.styleSchema;
|
|
1360
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1361
|
+
return empty;
|
|
1362
|
+
}
|
|
1363
|
+
let sources = [];
|
|
1364
|
+
if (typeof files === "string") sources = [files];
|
|
1365
|
+
else if (Array.isArray(files)) sources = files;
|
|
1366
|
+
else if (files && typeof files === "object") sources = Object.values(files);
|
|
1367
|
+
sources = sources.filter((s) => typeof s === "string" && s.length > 0);
|
|
1368
|
+
if (sources.length === 0) return empty;
|
|
1369
|
+
const code = sources
|
|
1370
|
+
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
1371
|
+
.join("\n");
|
|
1372
|
+
const findings = [];
|
|
1373
|
+
for (const field of Object.keys(schema)) {
|
|
1374
|
+
// A key that is not a bare identifier cannot be scanned by name; the
|
|
1375
|
+
// manifest validator rejects those anyway.
|
|
1376
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
1377
|
+
if (new RegExp(`\\b${field}\\b`).test(code)) continue;
|
|
1378
|
+
findings.push({
|
|
1379
|
+
rule: "style-field-unread",
|
|
1380
|
+
severity: "warning",
|
|
1381
|
+
label:
|
|
1382
|
+
`manifest.styleSchema declares "${field}" but the name appears ` +
|
|
1383
|
+
`nowhere in the source, so the control the Studio renders for it ` +
|
|
1384
|
+
`— per instance, and app-wide under Design -> Widget appearance — ` +
|
|
1385
|
+
`moves nothing. Read it from props.style and apply it ONLY when ` +
|
|
1386
|
+
`set, so the theme still shows through while the author has chosen ` +
|
|
1387
|
+
`none. If it is not styleable, drop it from styleSchema instead.`,
|
|
1388
|
+
line: 0,
|
|
1389
|
+
snippet: field,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
1393
|
+
return { ok: !hasErrors, findings };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1237
1396
|
function lintSource(source, options) {
|
|
1238
1397
|
if (typeof source !== "string") {
|
|
1239
1398
|
return {
|
|
@@ -1286,6 +1445,8 @@ function lintSource(source, options) {
|
|
|
1286
1445
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
1287
1446
|
// padding wraps the last grid column into an empty one.
|
|
1288
1447
|
findings.push(..._measuredPaddingRules(source));
|
|
1448
|
+
// sc-6455 — soft warning: a colour/font literal the theme can never reach.
|
|
1449
|
+
findings.push(..._hardcodedDesignRules(source));
|
|
1289
1450
|
findings.push(..._writeGatedOnUserRules(source));
|
|
1290
1451
|
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
1291
1452
|
findings.push(..._paymentCurrencyRules(source));
|
|
@@ -1306,4 +1467,9 @@ function lintSource(source, options) {
|
|
|
1306
1467
|
return { ok: !hasErrors, findings };
|
|
1307
1468
|
}
|
|
1308
1469
|
|
|
1309
|
-
module.exports = {
|
|
1470
|
+
module.exports = {
|
|
1471
|
+
lintSource,
|
|
1472
|
+
lintStyleWiring,
|
|
1473
|
+
bannedIdentifiers,
|
|
1474
|
+
narrowManifestForFile,
|
|
1475
|
+
};
|
package/dist/linter.js
CHANGED
|
@@ -1409,6 +1409,165 @@ export function narrowManifestForFile(manifest, filename) {
|
|
|
1409
1409
|
return manifest;
|
|
1410
1410
|
}
|
|
1411
1411
|
|
|
1412
|
+
// sc-6455 — the design-token gate. A widget's look must come from the theme
|
|
1413
|
+
// (`useTheme()`) or from a `styleSchema` field the author can move. A literal
|
|
1414
|
+
// colour or font pinned in the source outranks BOTH permanently, so neither
|
|
1415
|
+
// the workspace theme nor Design -> Widget appearance can ever reach it.
|
|
1416
|
+
//
|
|
1417
|
+
// Deliberately narrow: colour literals, a `fontFamily` string, and a bare
|
|
1418
|
+
// numeric `fontSize`. Raw padding/margin/borderRadius numbers are NOT flagged
|
|
1419
|
+
// — measured layout legitimately carries them (a `flexBasis` cell width the
|
|
1420
|
+
// designer skill itself teaches), so a spacing rule would be noise that
|
|
1421
|
+
// devalues the three unambiguous ones.
|
|
1422
|
+
const _DESIGN_HEX_RE =
|
|
1423
|
+
/#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-fA-F])/;
|
|
1424
|
+
const _DESIGN_FUNC_COLOR_RE = /\b(?:rgba?|hsla?)\s*\(/;
|
|
1425
|
+
const _DESIGN_FONT_FAMILY_RE = /\bfontFamily\s*:\s*["'`]\s*\S/;
|
|
1426
|
+
const _DESIGN_FONT_SIZE_RE = /\bfontSize\s*:\s*-?\d/;
|
|
1427
|
+
// A line that already reads the theme or the author's style object is
|
|
1428
|
+
// resolving a DECLARED fallback, not pinning a look — `style.valueSize ?? 18`
|
|
1429
|
+
// is exactly what a styleSchema `default` is for. Colour literals are held to
|
|
1430
|
+
// the stricter bar: `theme.colors` always has a role to fall back to.
|
|
1431
|
+
const _DESIGN_TOKEN_REF_RE = /\btheme\s*[.?[]|\bstyle\s*[.?[]|\bprops\.style\b/;
|
|
1432
|
+
const _DESIGN_OK_RE = /appstudio-design-ok\s*:\s*\S/;
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Lines licensed by an `// appstudio-design-ok: <reason>` marker.
|
|
1436
|
+
*
|
|
1437
|
+
* The reason is mandatory — a bare marker licenses nothing. A TRAILING marker
|
|
1438
|
+
* covers only its own line; a marker on its OWN line covers the statement
|
|
1439
|
+
* that follows it, bracket-balanced, so a multi-line categorical palette needs
|
|
1440
|
+
* one marker rather than forty. Balance is counted on brace-blanked code so a
|
|
1441
|
+
* bracket inside a string cannot unbalance the span.
|
|
1442
|
+
*/
|
|
1443
|
+
function _designExemptLines(source) {
|
|
1444
|
+
const lines = source.split(/\r?\n/);
|
|
1445
|
+
const balance = _stripNonCode(source).split(/\r?\n/);
|
|
1446
|
+
const exempt = new Set();
|
|
1447
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1448
|
+
if (!_DESIGN_OK_RE.test(lines[i])) continue;
|
|
1449
|
+
exempt.add(i + 1);
|
|
1450
|
+
if (!/^\s*(?:\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1451
|
+
let depth = 0;
|
|
1452
|
+
let started = false;
|
|
1453
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
1454
|
+
exempt.add(j + 1);
|
|
1455
|
+
const text = balance[j] || "";
|
|
1456
|
+
if (!started && text.trim() === "") continue;
|
|
1457
|
+
for (const ch of text) {
|
|
1458
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1459
|
+
depth += 1;
|
|
1460
|
+
started = true;
|
|
1461
|
+
} else if (ch === ")" || ch === "]" || ch === "}") {
|
|
1462
|
+
depth -= 1;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
if (started && depth <= 0) break;
|
|
1466
|
+
if (!started) break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return exempt;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function _hardcodedDesignRules(source) {
|
|
1473
|
+
// Comments blanked, string CONTENT kept — a colour literal IS a string.
|
|
1474
|
+
const code = _stripNonCode(source, { keepStrings: true }).split(/\r?\n/);
|
|
1475
|
+
const sourceLines = source.split(/\r?\n/);
|
|
1476
|
+
const exempt = _designExemptLines(source);
|
|
1477
|
+
const findings = [];
|
|
1478
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
1479
|
+
if (exempt.has(i + 1)) continue;
|
|
1480
|
+
const line = code[i];
|
|
1481
|
+
let what = null;
|
|
1482
|
+
if (_DESIGN_HEX_RE.test(line) || _DESIGN_FUNC_COLOR_RE.test(line)) {
|
|
1483
|
+
what = "a colour literal";
|
|
1484
|
+
} else if (_DESIGN_FONT_FAMILY_RE.test(line)) {
|
|
1485
|
+
what = "a font-family name";
|
|
1486
|
+
} else if (
|
|
1487
|
+
_DESIGN_FONT_SIZE_RE.test(line) &&
|
|
1488
|
+
!_DESIGN_TOKEN_REF_RE.test(line)
|
|
1489
|
+
) {
|
|
1490
|
+
what = "a pixel font size";
|
|
1491
|
+
}
|
|
1492
|
+
if (!what) continue;
|
|
1493
|
+
findings.push({
|
|
1494
|
+
rule: "no-hardcoded-design",
|
|
1495
|
+
severity: "warning",
|
|
1496
|
+
label:
|
|
1497
|
+
`${what} pinned in the source is beyond the reach of BOTH the ` +
|
|
1498
|
+
`workspace theme and the author's Style controls. Read it from ` +
|
|
1499
|
+
`useTheme() (theme.colors.* / theme.typography.*), or declare a ` +
|
|
1500
|
+
`styleSchema field and apply props.style.<field>. License a value ` +
|
|
1501
|
+
`that truly cannot be a token — a series palette, a video ` +
|
|
1502
|
+
`letterbox, a QR plate — with a preceding ` +
|
|
1503
|
+
`"// appstudio-design-ok: <reason>".`,
|
|
1504
|
+
line: i + 1,
|
|
1505
|
+
snippet: (sourceLines[i] || "").trim().slice(0, 200),
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
return findings;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* sc-6455 — a `styleSchema` field the bundle never mentions is a DEAD
|
|
1513
|
+
* control: the Studio renders it in the widget editor and again on
|
|
1514
|
+
* Design -> Widget appearance, the author moves it, and nothing happens.
|
|
1515
|
+
*
|
|
1516
|
+
* Bundle-level, NOT per-file. A split-impl widget legitimately reads a field
|
|
1517
|
+
* in `widget.web.jsx` and not in `widget.native.jsx`, so a per-file scan
|
|
1518
|
+
* would flag every one of them — which is also why this cannot live inside
|
|
1519
|
+
* `lintSource`.
|
|
1520
|
+
*
|
|
1521
|
+
* Deliberately conservative: the field counts as wired when its NAME appears
|
|
1522
|
+
* anywhere in any file's code, so `style.cardRadius`, `style["cardRadius"]`
|
|
1523
|
+
* and `const { cardRadius } = style` all satisfy it. That leaves a field
|
|
1524
|
+
* mentioned but misapplied uncaught, and catches the one that reaches an
|
|
1525
|
+
* author — declared, then forgotten.
|
|
1526
|
+
*
|
|
1527
|
+
* @param {object} manifest widget manifest (reads `styleSchema` only)
|
|
1528
|
+
* @param {object|string[]|string} files bundle sources — a `{ name: source }`
|
|
1529
|
+
* map, an array of sources, or one source string
|
|
1530
|
+
* @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
|
|
1531
|
+
*/
|
|
1532
|
+
export function lintStyleWiring(manifest, files) {
|
|
1533
|
+
const empty = { ok: true, findings: [] };
|
|
1534
|
+
const schema = manifest && manifest.styleSchema;
|
|
1535
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1536
|
+
return empty;
|
|
1537
|
+
}
|
|
1538
|
+
let sources = [];
|
|
1539
|
+
if (typeof files === "string") sources = [files];
|
|
1540
|
+
else if (Array.isArray(files)) sources = files;
|
|
1541
|
+
else if (files && typeof files === "object") sources = Object.values(files);
|
|
1542
|
+
sources = sources.filter((s) => typeof s === "string" && s.length > 0);
|
|
1543
|
+
if (sources.length === 0) return empty;
|
|
1544
|
+
const code = sources
|
|
1545
|
+
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
1546
|
+
.join("\n");
|
|
1547
|
+
const findings = [];
|
|
1548
|
+
for (const field of Object.keys(schema)) {
|
|
1549
|
+
// A key that is not a bare identifier cannot be scanned by name; the
|
|
1550
|
+
// manifest validator rejects those anyway.
|
|
1551
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) continue;
|
|
1552
|
+
if (new RegExp(`\\b${field}\\b`).test(code)) continue;
|
|
1553
|
+
findings.push({
|
|
1554
|
+
rule: "style-field-unread",
|
|
1555
|
+
severity: "warning",
|
|
1556
|
+
label:
|
|
1557
|
+
`manifest.styleSchema declares "${field}" but the name appears ` +
|
|
1558
|
+
`nowhere in the source, so the control the Studio renders for it ` +
|
|
1559
|
+
`— per instance, and app-wide under Design -> Widget appearance — ` +
|
|
1560
|
+
`moves nothing. Read it from props.style and apply it ONLY when ` +
|
|
1561
|
+
`set, so the theme still shows through while the author has chosen ` +
|
|
1562
|
+
`none. If it is not styleable, drop it from styleSchema instead.`,
|
|
1563
|
+
line: 0,
|
|
1564
|
+
snippet: field,
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
const hasErrors = findings.some((f) => f.severity !== "warning");
|
|
1568
|
+
return { ok: !hasErrors, findings };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1412
1571
|
export function lintSource(source, options) {
|
|
1413
1572
|
if (typeof source !== "string") {
|
|
1414
1573
|
return {
|
|
@@ -1466,6 +1625,8 @@ export function lintSource(source, options) {
|
|
|
1466
1625
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
1467
1626
|
// padding wraps the last grid column into an empty one.
|
|
1468
1627
|
findings.push(..._measuredPaddingRules(source));
|
|
1628
|
+
// sc-6455 — soft warning: a colour/font literal the theme can never reach.
|
|
1629
|
+
findings.push(..._hardcodedDesignRules(source));
|
|
1469
1630
|
findings.push(..._writeGatedOnUserRules(source));
|
|
1470
1631
|
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
1471
1632
|
findings.push(..._paymentCurrencyRules(source));
|
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.112.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/vetted-imports-audit.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-invites.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-page-url.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-measured-padding.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/corner-radius.test.js src/__tests__/theme-components-parity.test.js src/__tests__/navigation-parity.test.js src/__tests__/theme-depth-tokens.test.js src/__tests__/toast-host.test.js src/__tests__/hooks-domain-error-mapping.test.js src/__tests__/linter-datastore-error.test.js src/__tests__/linter-write-gating.test.js src/__tests__/hooks-speech-to-text.test.js src/__tests__/hooks-bound-columns.test.js src/__tests__/hooks-stable-query.test.js src/__tests__/hooks-can-write.test.js src/__tests__/widget-route.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|