@colixsystems/widget-sdk 0.111.0 → 0.113.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 +60 -5
- package/dist/cli.js +75 -22
- package/dist/contract.cjs +53 -6
- package/dist/contract.js +53 -6
- package/dist/hooks.js +153 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +167 -1
- package/dist/linter.js +161 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
37
37
|
| **CORE** | `useToast()` | `{ showToast }` | `ctx.toast.showToast` — wired by the Player and the Expo export; an authoring preview omits it and the call is a no-op — no scope |
|
|
38
38
|
| **CORE** | `useGeolocation(options?)` | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition }` | `ctx.device.geolocation` — no scope. Capture is IMPERATIVE: call `getCurrentPosition()` from a user gesture (a tap), never on mount. Resolves to `{ latitude, longitude, accuracy }`; rejects with `GeolocationError` (`.code` in `PERMISSION_DENIED \| UNAVAILABLE \| TIMEOUT \| UNSUPPORTED \| INTERNAL`). Identical on web (`navigator.geolocation`) and the Expo export (`expo-location`). |
|
|
39
39
|
| **CORE** | `useSpeechToText(options?)` | `{ transcript, partial, listening, supported, error, start, stop, abort, reset }` | `ctx.device.speech` — no scope. Dictation with the device's **on-device** recogniser: no audio is uploaded and no AI credit is spent. Capture is IMPERATIVE: call `start()` from a user gesture (a tap), never on mount. `transcript` accumulates finalised speech, `partial` holds the uncommitted guess (needs `options.interimResults`); `stop()` keeps it, `abort()` discards it. Rejects with `SpeechToTextError` (`.code` in `PERMISSION_DENIED \| NO_SPEECH \| LANGUAGE_UNSUPPORTED \| NETWORK \| ABORTED \| UNSUPPORTED \| INTERNAL`). **Gate your mic button on `supported`** — Firefox ships no `SpeechRecognition`. Identical on web (`SpeechRecognition`) and the Expo export (`expo-speech-recognition`). |
|
|
40
|
+
| **CORE** | `useCamera(options?)` | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (file input) and the Expo export (`expo-image-picker`). |
|
|
40
41
|
| **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
|
|
41
42
|
| **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
|
|
42
43
|
| **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
|
|
@@ -69,8 +70,55 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
69
70
|
|
|
70
71
|
## Status
|
|
71
72
|
|
|
72
|
-
`v0.
|
|
73
|
+
`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
|
|
|
75
|
+
### What's new in 0.113.0 (contract 1.86.0)
|
|
76
|
+
|
|
77
|
+
**New `useCamera()` hook — take a photo or pick one from the device library.** A new CORE hook reading a new `camera` capability on the existing `ctx.device` slice. Returns `{ asset, loading, error, supported, capture, pick, reset }`. Capture is **imperative** — call `capture()` or `pick()` from a user gesture (a `Pressable.onPress`); the browser and the mobile OS gate the permission prompt on a gesture, so it NEVER opens on mount. `options` (`{ allowsEditing, quality }`) pass through to the host. It needs **no manifest scope** and **no `requestedScopes` entry**.
|
|
78
|
+
|
|
79
|
+
**Dismissing the picker resolves `null`, not an error.** Backing out is the most common outcome, so it is deliberately not a rejection — your happy path needs no `try/catch`. A genuine failure (permission refused, no host broker) rejects with a structured `CameraError` (new named export) carrying a stable `.code` (`PERMISSION_DENIED` / `UNSUPPORTED` / `INTERNAL`).
|
|
80
|
+
|
|
81
|
+
**One upload path on both hosts.** The resolved asset is normalised to `{ uri, name, mimeType, width, height, size, file }`, where `uri` is directly displayable (`<Image source={{ uri }} />`) and `file` is already the right upload part for the platform — a `File` on web, a `{ uri, name, type }` triple on native. So the same three lines work everywhere:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const fd = new FormData();
|
|
85
|
+
fd.append("file", asset.file);
|
|
86
|
+
await ctx.assets.upload(fd);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`options` (`{ allowsEditing, quality }`) are **hints**: the Expo export applies both, the web file input applies neither — so never depend on a cropped result or a capped file size. `reset()` clears the asset and releases it (on web that revokes the blob URL, which otherwise leaks for the life of the document). **Gate your camera button on `supported`** — a host that brokers no camera reports `false` rather than throwing. The web Player brokers it via a file input (`capture="environment"` opens the camera on a phone); the Expo export via `expo-image-picker`, whose config plugin declares the camera and photo-library permissions the runtime needs.
|
|
90
|
+
|
|
91
|
+
Additive — one new hook, one new optional context-slice member; no existing export changed signature.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
### What's new in 0.112.0 (contract unchanged at 1.85.0)
|
|
95
|
+
|
|
96
|
+
**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.
|
|
97
|
+
|
|
98
|
+
- **`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.
|
|
99
|
+
- **`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.
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
// appstudio-design-ok: categorical series identity cannot come from one accent
|
|
105
|
+
const SERIES = ["#ff6b5b", "#3b82f6", "#10b981"];
|
|
106
|
+
|
|
107
|
+
const letterbox = { backgroundColor: "#000" }; // appstudio-design-ok: video letterbox
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
`lint` now takes a whole bundle, and `--manifest` enables the wiring check:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
import { lintStyleWiring } from "@colixsystems/widget-sdk/linter";
|
|
120
|
+
const report = lintStyleWiring(manifest, { "widget.jsx": source });
|
|
121
|
+
```
|
|
74
122
|
### What's new in 0.111.0 (contract 1.85.0)
|
|
75
123
|
|
|
76
124
|
**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.
|
|
@@ -1292,16 +1340,23 @@ that renders on one platform and blanks on the other.
|
|
|
1292
1340
|
## Linter
|
|
1293
1341
|
|
|
1294
1342
|
```sh
|
|
1295
|
-
npx appstudio-widget lint path/to/widget.
|
|
1343
|
+
npx appstudio-widget lint path/to/widget.jsx
|
|
1344
|
+
npx appstudio-widget lint widget.web.jsx widget.native.jsx --manifest manifest.js
|
|
1296
1345
|
```
|
|
1297
1346
|
|
|
1298
|
-
Scans for banned patterns (`eval`, `new Function`, dynamic `import()`, direct imports of host stores, raw axios).
|
|
1347
|
+
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.
|
|
1299
1348
|
|
|
1300
1349
|
```js
|
|
1301
|
-
import { lintSource } from "@colixsystems/widget-sdk/linter";
|
|
1302
|
-
const report = lintSource(source);
|
|
1350
|
+
import { lintSource, lintStyleWiring } from "@colixsystems/widget-sdk/linter";
|
|
1351
|
+
const report = lintSource(source, { manifest });
|
|
1352
|
+
// Bundle-level — pass every file the widget ships:
|
|
1353
|
+
const wiring = lintStyleWiring(manifest, { "widget.jsx": source });
|
|
1303
1354
|
```
|
|
1304
1355
|
|
|
1356
|
+
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*.
|
|
1357
|
+
|
|
1358
|
+
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.
|
|
1359
|
+
|
|
1305
1360
|
## Local dev loop (`appstudio-widget dev`)
|
|
1306
1361
|
|
|
1307
1362
|
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
|
@@ -1576,6 +1576,37 @@ const HOOKS = [
|
|
|
1576
1576
|
requiredContextSlice: [],
|
|
1577
1577
|
scopes: null,
|
|
1578
1578
|
},
|
|
1579
|
+
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
1580
|
+
// the hook reports supported:false rather than throwing at render.
|
|
1581
|
+
{
|
|
1582
|
+
name: "useCamera",
|
|
1583
|
+
signature: "useCamera(options?)",
|
|
1584
|
+
description:
|
|
1585
|
+
"Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
|
|
1586
|
+
"Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
|
|
1587
|
+
"permission prompt on a gesture, so it NEVER opens on mount. Both resolve to a normalised asset " +
|
|
1588
|
+
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
1589
|
+
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
1590
|
+
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
1591
|
+
"upload part for the host (a File on web, { uri, name, type } on native): append it to a FormData as `file` and pass " +
|
|
1592
|
+
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1593
|
+
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1594
|
+
"can — the Expo export applies both, the web file input applies neither, so never depend on a capped file size or a " +
|
|
1595
|
+
"cropped result. Behaviour is otherwise identical on web (file input) and the Expo export (expo-image-picker).",
|
|
1596
|
+
returnShape: {
|
|
1597
|
+
asset:
|
|
1598
|
+
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
1599
|
+
loading: "boolean",
|
|
1600
|
+
error: "CameraError | null",
|
|
1601
|
+
supported: "boolean // false when the host brokers no camera",
|
|
1602
|
+
capture:
|
|
1603
|
+
"() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1604
|
+
pick: "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1605
|
+
reset: "() => void // clear the asset + error and release it",
|
|
1606
|
+
},
|
|
1607
|
+
requiredContextSlice: [],
|
|
1608
|
+
scopes: null,
|
|
1609
|
+
},
|
|
1579
1610
|
];
|
|
1580
1611
|
|
|
1581
1612
|
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
@@ -2168,14 +2199,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2168
2199
|
description:
|
|
2169
2200
|
"Optional host-brokered device capabilities. " +
|
|
2170
2201
|
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
|
|
2171
|
-
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }
|
|
2172
|
-
"
|
|
2173
|
-
"
|
|
2202
|
+
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2203
|
+
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2204
|
+
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
2205
|
+
"window.SpeechRecognition and a file input; the Expo export via expo-location, expo-speech-recognition and " +
|
|
2206
|
+
"expo-image-picker. " +
|
|
2174
2207
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
2175
2208
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
2176
|
-
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted)."
|
|
2209
|
+
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
|
|
2210
|
+
"camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
|
|
2211
|
+
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
|
|
2177
2212
|
required: false,
|
|
2178
|
-
fields: { geolocation: "object", speech: "object" },
|
|
2213
|
+
fields: { geolocation: "object", speech: "object", camera: "object" },
|
|
2179
2214
|
},
|
|
2180
2215
|
};
|
|
2181
2216
|
|
|
@@ -3386,7 +3421,19 @@ const CONTRACT = deepFreeze({
|
|
|
3386
3421
|
// byte-identically. `mapSpacing` pushes each side through the responsive
|
|
3387
3422
|
// and theme scaling the scalar already got; `isZeroSpacing` lets each box
|
|
3388
3423
|
// property keep its own zero policy.
|
|
3389
|
-
|
|
3424
|
+
//
|
|
3425
|
+
// 1.86.0: additive (sc-6448) — new `useCamera()` hook + a `camera` member on
|
|
3426
|
+
// the optional `device` host slice. Takes a photo or picks one from the
|
|
3427
|
+
// library, resolving a normalised asset whose `file` is already the right
|
|
3428
|
+
// upload part for the host (a File on web, `{ uri, name, type }` on
|
|
3429
|
+
// native), so one code path feeds `ctx.assets.upload`. Host-brokered
|
|
3430
|
+
// rather than a vetted import — the same reasoning as `speech`: widgets
|
|
3431
|
+
// never import the native module, so it stays out of widget bundles.
|
|
3432
|
+
// This supersedes the `expo-camera` deferral in
|
|
3433
|
+
// docs/design/req-widget-sdk-cross-platform-primitives.md, which was
|
|
3434
|
+
// about vetting a full camera surface (permissions, multi-step UX, frame
|
|
3435
|
+
// processing) as a widget import — none of which this adds.
|
|
3436
|
+
version: "1.86.0",
|
|
3390
3437
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3391
3438
|
hooks: HOOKS,
|
|
3392
3439
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -1576,6 +1576,37 @@ const HOOKS = [
|
|
|
1576
1576
|
requiredContextSlice: [],
|
|
1577
1577
|
scopes: null,
|
|
1578
1578
|
},
|
|
1579
|
+
// sc-6448 — host-brokered camera capture / photo picking. Optional slice;
|
|
1580
|
+
// the hook reports supported:false rather than throwing at render.
|
|
1581
|
+
{
|
|
1582
|
+
name: "useCamera",
|
|
1583
|
+
signature: "useCamera(options?)",
|
|
1584
|
+
description:
|
|
1585
|
+
"Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
|
|
1586
|
+
"Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
|
|
1587
|
+
"permission prompt on a gesture, so it NEVER opens on mount. Both resolve to a normalised asset " +
|
|
1588
|
+
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
1589
|
+
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
1590
|
+
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
1591
|
+
"upload part for the host (a File on web, { uri, name, type } on native): append it to a FormData as `file` and pass " +
|
|
1592
|
+
"that to ctx.assets.upload(fd) — one code path on both platforms. reset() clears the asset and releases it. Check " +
|
|
1593
|
+
"`supported` before rendering a camera button. options: { allowsEditing, quality } are HINTS the host honours where it " +
|
|
1594
|
+
"can — the Expo export applies both, the web file input applies neither, so never depend on a capped file size or a " +
|
|
1595
|
+
"cropped result. Behaviour is otherwise identical on web (file input) and the Expo export (expo-image-picker).",
|
|
1596
|
+
returnShape: {
|
|
1597
|
+
asset:
|
|
1598
|
+
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
1599
|
+
loading: "boolean",
|
|
1600
|
+
error: "CameraError | null",
|
|
1601
|
+
supported: "boolean // false when the host brokers no camera",
|
|
1602
|
+
capture:
|
|
1603
|
+
"() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1604
|
+
pick: "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
1605
|
+
reset: "() => void // clear the asset + error and release it",
|
|
1606
|
+
},
|
|
1607
|
+
requiredContextSlice: [],
|
|
1608
|
+
scopes: null,
|
|
1609
|
+
},
|
|
1579
1610
|
];
|
|
1580
1611
|
|
|
1581
1612
|
// REQ-WSDK-RN-WEB: the SDK exposes the React Native primitive API
|
|
@@ -2168,14 +2199,18 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2168
2199
|
description:
|
|
2169
2200
|
"Optional host-brokered device capabilities. " +
|
|
2170
2201
|
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
|
|
2171
|
-
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }
|
|
2172
|
-
"
|
|
2173
|
-
"
|
|
2202
|
+
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2203
|
+
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2204
|
+
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
2205
|
+
"window.SpeechRecognition and a file input; the Expo export via expo-location, expo-speech-recognition and " +
|
|
2206
|
+
"expo-image-picker. " +
|
|
2174
2207
|
"getCurrentPosition rejects with a GeolocationError (.code PERMISSION_DENIED | UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL). " +
|
|
2175
2208
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
2176
|
-
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted)."
|
|
2209
|
+
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
|
|
2210
|
+
"camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
|
|
2211
|
+
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
|
|
2177
2212
|
required: false,
|
|
2178
|
-
fields: { geolocation: "object", speech: "object" },
|
|
2213
|
+
fields: { geolocation: "object", speech: "object", camera: "object" },
|
|
2179
2214
|
},
|
|
2180
2215
|
};
|
|
2181
2216
|
|
|
@@ -3386,7 +3421,19 @@ const CONTRACT = deepFreeze({
|
|
|
3386
3421
|
// byte-identically. `mapSpacing` pushes each side through the responsive
|
|
3387
3422
|
// and theme scaling the scalar already got; `isZeroSpacing` lets each box
|
|
3388
3423
|
// property keep its own zero policy.
|
|
3389
|
-
|
|
3424
|
+
//
|
|
3425
|
+
// 1.86.0: additive (sc-6448) — new `useCamera()` hook + a `camera` member on
|
|
3426
|
+
// the optional `device` host slice. Takes a photo or picks one from the
|
|
3427
|
+
// library, resolving a normalised asset whose `file` is already the right
|
|
3428
|
+
// upload part for the host (a File on web, `{ uri, name, type }` on
|
|
3429
|
+
// native), so one code path feeds `ctx.assets.upload`. Host-brokered
|
|
3430
|
+
// rather than a vetted import — the same reasoning as `speech`: widgets
|
|
3431
|
+
// never import the native module, so it stays out of widget bundles.
|
|
3432
|
+
// This supersedes the `expo-camera` deferral in
|
|
3433
|
+
// docs/design/req-widget-sdk-cross-platform-primitives.md, which was
|
|
3434
|
+
// about vetting a full camera surface (permissions, multi-step UX, frame
|
|
3435
|
+
// processing) as a widget import — none of which this adds.
|
|
3436
|
+
version: "1.86.0",
|
|
3390
3437
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3391
3438
|
hooks: HOOKS,
|
|
3392
3439
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -1297,6 +1297,159 @@ export function useSpeechToText(options) {
|
|
|
1297
1297
|
};
|
|
1298
1298
|
}
|
|
1299
1299
|
|
|
1300
|
+
/**
|
|
1301
|
+
* Structured error thrown by `useCamera` callbacks.
|
|
1302
|
+
*
|
|
1303
|
+
* `code` is one of:
|
|
1304
|
+
* - "PERMISSION_DENIED" — the user (or OS) refused camera / library access.
|
|
1305
|
+
* - "UNSUPPORTED" — this host does not broker the camera.
|
|
1306
|
+
* - "INTERNAL" — anything else.
|
|
1307
|
+
*
|
|
1308
|
+
* A user dismissing the picker is NOT an error — the promise resolves `null`.
|
|
1309
|
+
*/
|
|
1310
|
+
export class CameraError extends Error {
|
|
1311
|
+
constructor(code, message, opts) {
|
|
1312
|
+
super(message);
|
|
1313
|
+
this.name = "CameraError";
|
|
1314
|
+
this.code = code;
|
|
1315
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Coerce a thrown value into a CameraError with a stable code. */
|
|
1320
|
+
function toCameraError(err) {
|
|
1321
|
+
if (err instanceof CameraError) return err;
|
|
1322
|
+
const raw = err && err.code !== undefined ? err.code : null;
|
|
1323
|
+
let code = "INTERNAL";
|
|
1324
|
+
if (raw === "PERMISSION_DENIED") code = "PERMISSION_DENIED";
|
|
1325
|
+
else if (raw === "UNSUPPORTED") code = "UNSUPPORTED";
|
|
1326
|
+
const message =
|
|
1327
|
+
(err && typeof err.message === "string" && err.message) ||
|
|
1328
|
+
"Camera request failed";
|
|
1329
|
+
return new CameraError(code, message, { cause: err });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Take a photo or choose one from the device library. Returns
|
|
1334
|
+
* `{ asset, loading, error, supported, capture, pick, reset }`.
|
|
1335
|
+
*
|
|
1336
|
+
* Capture is IMPERATIVE — call `capture()` / `pick()` from a user gesture. The
|
|
1337
|
+
* OS and the browser gate the permission prompt on a gesture, so the hook never
|
|
1338
|
+
* opens the camera on mount.
|
|
1339
|
+
*
|
|
1340
|
+
* Both resolve to a normalised asset, or `null` when the user dismisses the
|
|
1341
|
+
* picker — dismissal is the most common outcome and is deliberately not an
|
|
1342
|
+
* error, so widgets need no try/catch on the happy path. They reject with a
|
|
1343
|
+
* `CameraError` for a genuine failure (permission refused, no host broker).
|
|
1344
|
+
*
|
|
1345
|
+
* The asset's `file` is already the right shape to upload on either host — a
|
|
1346
|
+
* `File` on web, a `{ uri, name, type }` part on native — so one code path
|
|
1347
|
+
* covers both:
|
|
1348
|
+
*
|
|
1349
|
+
* const fd = new FormData();
|
|
1350
|
+
* fd.append("file", asset.file);
|
|
1351
|
+
* await ctx.assets.upload(fd);
|
|
1352
|
+
*
|
|
1353
|
+
* Check `supported` before rendering a camera button; a host with no broker
|
|
1354
|
+
* reports false rather than throwing at render.
|
|
1355
|
+
*/
|
|
1356
|
+
export function useCamera(options) {
|
|
1357
|
+
const ctx = useWidgetContextOrThrow("useCamera");
|
|
1358
|
+
const [asset, setAsset] = useState(null);
|
|
1359
|
+
const [loading, setLoading] = useState(false);
|
|
1360
|
+
const [error, setError] = useState(null);
|
|
1361
|
+
|
|
1362
|
+
// `ctx` is a fresh identity every host render — hold the live client and
|
|
1363
|
+
// options in refs so the returned callbacks stay stable.
|
|
1364
|
+
const clientRef = useRef(ctx.device && ctx.device.camera);
|
|
1365
|
+
clientRef.current = ctx.device && ctx.device.camera;
|
|
1366
|
+
const optionsRef = useRef(options);
|
|
1367
|
+
optionsRef.current = options;
|
|
1368
|
+
// Web hands back a blob: URL per asset; abandoning it leaks the blob for the
|
|
1369
|
+
// life of the document, so the hook owns revoking the one it replaced.
|
|
1370
|
+
const releaseRef = useRef(null);
|
|
1371
|
+
const runRef = useRef(0);
|
|
1372
|
+
|
|
1373
|
+
const supported = Boolean(
|
|
1374
|
+
clientRef.current &&
|
|
1375
|
+
typeof clientRef.current.capture === "function" &&
|
|
1376
|
+
(typeof clientRef.current.isSupported !== "function" ||
|
|
1377
|
+
clientRef.current.isSupported()),
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1380
|
+
const release = useCallback(() => {
|
|
1381
|
+
const revoke = releaseRef.current;
|
|
1382
|
+
releaseRef.current = null;
|
|
1383
|
+
if (typeof revoke === "function") {
|
|
1384
|
+
try {
|
|
1385
|
+
revoke();
|
|
1386
|
+
} catch {
|
|
1387
|
+
/* the host already released it */
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}, []);
|
|
1391
|
+
|
|
1392
|
+
useEffect(() => () => release(), [release]);
|
|
1393
|
+
|
|
1394
|
+
const reset = useCallback(() => {
|
|
1395
|
+
runRef.current += 1;
|
|
1396
|
+
release();
|
|
1397
|
+
setAsset(null);
|
|
1398
|
+
setError(null);
|
|
1399
|
+
}, [release]);
|
|
1400
|
+
|
|
1401
|
+
// capture() and pick() differ only in which broker method they call, so both
|
|
1402
|
+
// run through one request path — otherwise the loading/abort bookkeeping
|
|
1403
|
+
// would exist twice and drift.
|
|
1404
|
+
const request = useCallback(
|
|
1405
|
+
async (method) => {
|
|
1406
|
+
const client = clientRef.current;
|
|
1407
|
+
if (
|
|
1408
|
+
!client ||
|
|
1409
|
+
typeof client[method] !== "function" ||
|
|
1410
|
+
(typeof client.isSupported === "function" && !client.isSupported())
|
|
1411
|
+
) {
|
|
1412
|
+
const e = new CameraError(
|
|
1413
|
+
"UNSUPPORTED",
|
|
1414
|
+
"This host does not provide camera access.",
|
|
1415
|
+
);
|
|
1416
|
+
setError(e);
|
|
1417
|
+
throw e;
|
|
1418
|
+
}
|
|
1419
|
+
const run = (runRef.current += 1);
|
|
1420
|
+
setLoading(true);
|
|
1421
|
+
setError(null);
|
|
1422
|
+
try {
|
|
1423
|
+
const next = await client[method](optionsRef.current || {});
|
|
1424
|
+
// A reset() or a newer request landed while this one was open — drop
|
|
1425
|
+
// the result rather than clobbering what the widget now shows.
|
|
1426
|
+
if (run !== runRef.current) {
|
|
1427
|
+
if (next && typeof next.release === "function") next.release();
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
if (!next) return null;
|
|
1431
|
+
release();
|
|
1432
|
+
releaseRef.current =
|
|
1433
|
+
typeof next.release === "function" ? next.release : null;
|
|
1434
|
+
setAsset(next);
|
|
1435
|
+
return next;
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
const ce = toCameraError(err);
|
|
1438
|
+
if (run === runRef.current) setError(ce);
|
|
1439
|
+
throw ce;
|
|
1440
|
+
} finally {
|
|
1441
|
+
if (run === runRef.current) setLoading(false);
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
[release],
|
|
1445
|
+
);
|
|
1446
|
+
|
|
1447
|
+
const capture = useCallback(() => request("capture"), [request]);
|
|
1448
|
+
const pick = useCallback(() => request("pick"), [request]);
|
|
1449
|
+
|
|
1450
|
+
return { asset, loading, error, supported, capture, pick, reset };
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1300
1453
|
/* ============================================================================
|
|
1301
1454
|
* DATASTORE CLIENT — ctx.datastore (@colixsystems/datastore-client)
|
|
1302
1455
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1538,6 +1538,75 @@ export class SpeechToTextError extends Error {
|
|
|
1538
1538
|
);
|
|
1539
1539
|
}
|
|
1540
1540
|
|
|
1541
|
+
/**
|
|
1542
|
+
* Options for `useCamera(...)`. Both are HINTS the host honours where it can:
|
|
1543
|
+
* the Expo export applies them, the web file input applies neither — so never
|
|
1544
|
+
* depend on a cropped result or a capped file size.
|
|
1545
|
+
*/
|
|
1546
|
+
export interface CameraOptions {
|
|
1547
|
+
/** Let the user crop/rotate before returning. Native only. Defaults to false. */
|
|
1548
|
+
allowsEditing?: boolean;
|
|
1549
|
+
/** 0–1 compression quality. Native only. Defaults to 0.8. */
|
|
1550
|
+
quality?: number;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** A photo taken or picked through `useCamera()`, normalised across hosts. */
|
|
1554
|
+
export interface CameraAsset {
|
|
1555
|
+
/** Displayable source — `<Image source={{ uri }} />` / `<img src>`. */
|
|
1556
|
+
uri: string;
|
|
1557
|
+
/** File name, derived from the source when the host supplies none. */
|
|
1558
|
+
name: string;
|
|
1559
|
+
mimeType: string;
|
|
1560
|
+
width: number | null;
|
|
1561
|
+
height: number | null;
|
|
1562
|
+
/** Bytes, when the host reports it. */
|
|
1563
|
+
size: number | null;
|
|
1564
|
+
/**
|
|
1565
|
+
* Ready-to-upload part — a `File` on web, `{ uri, name, type }` on native.
|
|
1566
|
+
* Append it to a FormData and hand that to `ctx.assets.upload(...)`.
|
|
1567
|
+
*/
|
|
1568
|
+
file: unknown;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
export interface CameraResult {
|
|
1572
|
+
/** The most recent asset, or null before the first capture / after reset. */
|
|
1573
|
+
asset: CameraAsset | null;
|
|
1574
|
+
loading: boolean;
|
|
1575
|
+
error: CameraError | null;
|
|
1576
|
+
/** False when the host brokers no camera. */
|
|
1577
|
+
supported: boolean;
|
|
1578
|
+
/** Open the camera. Resolves null if the user dismisses it. */
|
|
1579
|
+
capture(): Promise<CameraAsset | null>;
|
|
1580
|
+
/** Open the photo library. Resolves null if the user dismisses it. */
|
|
1581
|
+
pick(): Promise<CameraAsset | null>;
|
|
1582
|
+
/** Clear `asset` and `error`, releasing the held asset. */
|
|
1583
|
+
reset(): void;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Take a photo or choose one from the device library. Capture is imperative
|
|
1588
|
+
* (call `capture()` / `pick()` from a user gesture; it never opens on mount).
|
|
1589
|
+
* The same hook drives both platforms — the web Player brokers it via a file
|
|
1590
|
+
* input, the Expo export via `expo-image-picker`. Dismissing the picker
|
|
1591
|
+
* resolves `null` rather than rejecting; a genuine failure rejects with a
|
|
1592
|
+
* `CameraError`. Safe to call on a host that brokers no camera: `supported` is
|
|
1593
|
+
* then false, so gate the camera button on it.
|
|
1594
|
+
*/
|
|
1595
|
+
export function useCamera(options?: CameraOptions): CameraResult;
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Error surfaced by `useCamera()` — thrown by `capture()` / `pick()` and stored
|
|
1599
|
+
* in the hook's `error` slot. `code` is a stable categorisation.
|
|
1600
|
+
*/
|
|
1601
|
+
export class CameraError extends Error {
|
|
1602
|
+
code: "PERMISSION_DENIED" | "UNSUPPORTED" | "INTERNAL";
|
|
1603
|
+
constructor(
|
|
1604
|
+
code: CameraError["code"],
|
|
1605
|
+
message: string,
|
|
1606
|
+
opts?: { cause?: unknown },
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1541
1610
|
/**
|
|
1542
1611
|
* Error class thrown by useDatastoreMutation callbacks (and surfaced by
|
|
1543
1612
|
* useDatastoreQuery in its `error` slot). The `code` is a stable
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.113.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-camera.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"
|