@colixsystems/widget-sdk 0.138.0 → 0.140.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 +33 -3
- package/dist/cli.js +12 -3
- package/dist/contract.cjs +31 -1
- package/dist/contract.js +31 -1
- package/dist/host.d.ts +25 -0
- package/dist/host.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/linter.cjs +262 -5
- package/dist/linter.js +261 -5
- package/dist/theme-token-refs.js +198 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -70,7 +70,30 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
70
70
|
|
|
71
71
|
## Status
|
|
72
72
|
|
|
73
|
-
`v0.
|
|
73
|
+
`v0.140.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**.
|
|
74
|
+
|
|
75
|
+
### What's new in 0.140.0 (contract 1.108.0)
|
|
76
|
+
|
|
77
|
+
**Three rules make a generated widget's EDITABILITY checkable, and `lintEditability` joins the linter export (sc-7799).** The rules that already had a deterministic gate — the three basic style fields, `no-hardcoded-design`, `style-field-unread` — held every time. The ones left to prose drifted: `ui.group`, per-element coverage and author-editable copy were guidance only, so some widgets shipped a grouped Style section covering every element while others shipped the three basics and nothing else, and wording was routinely pinned in the source where no author could reach it.
|
|
78
|
+
|
|
79
|
+
- **`style-group-missing`** — a `styleSchema` field with no `ui.group`, or one filed under a catch-all name (`Advanced`, `Other`, `Misc`, `Style`, `Styles`, `General`) that rebuilds the flat list grouping exists to split. The three basics belong under `"Basics"`; each per-element field under the Title Case singular name of the element it styles (`"Card"`, `"Title"`, `"Row"`).
|
|
80
|
+
- **`style-elements-uncovered`** — a widget that renders several visual elements while its `styleSchema` carries nothing beyond `background`/`textColor`/`align`. A genuinely single-element widget is exempt by construction.
|
|
81
|
+
- **`text-literal-unexposed`** — a string literal sitting directly in a `<Text>`: copy that reaches neither the author nor the translation pipeline.
|
|
82
|
+
|
|
83
|
+
All three are `warning`, so `appstudio-widget lint` reports them and still exits 0 — the human author decides when to act. The AI widget agent publishes with no human in the loop and `_lintFindingsToPublishChecks` turns every finding into a publish check regardless of severity, so the same three **block** an agent publish and drive a repair turn, with no new severity plumbing.
|
|
84
|
+
|
|
85
|
+
`lintEditability(manifest, files)` is bundle-level like `lintStyleWiring`, and needs `--manifest` for the same reason. It counts rendered elements PER FILE, though: joining `widget.web.jsx` and `widget.native.jsx` would double every tag and call a single-`<Text>` split-impl widget multi-element.
|
|
86
|
+
|
|
87
|
+
### What's new in 0.139.0 (contract 1.108.0)
|
|
88
|
+
|
|
89
|
+
**An author can BIND a colour field to the workspace brand instead of freezing a hex (sc-7800).** A `type: "color"` field tracked the theme only while it was *unset*, through its `themeDefault` placeholder. The moment the author picked a colour it became a literal that outranked the theme forever, so "make this our brand colour" was inexpressible — the author had to type today's hex, and it went stale silently at the next rebrand.
|
|
90
|
+
|
|
91
|
+
A colour field's value may now be a **binding** — `"theme:colors.primary"` or `"theme:colors.secondary"` — which both hosts resolve to the workspace's current colour at render.
|
|
92
|
+
|
|
93
|
+
- **Nothing changes for a widget author.** You keep declaring `{ type: "color", … }` and reading the value off `props.style` / `props`. The host hands your code an ordinary colour string either way, so never branch on a binding, never parse the `theme:` prefix, and never add a "use theme colour" toggle beside a colour field — the Studio's picker already offers the choice, and a second control for it is a dead knob.
|
|
94
|
+
- **Only the two brand tokens are bindable**, listed on `CONTRACT.themeColorTokens`. The derived and contrast-paired tokens (`onPrimary`, `primarySoft`, `primaryStrong`, `loader`) are deliberately withheld: they are the host's to compute, and binding one half of a contrast pair produces unreadable text.
|
|
95
|
+
- **An unresolvable binding falls back to the field's `themeDefault`**, exactly as an unset field does — it never paints blank.
|
|
96
|
+
- **Fully additive.** Every colour already stored is a literal, is untouched, and renders byte-identically.
|
|
74
97
|
|
|
75
98
|
### What's new in 0.138.0 (contract 1.107.0)
|
|
76
99
|
|
|
@@ -1751,15 +1774,22 @@ npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.j
|
|
|
1751
1774
|
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.
|
|
1752
1775
|
|
|
1753
1776
|
```js
|
|
1754
|
-
import {
|
|
1777
|
+
import {
|
|
1778
|
+
lintSource,
|
|
1779
|
+
lintStyleWiring,
|
|
1780
|
+
lintEditability,
|
|
1781
|
+
} from "@colixsystems/widget-sdk/linter";
|
|
1755
1782
|
const report = lintSource(source, { manifest });
|
|
1756
1783
|
// Bundle-level — pass every file the widget ships:
|
|
1757
1784
|
const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
|
|
1785
|
+
const editability = lintEditability(manifest, { "widget.jsx": source });
|
|
1758
1786
|
```
|
|
1759
1787
|
|
|
1760
1788
|
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*.
|
|
1761
1789
|
|
|
1762
|
-
|
|
1790
|
+
Three more rules keep a widget EDITABLE once it ships (sc-7799), via the bundle-level `lintEditability(manifest, files)`. `style-group-missing` flags a `styleSchema` field with no `ui.group` — or one under a catch-all name — so a long Style section reads as labelled per-element fieldsets. `style-elements-uncovered` flags a multi-element widget whose Style section still only restyles the widget as a whole. `text-literal-unexposed` flags copy pinned in a `<Text>` — wording no control can change and no locale can translate. All three are warnings here and blocking for the AI widget agent; see *What's new in 0.140.0*.
|
|
1791
|
+
|
|
1792
|
+
Pass `--manifest` to enable `style-field-unread` and the three editability rules — they need the manifest, and every source at once so a split-impl widget's per-host reads are seen together.
|
|
1763
1793
|
|
|
1764
1794
|
`flex-basis-percent` flags a literal percentage `flexBasis` (sc-7274) — `"100%"`, `"50%"`, or the ternary a responsive cell writes. `flex-basis` sizes the MAIN axis, so in a column parent it asks for the parent's HEIGHT: under an ancestor that makes that height definite (a Grid cell stretches its child on the web Player) the child fills the container and, since a `View` never shrinks, overflows over everything below it. Say full-width with `width: "100%"` — correct in a column AND in a wrap row — and keep a NUMBER for a wrap threshold. A warning, because the percentage is right in a row and a text scan cannot see the parent.
|
|
1765
1795
|
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { pathToFileURL } from "node:url";
|
|
10
10
|
import { argv, exit, stderr, stdout } from "node:process";
|
|
11
|
-
import { lintSource, lintStyleWiring } from "./linter.js";
|
|
11
|
+
import { lintSource, lintStyleWiring, lintEditability } from "./linter.js";
|
|
12
12
|
import { startDevServer } from "./devserver.js";
|
|
13
13
|
|
|
14
14
|
function usage() {
|
|
@@ -106,9 +106,14 @@ async function runLint(rest) {
|
|
|
106
106
|
const wiring = manifest
|
|
107
107
|
? lintStyleWiring(manifest, files)
|
|
108
108
|
: { ok: true, findings: [] };
|
|
109
|
+
// sc-7799 — same manifest+bundle shape as the dead-control gate above.
|
|
110
|
+
const editability = manifest
|
|
111
|
+
? lintEditability(manifest, files)
|
|
112
|
+
: { ok: true, findings: [] };
|
|
109
113
|
const all = perFile
|
|
110
114
|
.flatMap(([, r]) => r.findings)
|
|
111
|
-
.concat(wiring.findings)
|
|
115
|
+
.concat(wiring.findings)
|
|
116
|
+
.concat(editability.findings);
|
|
112
117
|
if (all.length === 0) {
|
|
113
118
|
stdout.write(`${label}: clean\n`);
|
|
114
119
|
exit(0);
|
|
@@ -116,7 +121,7 @@ async function runLint(rest) {
|
|
|
116
121
|
// sc-3493 — a warning-severity finding used to be swallowed: `ok` stays true
|
|
117
122
|
// for warnings, so the CLI printed "clean" and dropped them. A warning nobody
|
|
118
123
|
// sees is pointless. Report every finding; only errors change the exit code.
|
|
119
|
-
const ok = perFile.every(([, r]) => r.ok) && wiring.ok;
|
|
124
|
+
const ok = perFile.every(([, r]) => r.ok) && wiring.ok && editability.ok;
|
|
120
125
|
const errors = all.filter((f) => f.severity !== "warning").length;
|
|
121
126
|
const stream = ok ? stdout : stderr;
|
|
122
127
|
stream.write(
|
|
@@ -131,6 +136,10 @@ async function runLint(rest) {
|
|
|
131
136
|
stream.write(`manifest.styleSchema:\n`);
|
|
132
137
|
writeFindings(wiring.findings, stream);
|
|
133
138
|
}
|
|
139
|
+
if (editability.findings.length > 0) {
|
|
140
|
+
stream.write(`manifest.editability:\n`);
|
|
141
|
+
writeFindings(editability.findings, stream);
|
|
142
|
+
}
|
|
134
143
|
exit(ok ? 0 : 1);
|
|
135
144
|
}
|
|
136
145
|
|
package/dist/contract.cjs
CHANGED
|
@@ -121,6 +121,26 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
121
121
|
widgetStyles: Object.freeze({}),
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
// sc-7800 -- the colour tokens an AUTHOR may BIND a `type: "color"` field to.
|
|
125
|
+
//
|
|
126
|
+
// A colour field already tracked the theme while unset (its `themeDefault`
|
|
127
|
+
// placeholder); the moment it was set it froze to a hex and stopped following a
|
|
128
|
+
// rebrand. A bound field stores `theme:<path>` instead, and the hosts resolve it
|
|
129
|
+
// per render, so the author's INTENT ("our brand colour") survives the rebrand.
|
|
130
|
+
//
|
|
131
|
+
// Deliberately only the two BRAND tokens. The derived and contrast-paired
|
|
132
|
+
// tokens -- `onPrimary`, `primarySoft`, `primaryStrong`, `loader` -- are the
|
|
133
|
+
// host's to compute: offering them would let an author pick one half of a
|
|
134
|
+
// contrast pair and produce unreadable text with no way to see it was their
|
|
135
|
+
// choice. `label` is the author-facing name; the Studio picker, the host
|
|
136
|
+
// resolver and the AI widget agent all read this ONE list, so what may be
|
|
137
|
+
// written and what will resolve cannot drift.
|
|
138
|
+
const THEME_COLOR_TOKENS = Object.freeze([
|
|
139
|
+
Object.freeze({ path: "colors.primary", label: "Primary" }),
|
|
140
|
+
Object.freeze({ path: "colors.secondary", label: "Secondary" }),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
|
|
124
144
|
// REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
|
|
125
145
|
// single lever: remapping `primaryColor` to recolour "the buttons" recolours
|
|
126
146
|
// every element that shares it. A theme may therefore also carry
|
|
@@ -3947,7 +3967,16 @@ const CONTRACT = deepFreeze({
|
|
|
3947
3967
|
// ON the part otherwise, since expo-file-system's File is NOT
|
|
3948
3968
|
// `instanceof Blob` at runtime and Expo reads the name off the part.
|
|
3949
3969
|
// One upload path on both hosts, as before.
|
|
3950
|
-
|
|
3970
|
+
// 1.108.0: additive (sc-7800) -- `themeColorTokens`: the brand colour tokens
|
|
3971
|
+
// an author may BIND a `type: "color"` field to, plus the `theme:<path>`
|
|
3972
|
+
// value grammar and the `resolveThemeTokenRefs` host resolver behind it.
|
|
3973
|
+
// A set colour field used to freeze to a hex literal and stop following
|
|
3974
|
+
// the workspace theme, so "make this our brand colour" was inexpressible
|
|
3975
|
+
// and every picked colour went stale on the next rebrand. Purely additive:
|
|
3976
|
+
// a stored hex is untouched and still renders byte-identically, and an
|
|
3977
|
+
// unresolvable binding falls back to the field's `themeDefault` rather
|
|
3978
|
+
// than painting blank.
|
|
3979
|
+
version: "1.108.0",
|
|
3951
3980
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3952
3981
|
hooks: HOOKS,
|
|
3953
3982
|
primitives: PRIMITIVES,
|
|
@@ -3958,6 +3987,7 @@ const CONTRACT = deepFreeze({
|
|
|
3958
3987
|
actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
|
|
3959
3988
|
actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
|
|
3960
3989
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
3990
|
+
themeColorTokens: THEME_COLOR_TOKENS,
|
|
3961
3991
|
themeComponents: THEME_COMPONENTS,
|
|
3962
3992
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
3963
3993
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
package/dist/contract.js
CHANGED
|
@@ -121,6 +121,26 @@ const DEFAULT_THEME_TOKENS = Object.freeze({
|
|
|
121
121
|
widgetStyles: Object.freeze({}),
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
// sc-7800 -- the colour tokens an AUTHOR may BIND a `type: "color"` field to.
|
|
125
|
+
//
|
|
126
|
+
// A colour field already tracked the theme while unset (its `themeDefault`
|
|
127
|
+
// placeholder); the moment it was set it froze to a hex and stopped following a
|
|
128
|
+
// rebrand. A bound field stores `theme:<path>` instead, and the hosts resolve it
|
|
129
|
+
// per render, so the author's INTENT ("our brand colour") survives the rebrand.
|
|
130
|
+
//
|
|
131
|
+
// Deliberately only the two BRAND tokens. The derived and contrast-paired
|
|
132
|
+
// tokens -- `onPrimary`, `primarySoft`, `primaryStrong`, `loader` -- are the
|
|
133
|
+
// host's to compute: offering them would let an author pick one half of a
|
|
134
|
+
// contrast pair and produce unreadable text with no way to see it was their
|
|
135
|
+
// choice. `label` is the author-facing name; the Studio picker, the host
|
|
136
|
+
// resolver and the AI widget agent all read this ONE list, so what may be
|
|
137
|
+
// written and what will resolve cannot drift.
|
|
138
|
+
const THEME_COLOR_TOKENS = Object.freeze([
|
|
139
|
+
Object.freeze({ path: "colors.primary", label: "Primary" }),
|
|
140
|
+
Object.freeze({ path: "colors.secondary", label: "Secondary" }),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
|
|
124
144
|
// REQ-THEME-15 (sc-1497) — per-component style tokens. The global palette is a
|
|
125
145
|
// single lever: remapping `primaryColor` to recolour "the buttons" recolours
|
|
126
146
|
// every element that shares it. A theme may therefore also carry
|
|
@@ -3947,7 +3967,16 @@ const CONTRACT = deepFreeze({
|
|
|
3947
3967
|
// ON the part otherwise, since expo-file-system's File is NOT
|
|
3948
3968
|
// `instanceof Blob` at runtime and Expo reads the name off the part.
|
|
3949
3969
|
// One upload path on both hosts, as before.
|
|
3950
|
-
|
|
3970
|
+
// 1.108.0: additive (sc-7800) -- `themeColorTokens`: the brand colour tokens
|
|
3971
|
+
// an author may BIND a `type: "color"` field to, plus the `theme:<path>`
|
|
3972
|
+
// value grammar and the `resolveThemeTokenRefs` host resolver behind it.
|
|
3973
|
+
// A set colour field used to freeze to a hex literal and stop following
|
|
3974
|
+
// the workspace theme, so "make this our brand colour" was inexpressible
|
|
3975
|
+
// and every picked colour went stale on the next rebrand. Purely additive:
|
|
3976
|
+
// a stored hex is untouched and still renders byte-identically, and an
|
|
3977
|
+
// unresolvable binding falls back to the field's `themeDefault` rather
|
|
3978
|
+
// than painting blank.
|
|
3979
|
+
version: "1.108.0",
|
|
3951
3980
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3952
3981
|
hooks: HOOKS,
|
|
3953
3982
|
primitives: PRIMITIVES,
|
|
@@ -3958,6 +3987,7 @@ const CONTRACT = deepFreeze({
|
|
|
3958
3987
|
actionScriptGlobals: ACTION_SCRIPT_GLOBALS,
|
|
3959
3988
|
actionScriptMaxBytes: ACTION_SCRIPT_MAX_BYTES,
|
|
3960
3989
|
themeTokens: DEFAULT_THEME_TOKENS,
|
|
3990
|
+
themeColorTokens: THEME_COLOR_TOKENS,
|
|
3961
3991
|
themeComponents: THEME_COMPONENTS,
|
|
3962
3992
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
3963
3993
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
package/dist/host.d.ts
CHANGED
|
@@ -334,3 +334,28 @@ export interface LoginDesign {
|
|
|
334
334
|
|
|
335
335
|
/** The sign-in screen's authored design, or null when nothing is configured. */
|
|
336
336
|
export function normaliseLoginDesign(raw: unknown): LoginDesign | null;
|
|
337
|
+
|
|
338
|
+
/** sc-7800 — true when a stored value is a `theme:<path>` colour binding. */
|
|
339
|
+
export function isThemeTokenRef(value: unknown): boolean;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Resolve one `theme:<path>` binding against a resolved widget theme. Returns
|
|
343
|
+
* `undefined` for a non-binding, a token outside `CONTRACT.themeColorTokens`,
|
|
344
|
+
* or a path that does not resolve to a colour.
|
|
345
|
+
*/
|
|
346
|
+
export function resolveThemeTokenRef(
|
|
347
|
+
value: unknown,
|
|
348
|
+
theme: unknown,
|
|
349
|
+
): string | undefined;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Replace every bound `type: "color"` field on a widget's props — both the
|
|
353
|
+
* top-level `propertySchema` fields and the `styleSchema` fields under
|
|
354
|
+
* `props.style` — with the colour its token currently resolves to. Returns the
|
|
355
|
+
* same reference when nothing is bound.
|
|
356
|
+
*/
|
|
357
|
+
export function resolveThemeTokenRefs<T>(
|
|
358
|
+
props: T,
|
|
359
|
+
theme: unknown,
|
|
360
|
+
schemas?: { propertySchema?: unknown; styleSchema?: unknown },
|
|
361
|
+
): T;
|
package/dist/host.js
CHANGED
|
@@ -23,6 +23,17 @@ export {
|
|
|
23
23
|
applyThemeComponentStyle,
|
|
24
24
|
} from "./theme-components.js";
|
|
25
25
|
|
|
26
|
+
// sc-7800: the theme-token colour bindings. `resolveThemeTokenRefs` replaces a
|
|
27
|
+
// bound `type: "color"` field with the colour its token currently resolves to,
|
|
28
|
+
// at the SAME render boundary on both hosts — so a rebrand repaints the Player,
|
|
29
|
+
// the Canvas and the exported app alike, and the widget still receives a plain
|
|
30
|
+
// colour string.
|
|
31
|
+
export {
|
|
32
|
+
isThemeTokenRef,
|
|
33
|
+
resolveThemeTokenRef,
|
|
34
|
+
resolveThemeTokenRefs,
|
|
35
|
+
} from "./theme-token-refs.js";
|
|
36
|
+
|
|
26
37
|
// REQ-THEME-SURFACE: the surface-token derivation both hosts apply per painted
|
|
27
38
|
// surface — the page background and every container that carries its own fill.
|
|
28
39
|
// Host plumbing, deliberately off the author entry point: a widget reads the
|
package/dist/index.d.ts
CHANGED
|
@@ -2700,6 +2700,8 @@ export interface AiWidgetContract {
|
|
|
2700
2700
|
readonly manifestCategories: ReadonlyArray<string>;
|
|
2701
2701
|
readonly manifestPlatforms: ReadonlyArray<string>;
|
|
2702
2702
|
readonly themeTokens: ThemeTokens;
|
|
2703
|
+
/** sc-7800 — the brand colour tokens a `type: "color"` field may bind to. */
|
|
2704
|
+
readonly themeColorTokens: ReadonlyArray<{ path: string; label: string }>;
|
|
2703
2705
|
readonly widgetContextShape: Readonly<
|
|
2704
2706
|
Record<
|
|
2705
2707
|
string,
|
package/dist/linter.cjs
CHANGED
|
@@ -1438,11 +1438,7 @@ function lintStyleWiring(manifest, files) {
|
|
|
1438
1438
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1439
1439
|
return empty;
|
|
1440
1440
|
}
|
|
1441
|
-
|
|
1442
|
-
if (typeof files === "string") sources = [files];
|
|
1443
|
-
else if (Array.isArray(files)) sources = files;
|
|
1444
|
-
else if (files && typeof files === "object") sources = Object.values(files);
|
|
1445
|
-
sources = sources.filter((s) => typeof s === "string" && s.length > 0);
|
|
1441
|
+
const sources = _bundleSources(files);
|
|
1446
1442
|
if (sources.length === 0) return empty;
|
|
1447
1443
|
const code = sources
|
|
1448
1444
|
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
@@ -1471,6 +1467,266 @@ function lintStyleWiring(manifest, files) {
|
|
|
1471
1467
|
return { ok: !hasErrors, findings };
|
|
1472
1468
|
}
|
|
1473
1469
|
|
|
1470
|
+
// sc-7799 — a group name that groups everything defeats the rule: one
|
|
1471
|
+
// catch-all fieldset reads exactly like the flat list it replaced.
|
|
1472
|
+
const _GENERIC_STYLE_GROUPS = new Set([
|
|
1473
|
+
"advanced",
|
|
1474
|
+
"other",
|
|
1475
|
+
"misc",
|
|
1476
|
+
"style",
|
|
1477
|
+
"styles",
|
|
1478
|
+
"general",
|
|
1479
|
+
]);
|
|
1480
|
+
|
|
1481
|
+
// The three the basic-style gate mandates; they restyle the widget as a whole.
|
|
1482
|
+
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
1483
|
+
"background",
|
|
1484
|
+
"textColor",
|
|
1485
|
+
"align",
|
|
1486
|
+
]);
|
|
1487
|
+
|
|
1488
|
+
// A second element an author would style apart from the text. Pressable and
|
|
1489
|
+
// Button are deliberately absent: a touch target wrapping the only label is
|
|
1490
|
+
// one element, not two.
|
|
1491
|
+
const _SECOND_ELEMENT_TAGS = ["Image", "TextInput", "FlatList", "SectionList"];
|
|
1492
|
+
|
|
1493
|
+
const _SEPARATOR_GLYPHS = new Set(["-", "–", "—", "•", "·", "…", "|", "/"]);
|
|
1494
|
+
|
|
1495
|
+
// Names ride the finding; the remainder is counted, so one finding stays
|
|
1496
|
+
// inside the agent's per-message budget however many fields are wrong.
|
|
1497
|
+
const _EDITABILITY_LIST_CAP = 3;
|
|
1498
|
+
|
|
1499
|
+
// Tighter for copy: each entry carries the text AND its line, so six of them
|
|
1500
|
+
// push the message past the report's 300-char truncation.
|
|
1501
|
+
const _LITERAL_LIST_CAP = 2;
|
|
1502
|
+
|
|
1503
|
+
// Shared by both bundle-level gates — one normaliser, not two.
|
|
1504
|
+
function _bundleSources(files) {
|
|
1505
|
+
let sources = [];
|
|
1506
|
+
if (typeof files === "string") sources = [files];
|
|
1507
|
+
else if (Array.isArray(files)) sources = files;
|
|
1508
|
+
else if (files && typeof files === "object") sources = Object.values(files);
|
|
1509
|
+
return sources.filter((s) => typeof s === "string" && s.length > 0);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function _plainObject(value) {
|
|
1513
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Both the count and each entry are bounded: the backend wraps the label in
|
|
1517
|
+
// `[manifest.editability] … (line N: snippet)` and truncates at 300, so an
|
|
1518
|
+
// unbounded name list loses the tail of the advice that follows it.
|
|
1519
|
+
function _clip(text, max) {
|
|
1520
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function _quoteList(names) {
|
|
1524
|
+
const shown = names.slice(0, _EDITABILITY_LIST_CAP).map((n) => _clip(n, 16));
|
|
1525
|
+
const more = names.length - shown.length;
|
|
1526
|
+
return shown.join(", ") + (more > 0 ? `, …(+${more} more)` : "");
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Copy worth a control: a separator glyph or a bare number is chrome.
|
|
1530
|
+
function _isAuthoredCopy(text) {
|
|
1531
|
+
if (text.length < 2) return false;
|
|
1532
|
+
if (!/[A-Za-z]/.test(text)) return false;
|
|
1533
|
+
return !_SEPARATOR_GLYPHS.has(text);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
|
|
1537
|
+
// inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
|
|
1538
|
+
// as the tag end reports the handler body as copy.
|
|
1539
|
+
function _tagEnd(code, from) {
|
|
1540
|
+
let depth = 0;
|
|
1541
|
+
let quote = null;
|
|
1542
|
+
for (let i = from; i < code.length; i++) {
|
|
1543
|
+
const ch = code[i];
|
|
1544
|
+
if (quote) {
|
|
1545
|
+
if (ch === "\\") i++;
|
|
1546
|
+
else if (ch === quote) quote = null;
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1549
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
1550
|
+
else if (ch === "{") depth++;
|
|
1551
|
+
else if (ch === "}") depth--;
|
|
1552
|
+
else if (ch === ">" && depth <= 0) return i;
|
|
1553
|
+
}
|
|
1554
|
+
return -1;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// The balanced `{…}` starting at `from`, or null.
|
|
1558
|
+
function _bracedSpan(code, from) {
|
|
1559
|
+
if (code[from] !== "{") return null;
|
|
1560
|
+
let depth = 0;
|
|
1561
|
+
let quote = null;
|
|
1562
|
+
for (let i = from; i < code.length; i++) {
|
|
1563
|
+
const ch = code[i];
|
|
1564
|
+
if (quote) {
|
|
1565
|
+
if (ch === "\\") i++;
|
|
1566
|
+
else if (ch === quote) quote = null;
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
1570
|
+
else if (ch === "{") depth++;
|
|
1571
|
+
else if (ch === "}") {
|
|
1572
|
+
depth--;
|
|
1573
|
+
if (depth === 0) return code.slice(from + 1, i);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return null;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
|
|
1580
|
+
// NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
|
|
1581
|
+
// A braced expression carrying an identifier is left alone: it may already be
|
|
1582
|
+
// a prop, and under-firing is right for a gate that blocks a publish.
|
|
1583
|
+
function _textLiteralOccurrences(codes, masks) {
|
|
1584
|
+
const byText = new Map();
|
|
1585
|
+
for (let f = 0; f < codes.length; f++) {
|
|
1586
|
+
const code = codes[f];
|
|
1587
|
+
// Locate tags on the string-blanked mask, so markup HELD IN a string is
|
|
1588
|
+
// not read as markup. Both masks preserve offsets, so the child is read
|
|
1589
|
+
// back from the kept-strings source at the very same index.
|
|
1590
|
+
const mask = masks[f];
|
|
1591
|
+
const re = /<Text\b/g;
|
|
1592
|
+
let m;
|
|
1593
|
+
while ((m = re.exec(mask)) !== null) {
|
|
1594
|
+
const end = _tagEnd(mask, m.index);
|
|
1595
|
+
if (end === -1) continue;
|
|
1596
|
+
let i = end + 1;
|
|
1597
|
+
while (i < code.length && /\s/.test(code[i])) i++;
|
|
1598
|
+
let text = null;
|
|
1599
|
+
if (code[i] === "{") {
|
|
1600
|
+
const inner = _bracedSpan(code, i);
|
|
1601
|
+
const lit =
|
|
1602
|
+
inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
|
|
1603
|
+
// A template carrying `${…}` interpolates a value, so it is not a literal.
|
|
1604
|
+
if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
|
|
1605
|
+
} else {
|
|
1606
|
+
const child = code.slice(i).match(/^([^<>{}\n]+)/);
|
|
1607
|
+
if (child) text = child[1];
|
|
1608
|
+
}
|
|
1609
|
+
if (text === null) continue;
|
|
1610
|
+
text = text.trim();
|
|
1611
|
+
if (!_isAuthoredCopy(text) || byText.has(text)) continue;
|
|
1612
|
+
// Split-impl carries the same copy twice; report the string, not the file.
|
|
1613
|
+
byText.set(text, code.slice(0, i).split("\n").length);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
return [...byText.entries()].map(([text, line]) => ({ text, line }));
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Per FILE: joining widget.web.jsx and widget.native.jsx doubles every tag.
|
|
1620
|
+
// A second element must be a DISTINCT visual kind beside the text — two
|
|
1621
|
+
// `<Text>` tags are as often a loading branch and its content as a title and
|
|
1622
|
+
// a body, and a blocking gate may not guess.
|
|
1623
|
+
function _rendersSeveralElements(code) {
|
|
1624
|
+
if (!/<Text[\s>/]/.test(code)) return false;
|
|
1625
|
+
return _SECOND_ELEMENT_TAGS.some((tag) =>
|
|
1626
|
+
new RegExp(`<${tag}[\\s>/]`).test(code),
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function _styleGroupFindings(manifest) {
|
|
1631
|
+
const schema = _plainObject(manifest) ? manifest.styleSchema : null;
|
|
1632
|
+
if (!_plainObject(schema)) return [];
|
|
1633
|
+
const bad = Object.keys(schema).filter((field) => {
|
|
1634
|
+
const def = schema[field];
|
|
1635
|
+
const group = _plainObject(def) && _plainObject(def.ui) ? def.ui.group : "";
|
|
1636
|
+
if (typeof group !== "string" || group.trim().length === 0) return true;
|
|
1637
|
+
return _GENERIC_STYLE_GROUPS.has(group.trim().toLowerCase());
|
|
1638
|
+
});
|
|
1639
|
+
if (bad.length === 0) return [];
|
|
1640
|
+
return [
|
|
1641
|
+
{
|
|
1642
|
+
rule: "style-group-missing",
|
|
1643
|
+
severity: "warning",
|
|
1644
|
+
label:
|
|
1645
|
+
`styleSchema fields with no usable ui.group: ${_quoteList(
|
|
1646
|
+
bad.map((f) => `"${f}"`),
|
|
1647
|
+
)}. Group EVERY field — basics under "Basics", each per-element ` +
|
|
1648
|
+
`field under the element it styles. A catch-all name is not a group.`,
|
|
1649
|
+
line: 0,
|
|
1650
|
+
snippet: _clip(bad[0], 12),
|
|
1651
|
+
},
|
|
1652
|
+
];
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function _styleCoverageFindings(manifest, codes) {
|
|
1656
|
+
const schema = _plainObject(manifest) ? manifest.styleSchema : null;
|
|
1657
|
+
if (!_plainObject(schema)) return [];
|
|
1658
|
+
const fields = Object.keys(schema);
|
|
1659
|
+
if (fields.length === 0) return [];
|
|
1660
|
+
if (fields.some((f) => !_WHOLE_WIDGET_STYLE_FIELDS.has(f))) return [];
|
|
1661
|
+
if (!codes.some(_rendersSeveralElements)) return [];
|
|
1662
|
+
return [
|
|
1663
|
+
{
|
|
1664
|
+
rule: "style-elements-uncovered",
|
|
1665
|
+
severity: "warning",
|
|
1666
|
+
label:
|
|
1667
|
+
`The widget renders more than one visual element but its styleSchema ` +
|
|
1668
|
+
`stops at background/textColor/align. Add 2-4 grouped per-element ` +
|
|
1669
|
+
`fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
|
|
1670
|
+
`each applied ONLY when set.`,
|
|
1671
|
+
line: 0,
|
|
1672
|
+
snippet: "",
|
|
1673
|
+
},
|
|
1674
|
+
];
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function _textLiteralFindings(occurrences) {
|
|
1678
|
+
if (occurrences.length === 0) return [];
|
|
1679
|
+
// Each entry is bounded so a long line of copy cannot push the whole
|
|
1680
|
+
// message past the report's truncation and take the line numbers with it.
|
|
1681
|
+
const listed = occurrences
|
|
1682
|
+
.slice(0, _LITERAL_LIST_CAP)
|
|
1683
|
+
.map((o) => `"${_clip(o.text, 16)}" (line ${o.line})`);
|
|
1684
|
+
const more = occurrences.length - listed.length;
|
|
1685
|
+
return [
|
|
1686
|
+
{
|
|
1687
|
+
rule: "text-literal-unexposed",
|
|
1688
|
+
severity: "warning",
|
|
1689
|
+
label:
|
|
1690
|
+
`Copy pinned in a <Text> reaches neither author nor translation: ` +
|
|
1691
|
+
`${listed.join(", ")}${more > 0 ? `, …(+${more} more)` : ""}. ` +
|
|
1692
|
+
`Declare each as a propertySchema string defaulting to that text ` +
|
|
1693
|
+
`and render the prop; fixed chrome uses t().`,
|
|
1694
|
+
line: occurrences[0].line,
|
|
1695
|
+
snippet: _clip(occurrences[0].text, 12),
|
|
1696
|
+
},
|
|
1697
|
+
];
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* sc-7799 — the editability gates: what an author can still change once the
|
|
1702
|
+
* widget ships. Bundle-level like `lintStyleWiring`, and for the same reason
|
|
1703
|
+
* — one manifest serves both halves of a split-impl widget.
|
|
1704
|
+
*
|
|
1705
|
+
* Each rule emits ONE consolidated finding, because the agent's draft-findings
|
|
1706
|
+
* report keeps only the first dozen checks and truncates each message: a
|
|
1707
|
+
* per-field finding on a 14-field manifest would evict the render-time crash
|
|
1708
|
+
* sitting behind it.
|
|
1709
|
+
*
|
|
1710
|
+
* @param {object} manifest widget manifest (`styleSchema` only)
|
|
1711
|
+
* @param {object|string[]|string} files bundle sources — a `{ name: source }`
|
|
1712
|
+
* map, an array of sources, or one source string
|
|
1713
|
+
* @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
|
|
1714
|
+
*/
|
|
1715
|
+
function lintEditability(manifest, files) {
|
|
1716
|
+
const sources = _bundleSources(files);
|
|
1717
|
+
if (sources.length === 0) return { ok: true, findings: [] };
|
|
1718
|
+
const codes = sources.map((s) => _stripNonCode(s, { keepStrings: true }));
|
|
1719
|
+
const masks = sources.map((s) => _stripNonCode(s, { keepStrings: false }));
|
|
1720
|
+
const findings = [
|
|
1721
|
+
..._styleGroupFindings(manifest),
|
|
1722
|
+
..._styleCoverageFindings(manifest, codes),
|
|
1723
|
+
..._textLiteralFindings(_textLiteralOccurrences(codes, masks)),
|
|
1724
|
+
];
|
|
1725
|
+
// Every rule here is a warning by construction: the CLI must stay exit-0 for
|
|
1726
|
+
// a human author, while the agent blocks on any finding regardless.
|
|
1727
|
+
return { ok: true, findings };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1474
1730
|
function lintSource(source, options) {
|
|
1475
1731
|
if (typeof source !== "string") {
|
|
1476
1732
|
return {
|
|
@@ -1553,6 +1809,7 @@ function lintSource(source, options) {
|
|
|
1553
1809
|
module.exports = {
|
|
1554
1810
|
lintSource,
|
|
1555
1811
|
lintStyleWiring,
|
|
1812
|
+
lintEditability,
|
|
1556
1813
|
bannedIdentifiers,
|
|
1557
1814
|
narrowManifestForFile,
|
|
1558
1815
|
};
|
package/dist/linter.js
CHANGED
|
@@ -1509,11 +1509,7 @@ export function lintStyleWiring(manifest, files) {
|
|
|
1509
1509
|
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
1510
1510
|
return empty;
|
|
1511
1511
|
}
|
|
1512
|
-
|
|
1513
|
-
if (typeof files === "string") sources = [files];
|
|
1514
|
-
else if (Array.isArray(files)) sources = files;
|
|
1515
|
-
else if (files && typeof files === "object") sources = Object.values(files);
|
|
1516
|
-
sources = sources.filter((s) => typeof s === "string" && s.length > 0);
|
|
1512
|
+
const sources = _bundleSources(files);
|
|
1517
1513
|
if (sources.length === 0) return empty;
|
|
1518
1514
|
const code = sources
|
|
1519
1515
|
.map((s) => _stripNonCode(s, { keepStrings: true }))
|
|
@@ -1542,6 +1538,266 @@ export function lintStyleWiring(manifest, files) {
|
|
|
1542
1538
|
return { ok: !hasErrors, findings };
|
|
1543
1539
|
}
|
|
1544
1540
|
|
|
1541
|
+
// sc-7799 — a group name that groups everything defeats the rule: one
|
|
1542
|
+
// catch-all fieldset reads exactly like the flat list it replaced.
|
|
1543
|
+
const _GENERIC_STYLE_GROUPS = new Set([
|
|
1544
|
+
"advanced",
|
|
1545
|
+
"other",
|
|
1546
|
+
"misc",
|
|
1547
|
+
"style",
|
|
1548
|
+
"styles",
|
|
1549
|
+
"general",
|
|
1550
|
+
]);
|
|
1551
|
+
|
|
1552
|
+
// The three the basic-style gate mandates; they restyle the widget as a whole.
|
|
1553
|
+
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
1554
|
+
"background",
|
|
1555
|
+
"textColor",
|
|
1556
|
+
"align",
|
|
1557
|
+
]);
|
|
1558
|
+
|
|
1559
|
+
// A second element an author would style apart from the text. Pressable and
|
|
1560
|
+
// Button are deliberately absent: a touch target wrapping the only label is
|
|
1561
|
+
// one element, not two.
|
|
1562
|
+
const _SECOND_ELEMENT_TAGS = ["Image", "TextInput", "FlatList", "SectionList"];
|
|
1563
|
+
|
|
1564
|
+
const _SEPARATOR_GLYPHS = new Set(["-", "–", "—", "•", "·", "…", "|", "/"]);
|
|
1565
|
+
|
|
1566
|
+
// Names ride the finding; the remainder is counted, so one finding stays
|
|
1567
|
+
// inside the agent's per-message budget however many fields are wrong.
|
|
1568
|
+
const _EDITABILITY_LIST_CAP = 3;
|
|
1569
|
+
|
|
1570
|
+
// Tighter for copy: each entry carries the text AND its line, so six of them
|
|
1571
|
+
// push the message past the report's 300-char truncation.
|
|
1572
|
+
const _LITERAL_LIST_CAP = 2;
|
|
1573
|
+
|
|
1574
|
+
// Shared by both bundle-level gates — one normaliser, not two.
|
|
1575
|
+
function _bundleSources(files) {
|
|
1576
|
+
let sources = [];
|
|
1577
|
+
if (typeof files === "string") sources = [files];
|
|
1578
|
+
else if (Array.isArray(files)) sources = files;
|
|
1579
|
+
else if (files && typeof files === "object") sources = Object.values(files);
|
|
1580
|
+
return sources.filter((s) => typeof s === "string" && s.length > 0);
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
function _plainObject(value) {
|
|
1584
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Both the count and each entry are bounded: the backend wraps the label in
|
|
1588
|
+
// `[manifest.editability] … (line N: snippet)` and truncates at 300, so an
|
|
1589
|
+
// unbounded name list loses the tail of the advice that follows it.
|
|
1590
|
+
function _clip(text, max) {
|
|
1591
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function _quoteList(names) {
|
|
1595
|
+
const shown = names.slice(0, _EDITABILITY_LIST_CAP).map((n) => _clip(n, 16));
|
|
1596
|
+
const more = names.length - shown.length;
|
|
1597
|
+
return shown.join(", ") + (more > 0 ? `, …(+${more} more)` : "");
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// Copy worth a control: a separator glyph or a bare number is chrome.
|
|
1601
|
+
function _isAuthoredCopy(text) {
|
|
1602
|
+
if (text.length < 2) return false;
|
|
1603
|
+
if (!/[A-Za-z]/.test(text)) return false;
|
|
1604
|
+
return !_SEPARATOR_GLYPHS.has(text);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
// The `>` that CLOSES a JSX tag, skipping quoted attribute values and any `>`
|
|
1608
|
+
// inside a `{…}` expression — `onPress={() => x}` carries one, and treating it
|
|
1609
|
+
// as the tag end reports the handler body as copy.
|
|
1610
|
+
function _tagEnd(code, from) {
|
|
1611
|
+
let depth = 0;
|
|
1612
|
+
let quote = null;
|
|
1613
|
+
for (let i = from; i < code.length; i++) {
|
|
1614
|
+
const ch = code[i];
|
|
1615
|
+
if (quote) {
|
|
1616
|
+
if (ch === "\\") i++;
|
|
1617
|
+
else if (ch === quote) quote = null;
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
1621
|
+
else if (ch === "{") depth++;
|
|
1622
|
+
else if (ch === "}") depth--;
|
|
1623
|
+
else if (ch === ">" && depth <= 0) return i;
|
|
1624
|
+
}
|
|
1625
|
+
return -1;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// The balanced `{…}` starting at `from`, or null.
|
|
1629
|
+
function _bracedSpan(code, from) {
|
|
1630
|
+
if (code[from] !== "{") return null;
|
|
1631
|
+
let depth = 0;
|
|
1632
|
+
let quote = null;
|
|
1633
|
+
for (let i = from; i < code.length; i++) {
|
|
1634
|
+
const ch = code[i];
|
|
1635
|
+
if (quote) {
|
|
1636
|
+
if (ch === "\\") i++;
|
|
1637
|
+
else if (ch === quote) quote = null;
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1640
|
+
if (ch === '"' || ch === "'" || ch === "`") quote = ch;
|
|
1641
|
+
else if (ch === "{") depth++;
|
|
1642
|
+
else if (ch === "}") {
|
|
1643
|
+
depth--;
|
|
1644
|
+
if (depth === 0) return code.slice(from + 1, i);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return null;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// Copy pinned in a `<Text>`: a bare literal child, or a braced child that is
|
|
1651
|
+
// NOTHING BUT a string literal (`{"Hello"}`) — the one-pair-of-braces evasion.
|
|
1652
|
+
// A braced expression carrying an identifier is left alone: it may already be
|
|
1653
|
+
// a prop, and under-firing is right for a gate that blocks a publish.
|
|
1654
|
+
function _textLiteralOccurrences(codes, masks) {
|
|
1655
|
+
const byText = new Map();
|
|
1656
|
+
for (let f = 0; f < codes.length; f++) {
|
|
1657
|
+
const code = codes[f];
|
|
1658
|
+
// Locate tags on the string-blanked mask, so markup HELD IN a string is
|
|
1659
|
+
// not read as markup. Both masks preserve offsets, so the child is read
|
|
1660
|
+
// back from the kept-strings source at the very same index.
|
|
1661
|
+
const mask = masks[f];
|
|
1662
|
+
const re = /<Text\b/g;
|
|
1663
|
+
let m;
|
|
1664
|
+
while ((m = re.exec(mask)) !== null) {
|
|
1665
|
+
const end = _tagEnd(mask, m.index);
|
|
1666
|
+
if (end === -1) continue;
|
|
1667
|
+
let i = end + 1;
|
|
1668
|
+
while (i < code.length && /\s/.test(code[i])) i++;
|
|
1669
|
+
let text = null;
|
|
1670
|
+
if (code[i] === "{") {
|
|
1671
|
+
const inner = _bracedSpan(code, i);
|
|
1672
|
+
const lit =
|
|
1673
|
+
inner && inner.trim().match(/^(["'`])((?:\\.|(?!\1).)*)\1$/);
|
|
1674
|
+
// A template carrying `${…}` interpolates a value, so it is not a literal.
|
|
1675
|
+
if (lit && !(lit[1] === "`" && lit[2].includes("${"))) text = lit[2];
|
|
1676
|
+
} else {
|
|
1677
|
+
const child = code.slice(i).match(/^([^<>{}\n]+)/);
|
|
1678
|
+
if (child) text = child[1];
|
|
1679
|
+
}
|
|
1680
|
+
if (text === null) continue;
|
|
1681
|
+
text = text.trim();
|
|
1682
|
+
if (!_isAuthoredCopy(text) || byText.has(text)) continue;
|
|
1683
|
+
// Split-impl carries the same copy twice; report the string, not the file.
|
|
1684
|
+
byText.set(text, code.slice(0, i).split("\n").length);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return [...byText.entries()].map(([text, line]) => ({ text, line }));
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Per FILE: joining widget.web.jsx and widget.native.jsx doubles every tag.
|
|
1691
|
+
// A second element must be a DISTINCT visual kind beside the text — two
|
|
1692
|
+
// `<Text>` tags are as often a loading branch and its content as a title and
|
|
1693
|
+
// a body, and a blocking gate may not guess.
|
|
1694
|
+
function _rendersSeveralElements(code) {
|
|
1695
|
+
if (!/<Text[\s>/]/.test(code)) return false;
|
|
1696
|
+
return _SECOND_ELEMENT_TAGS.some((tag) =>
|
|
1697
|
+
new RegExp(`<${tag}[\\s>/]`).test(code),
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function _styleGroupFindings(manifest) {
|
|
1702
|
+
const schema = _plainObject(manifest) ? manifest.styleSchema : null;
|
|
1703
|
+
if (!_plainObject(schema)) return [];
|
|
1704
|
+
const bad = Object.keys(schema).filter((field) => {
|
|
1705
|
+
const def = schema[field];
|
|
1706
|
+
const group = _plainObject(def) && _plainObject(def.ui) ? def.ui.group : "";
|
|
1707
|
+
if (typeof group !== "string" || group.trim().length === 0) return true;
|
|
1708
|
+
return _GENERIC_STYLE_GROUPS.has(group.trim().toLowerCase());
|
|
1709
|
+
});
|
|
1710
|
+
if (bad.length === 0) return [];
|
|
1711
|
+
return [
|
|
1712
|
+
{
|
|
1713
|
+
rule: "style-group-missing",
|
|
1714
|
+
severity: "warning",
|
|
1715
|
+
label:
|
|
1716
|
+
`styleSchema fields with no usable ui.group: ${_quoteList(
|
|
1717
|
+
bad.map((f) => `"${f}"`),
|
|
1718
|
+
)}. Group EVERY field — basics under "Basics", each per-element ` +
|
|
1719
|
+
`field under the element it styles. A catch-all name is not a group.`,
|
|
1720
|
+
line: 0,
|
|
1721
|
+
snippet: _clip(bad[0], 12),
|
|
1722
|
+
},
|
|
1723
|
+
];
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function _styleCoverageFindings(manifest, codes) {
|
|
1727
|
+
const schema = _plainObject(manifest) ? manifest.styleSchema : null;
|
|
1728
|
+
if (!_plainObject(schema)) return [];
|
|
1729
|
+
const fields = Object.keys(schema);
|
|
1730
|
+
if (fields.length === 0) return [];
|
|
1731
|
+
if (fields.some((f) => !_WHOLE_WIDGET_STYLE_FIELDS.has(f))) return [];
|
|
1732
|
+
if (!codes.some(_rendersSeveralElements)) return [];
|
|
1733
|
+
return [
|
|
1734
|
+
{
|
|
1735
|
+
rule: "style-elements-uncovered",
|
|
1736
|
+
severity: "warning",
|
|
1737
|
+
label:
|
|
1738
|
+
`The widget renders more than one visual element but its styleSchema ` +
|
|
1739
|
+
`stops at background/textColor/align. Add 2-4 grouped per-element ` +
|
|
1740
|
+
`fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
|
|
1741
|
+
`each applied ONLY when set.`,
|
|
1742
|
+
line: 0,
|
|
1743
|
+
snippet: "",
|
|
1744
|
+
},
|
|
1745
|
+
];
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function _textLiteralFindings(occurrences) {
|
|
1749
|
+
if (occurrences.length === 0) return [];
|
|
1750
|
+
// Each entry is bounded so a long line of copy cannot push the whole
|
|
1751
|
+
// message past the report's truncation and take the line numbers with it.
|
|
1752
|
+
const listed = occurrences
|
|
1753
|
+
.slice(0, _LITERAL_LIST_CAP)
|
|
1754
|
+
.map((o) => `"${_clip(o.text, 16)}" (line ${o.line})`);
|
|
1755
|
+
const more = occurrences.length - listed.length;
|
|
1756
|
+
return [
|
|
1757
|
+
{
|
|
1758
|
+
rule: "text-literal-unexposed",
|
|
1759
|
+
severity: "warning",
|
|
1760
|
+
label:
|
|
1761
|
+
`Copy pinned in a <Text> reaches neither author nor translation: ` +
|
|
1762
|
+
`${listed.join(", ")}${more > 0 ? `, …(+${more} more)` : ""}. ` +
|
|
1763
|
+
`Declare each as a propertySchema string defaulting to that text ` +
|
|
1764
|
+
`and render the prop; fixed chrome uses t().`,
|
|
1765
|
+
line: occurrences[0].line,
|
|
1766
|
+
snippet: _clip(occurrences[0].text, 12),
|
|
1767
|
+
},
|
|
1768
|
+
];
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* sc-7799 — the editability gates: what an author can still change once the
|
|
1773
|
+
* widget ships. Bundle-level like `lintStyleWiring`, and for the same reason
|
|
1774
|
+
* — one manifest serves both halves of a split-impl widget.
|
|
1775
|
+
*
|
|
1776
|
+
* Each rule emits ONE consolidated finding, because the agent's draft-findings
|
|
1777
|
+
* report keeps only the first dozen checks and truncates each message: a
|
|
1778
|
+
* per-field finding on a 14-field manifest would evict the render-time crash
|
|
1779
|
+
* sitting behind it.
|
|
1780
|
+
*
|
|
1781
|
+
* @param {object} manifest widget manifest (`styleSchema` only)
|
|
1782
|
+
* @param {object|string[]|string} files bundle sources — a `{ name: source }`
|
|
1783
|
+
* map, an array of sources, or one source string
|
|
1784
|
+
* @returns {{ ok: boolean, findings: Array<{ rule: string, severity: string, label: string, line: number, snippet: string }> }}
|
|
1785
|
+
*/
|
|
1786
|
+
export function lintEditability(manifest, files) {
|
|
1787
|
+
const sources = _bundleSources(files);
|
|
1788
|
+
if (sources.length === 0) return { ok: true, findings: [] };
|
|
1789
|
+
const codes = sources.map((s) => _stripNonCode(s, { keepStrings: true }));
|
|
1790
|
+
const masks = sources.map((s) => _stripNonCode(s, { keepStrings: false }));
|
|
1791
|
+
const findings = [
|
|
1792
|
+
..._styleGroupFindings(manifest),
|
|
1793
|
+
..._styleCoverageFindings(manifest, codes),
|
|
1794
|
+
..._textLiteralFindings(_textLiteralOccurrences(codes, masks)),
|
|
1795
|
+
];
|
|
1796
|
+
// Every rule here is a warning by construction: the CLI must stay exit-0 for
|
|
1797
|
+
// a human author, while the agent blocks on any finding regardless.
|
|
1798
|
+
return { ok: true, findings };
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1545
1801
|
export function lintSource(source, options) {
|
|
1546
1802
|
if (typeof source !== "string") {
|
|
1547
1803
|
return {
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// sc-7800 — the host side of THEME-TOKEN COLOUR BINDINGS.
|
|
2
|
+
//
|
|
3
|
+
// Host-integration surface, re-exported from `host.js`: consumed ONLY by the
|
|
4
|
+
// platform hosts that render widgets (the web Player / Studio Canvas via
|
|
5
|
+
// buildHostWidgetContext.js, and the exported Expo app's generated WidgetHost),
|
|
6
|
+
// never by a widget author. A widget keeps reading a plain colour string off
|
|
7
|
+
// `props` / `props.style` and never learns a token was involved.
|
|
8
|
+
//
|
|
9
|
+
// Living here — one implementation both hosts import — is what makes a bound
|
|
10
|
+
// colour resolve identically in the Player and the export (widget-parity
|
|
11
|
+
// skill). The alternative, a copy per host, is the drift this file exists to
|
|
12
|
+
// prevent.
|
|
13
|
+
//
|
|
14
|
+
// WHY THIS EXISTS: a `type: "color"` field already tracks the workspace theme
|
|
15
|
+
// while it is UNSET, through its `themeDefault` placeholder. The moment an
|
|
16
|
+
// author picks a colour it became a frozen hex that never followed a rebrand
|
|
17
|
+
// again, and "make this our brand colour" was inexpressible — the author had to
|
|
18
|
+
// type today's hex, which went stale silently. A bound field stores the TOKEN,
|
|
19
|
+
// so the intent survives the rebrand.
|
|
20
|
+
|
|
21
|
+
import { CONTRACT } from "./contract.js";
|
|
22
|
+
|
|
23
|
+
// A value is a binding, not a colour, when it carries this prefix. No CSS
|
|
24
|
+
// colour can begin with it, so a stored literal can never be misread as one.
|
|
25
|
+
const PREFIX = "theme:";
|
|
26
|
+
|
|
27
|
+
/** True when `value` is a theme-token binding rather than a literal colour. */
|
|
28
|
+
export function isThemeTokenRef(value) {
|
|
29
|
+
return typeof value === "string" && value.startsWith(PREFIX);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve one binding against a resolved widget theme.
|
|
34
|
+
*
|
|
35
|
+
* Returns `undefined` for anything that is not a binding, names a token outside
|
|
36
|
+
* the bindable allowlist, or resolves to a non-string — the caller then leaves
|
|
37
|
+
* the field unset so it falls back to its `themeDefault` exactly as an
|
|
38
|
+
* un-bound field does. A malformed binding must never paint blank.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveThemeTokenRef(value, theme) {
|
|
41
|
+
if (!isThemeTokenRef(value)) return undefined;
|
|
42
|
+
const path = value.slice(PREFIX.length);
|
|
43
|
+
// The allowlist is the contract's, so what the Studio may WRITE and what a
|
|
44
|
+
// host will RESOLVE cannot drift apart.
|
|
45
|
+
if (!CONTRACT.themeColorTokens.some((token) => token.path === path)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const resolved = path
|
|
49
|
+
.split(".")
|
|
50
|
+
.reduce((o, k) => (o == null ? undefined : o[k]), theme);
|
|
51
|
+
return typeof resolved === "string" && resolved.trim() !== ""
|
|
52
|
+
? resolved
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value) {
|
|
57
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The same guard the neighbouring style validators apply: these props are
|
|
61
|
+
// author-stored JSON, and assigning such a key while rebuilding an object would
|
|
62
|
+
// silently reseat its prototype instead of carrying the value.
|
|
63
|
+
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk a declared schema alongside a value and replace every bound `color`
|
|
67
|
+
* leaf with the colour its token currently resolves to.
|
|
68
|
+
*
|
|
69
|
+
* Schema-GUIDED rather than a blind string sweep, because top-level props hold
|
|
70
|
+
* arbitrary author content — body copy, rich text, a stored link — where a
|
|
71
|
+
* `theme:`-prefixed string is data and must be left verbatim. Only a field the
|
|
72
|
+
* widget declared as a colour is eligible here. (`props.style` is different and
|
|
73
|
+
* is handled by `resolveStyleBag` below.)
|
|
74
|
+
*
|
|
75
|
+
* Returns the SAME reference when nothing is bound, so an app that uses no
|
|
76
|
+
* bindings takes no extra render work and behaves exactly as it did before.
|
|
77
|
+
*/
|
|
78
|
+
function resolveAgainstSchema(schema, value, theme) {
|
|
79
|
+
if (!isPlainObject(schema) || !isPlainObject(value)) return value;
|
|
80
|
+
let changed = false;
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
83
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
84
|
+
const def = schema[key];
|
|
85
|
+
out[key] = raw;
|
|
86
|
+
if (!isPlainObject(def)) continue;
|
|
87
|
+
|
|
88
|
+
if (def.type === "color") {
|
|
89
|
+
if (isThemeTokenRef(raw)) {
|
|
90
|
+
// An unresolvable binding is dropped rather than painted: the field goes
|
|
91
|
+
// back to unset, which is what already renders its `themeDefault`.
|
|
92
|
+
out[key] = resolveThemeTokenRef(raw, theme);
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (def.type === "object" && isPlainObject(def.properties)) {
|
|
98
|
+
const next = resolveAgainstSchema(def.properties, raw, theme);
|
|
99
|
+
if (next !== raw) {
|
|
100
|
+
out[key] = next;
|
|
101
|
+
changed = true;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (def.type === "array" && isPlainObject(def.items) && Array.isArray(raw)) {
|
|
106
|
+
let itemChanged = false;
|
|
107
|
+
const items = raw.map((item) => {
|
|
108
|
+
const next =
|
|
109
|
+
def.items.type === "object" && isPlainObject(def.items.properties)
|
|
110
|
+
? resolveAgainstSchema(def.items.properties, item, theme)
|
|
111
|
+
: item;
|
|
112
|
+
if (next !== item) itemChanged = true;
|
|
113
|
+
return next;
|
|
114
|
+
});
|
|
115
|
+
if (itemChanged) {
|
|
116
|
+
out[key] = items;
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return changed ? out : value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A style bag is resolved WITHOUT a schema, and deliberately so. Both hosts
|
|
125
|
+
// thread `styleSchema` only for marketplace / AI-agent widgets; a BUILT-IN
|
|
126
|
+
// passes `undefined` on web and `null` on native (it keeps its defaults in
|
|
127
|
+
// code). Requiring the schema would therefore have left every built-in's colour
|
|
128
|
+
// field bindable in the Studio but unresolved at render — the author picks
|
|
129
|
+
// "Primary" and the widget is handed the literal string. Resolving the bag
|
|
130
|
+
// directly is what makes a built-in and a generated widget behave the same, on
|
|
131
|
+
// both hosts, with nothing new baked into the export.
|
|
132
|
+
//
|
|
133
|
+
// Safe without a schema because `props.style` is by construction a bag of style
|
|
134
|
+
// VALUES — colours, sizes, enums — so an allowlisted `theme:` string in it can
|
|
135
|
+
// only ever have been a binding. The depth cap keeps the walk bounded for the
|
|
136
|
+
// structured style values (a gradient's stops, per-corner radii).
|
|
137
|
+
const MAX_STYLE_DEPTH = 3;
|
|
138
|
+
|
|
139
|
+
function resolveStyleBag(value, theme, depth) {
|
|
140
|
+
if (typeof value === "string") {
|
|
141
|
+
if (!isThemeTokenRef(value)) return value;
|
|
142
|
+
// Unresolvable drops to unset, which is what renders `themeDefault`.
|
|
143
|
+
return resolveThemeTokenRef(value, theme);
|
|
144
|
+
}
|
|
145
|
+
if (depth >= MAX_STYLE_DEPTH) return value;
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
let changed = false;
|
|
148
|
+
const items = value.map((item) => {
|
|
149
|
+
const next = resolveStyleBag(item, theme, depth + 1);
|
|
150
|
+
if (next !== item) changed = true;
|
|
151
|
+
return next;
|
|
152
|
+
});
|
|
153
|
+
return changed ? items : value;
|
|
154
|
+
}
|
|
155
|
+
if (isPlainObject(value)) {
|
|
156
|
+
let changed = false;
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const [key, item] of Object.entries(value)) {
|
|
159
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
160
|
+
const next = resolveStyleBag(item, theme, depth + 1);
|
|
161
|
+
out[key] = next;
|
|
162
|
+
if (next !== item) changed = true;
|
|
163
|
+
}
|
|
164
|
+
return changed ? out : value;
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The host render-boundary call: resolve every theme-token colour binding on a
|
|
171
|
+
* widget's props, both the top-level `propertySchema` fields and the style
|
|
172
|
+
* values under `props.style`.
|
|
173
|
+
*
|
|
174
|
+
* Called by BOTH hosts in the same place, immediately after
|
|
175
|
+
* `applyThemeComponentStyle` — a binding is the author's own per-instance
|
|
176
|
+
* value, so it sits at the top of the style precedence and is resolved once the
|
|
177
|
+
* lower layers have been folded in.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} props — the widget's props (post-`applyThemeComponentStyle`).
|
|
180
|
+
* @param {object} theme — the resolved widget theme (`workspace.theme`).
|
|
181
|
+
* @param {object} [schemas] — `{ propertySchema }` as declared by the widget's
|
|
182
|
+
* manifest, used for the top-level props only. `props.style` needs no schema
|
|
183
|
+
* (see above), so a built-in resolves exactly like a generated widget.
|
|
184
|
+
* @returns {object} props, with every bound colour resolved.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveThemeTokenRefs(props, theme, schemas) {
|
|
187
|
+
if (!isPlainObject(props)) return props;
|
|
188
|
+
const { propertySchema } = schemas || {};
|
|
189
|
+
let out = resolveAgainstSchema(propertySchema, props, theme);
|
|
190
|
+
const style = out.style;
|
|
191
|
+
if (isPlainObject(style)) {
|
|
192
|
+
const nextStyle = resolveStyleBag(style, theme, 1);
|
|
193
|
+
if (nextStyle !== style) {
|
|
194
|
+
out = { ...out, style: nextStyle };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.140.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "node scripts/build.js",
|
|
52
|
-
"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-flex-basis-percent.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__/flatten-entry.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__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.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-camera.test.js src/__tests__/hooks-barcode.test.js src/__tests__/hooks-image-editor.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 src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
|
|
52
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/theme-token-refs.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-flex-basis-percent.test.js src/__tests__/linter-hardcoded-design.test.js src/__tests__/linter-editability.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__/flatten-entry.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__/interaction-lift.test.js src/__tests__/toast-host.test.js src/__tests__/overlay-tokens.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-camera.test.js src/__tests__/hooks-barcode.test.js src/__tests__/hooks-image-editor.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 src/__tests__/linter-html-in-content.test.js src/__tests__/markdown.test.js src/__tests__/markdown-edit.test.js src/__tests__/richtext-tokens.test.js src/__tests__/style-group.test.js"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=18"
|