@colixsystems/widget-sdk 0.228.0 → 0.230.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 +34 -2
- package/dist/contract.cjs +22 -4
- package/dist/contract.js +22 -4
- package/dist/form-control-tokens.js +20 -0
- package/dist/hooks.js +32 -5
- package/dist/index.d.ts +17 -0
- package/dist/linter.cjs +88 -0
- package/dist/linter.js +88 -0
- package/dist/select-view.js +4 -2
- package/dist/text-field-view.js +9 -5
- package/package.json +1 -1
- package/src/contract.js +22 -4
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
45
45
|
| **CORE** | `useGeolocation(options)` (optional: options) | `{ latitude, longitude, accuracy, loading, error, getCurrentPosition, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch }` | `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`). **Background watch (sc-6450)** — `startBackgroundWatch({ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs })` keeps positions arriving while the app is backgrounded; `stopBackgroundWatch()` releases it. NATIVE-ONLY and opt-in per app: gate the control on `backgroundSupported` (false on web, and in an export whose workspace did not opt in). The watch outlives the widget's mount, and its positions land in the same `latitude`/`longitude`/`accuracy` slots. |
|
|
46
46
|
| **CORE** | `useSpeechToText(options)` (optional: 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`). |
|
|
47
47
|
| **CORE** | `useCamera(options)` (optional: 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 (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
|
|
48
|
-
| **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. |
|
|
48
|
+
| **CORE** | `useI18n()` | `{ t, locale, languages, canSetLocale, setLocale }` | `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. `languages` lists the app's active languages (`[{ code, name, is_base }]`); `setLocale(code)` switches the whole app live — saved to a signed-in user's account, kept on the device for a visitor, `null` for the app default. Rejects with `.code` `INVALID_LOCALE` \| `UNSUPPORTED`; `canSetLocale` is false where the host cannot switch (the Builder canvas). |
|
|
49
49
|
| **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. |
|
|
50
50
|
| **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
|
|
51
51
|
| **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". |
|
|
@@ -84,7 +84,39 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
84
|
|
|
85
85
|
## Status
|
|
86
86
|
|
|
87
|
-
`v0.
|
|
87
|
+
`v0.230.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**.
|
|
88
|
+
|
|
89
|
+
### What's new in 0.230.0 (contract 1.185.0)
|
|
90
|
+
|
|
91
|
+
**A widget can switch the app's language — `useI18n().setLocale(code)`.** `useI18n()` now also returns `languages`, the app's active languages as `[{ code, name, is_base }]`, and `setLocale(code)`, which switches the whole app to that language at once, with no reload. A signed-in user's choice is saved to their account, so it follows them to other devices; a visitor who is not signed in keeps it on this device. `setLocale(null)` returns to the app's default language. It rejects with an `Error` whose `.code` is `INVALID_LOCALE` (not one of `languages`) or `UNSUPPORTED` (the host cannot switch — the Builder canvas). Gate your controls on `canSetLocale` and render them disabled when it is false. The same on the web Player and the exported app.
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
import { useI18n, Pressable, Text } from "@colixsystems/widget-sdk";
|
|
95
|
+
|
|
96
|
+
export default function Languages() {
|
|
97
|
+
const { languages, locale, setLocale, canSetLocale } = useI18n();
|
|
98
|
+
return languages.map((l) => (
|
|
99
|
+
<Pressable
|
|
100
|
+
key={l.code}
|
|
101
|
+
disabled={!canSetLocale}
|
|
102
|
+
onPress={() => setLocale(l.code).catch(() => { /* show "try again" */ })}
|
|
103
|
+
>
|
|
104
|
+
<Text>{l.code === locale ? `✓ ${l.name}` : l.name}</Text>
|
|
105
|
+
</Pressable>
|
|
106
|
+
));
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
AppStudio also ships a ready-made **Language picker** built-in widget on the same hook.
|
|
111
|
+
|
|
112
|
+
### What's new in 0.229.0 (contract unchanged)
|
|
113
|
+
|
|
114
|
+
**A field's focus ring and a card's border stay inside the widget.** The hosts clip a widget at its own edge, so anything painted outside it is cut off.
|
|
115
|
+
|
|
116
|
+
- **`<TextField>` and the `<Select>` trigger draw focus inside their own box.** A focused field shows a 2px ring in the accent: its border plus an inset 1px `boxShadow`, in the error colour while the field is invalid. On the web it turns the browser's outer focus outline off, because that outline sat outside the field and was cut off wherever the field touched the widget's edge. `<SearchField>` and the built-in form fields use the same field, so they get it too. The text field also takes `minWidth: 0`, so it shrinks in a row instead of pushing its neighbour past the edge. A caller's `style` still comes last.
|
|
117
|
+
- **New linter rule `full-width-margin-overflow` (severity `warning`, non-blocking).** It flags a style object that sets `width: "100%"` together with a non-zero side margin (`margin`, `marginHorizontal`, `marginLeft`/`Right`/`Start`/`End`, `marginInline`/`InlineStart`/`InlineEnd`; a zero, `undefined` or `null` value is not counted). The percentage is already the parent's whole width, so the margin pushes the box past its parent and the host cuts off its right edge, border included. Drop the width, because a `View` already stretches, and inset with the parent's `padding`. The scan reads one style literal, not the tree, so it stays a warning. Comments are not scanned.
|
|
118
|
+
|
|
119
|
+
`CONTRACT` is unchanged (no new field), and no export changed signature.
|
|
88
120
|
|
|
89
121
|
### What's new in 0.228.0 (contract 1.184.0)
|
|
90
122
|
|
package/dist/contract.cjs
CHANGED
|
@@ -824,9 +824,21 @@ const HOOKS = [
|
|
|
824
824
|
{
|
|
825
825
|
name: "useI18n",
|
|
826
826
|
signature: "useI18n()",
|
|
827
|
+
description:
|
|
828
|
+
"The app's translations and language. t(key, fallback?) resolves an author-written key; locale is the language the app " +
|
|
829
|
+
"renders in. The app user can switch it from a widget: languages lists the app's active languages as " +
|
|
830
|
+
"[{ code, name, is_base }] (empty when the host offers none), and setLocale(code) switches the WHOLE app live, with no " +
|
|
831
|
+
"reload. A signed-in user's choice is saved to their account; a visitor who is not signed in keeps it on this device. " +
|
|
832
|
+
"setLocale(null) returns to the app's default language. setLocale resolves once the choice is saved and rejects with " +
|
|
833
|
+
"an Error whose .code is INVALID_LOCALE (the code is not one of languages) or UNSUPPORTED (this host cannot switch, " +
|
|
834
|
+
"e.g. the Builder canvas) — always catch it. canSetLocale is false on such a host: render the choices disabled then, never a dead button. " +
|
|
835
|
+
"Identical on web (Player) and the Expo export.",
|
|
827
836
|
returnShape: {
|
|
828
837
|
t: "(key: string, fallback?: string) => string",
|
|
829
838
|
locale: "string",
|
|
839
|
+
languages: "Array<{ code: string, name: string, is_base: boolean }>",
|
|
840
|
+
canSetLocale: "boolean",
|
|
841
|
+
setLocale: "(code: string | null) => Promise<void> // rejects with Error .code INVALID_LOCALE | UNSUPPORTED",
|
|
830
842
|
},
|
|
831
843
|
requiredContextSlice: ["i18n.t", "i18n.locale"],
|
|
832
844
|
scopes: null,
|
|
@@ -3924,12 +3936,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3924
3936
|
// available:false (same convention as ctx.toast / ctx.device) rather than
|
|
3925
3937
|
// throwing at render. Both the web Player and the Expo export inject it.
|
|
3926
3938
|
description:
|
|
3927
|
-
"{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
|
|
3939
|
+
"{ t(key, fallback?), locale, translate?, languages?, setLocale? } — `translate` is the injected " +
|
|
3928
3940
|
"@colixsystems/translation-client ({ translate(body), status() }) that backs " +
|
|
3929
|
-
"useTranslate() for user-generated content."
|
|
3941
|
+
"useTranslate() for user-generated content. `languages` ([{ code, name, is_base }]) and " +
|
|
3942
|
+
"`setLocale(code)` back useI18n()'s language switch (sc-11482); a host that cannot " +
|
|
3943
|
+
"switch omits setLocale.",
|
|
3930
3944
|
required: true,
|
|
3931
3945
|
fields: { t: "function", locale: "string" },
|
|
3932
|
-
optionalFields: { translate: "object" },
|
|
3946
|
+
optionalFields: { translate: "object", languages: "array", setLocale: "function" },
|
|
3933
3947
|
},
|
|
3934
3948
|
logger: {
|
|
3935
3949
|
description:
|
|
@@ -5978,7 +5992,11 @@ const CONTRACT = deepFreeze({
|
|
|
5978
5992
|
// that section's Fit to screen on), and `useFitScreen()` with its
|
|
5979
5993
|
// optional `fitScreen` host hint: the widget is a fit section's surface,
|
|
5980
5994
|
// so it drops its own frame. Existing manifests and hosts read false.
|
|
5981
|
-
|
|
5995
|
+
// 1.185.0: additive (sc-11482) — `useI18n()` also returns `languages`,
|
|
5996
|
+
// `canSetLocale` and `setLocale(code)`, backed by the optional
|
|
5997
|
+
// `i18n.languages` / `i18n.setLocale` host slice: a widget can switch
|
|
5998
|
+
// the app's language. Hosts without them read [] / false.
|
|
5999
|
+
version: "1.185.0",
|
|
5982
6000
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5983
6001
|
hooks: HOOKS,
|
|
5984
6002
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -824,9 +824,21 @@ const HOOKS = [
|
|
|
824
824
|
{
|
|
825
825
|
name: "useI18n",
|
|
826
826
|
signature: "useI18n()",
|
|
827
|
+
description:
|
|
828
|
+
"The app's translations and language. t(key, fallback?) resolves an author-written key; locale is the language the app " +
|
|
829
|
+
"renders in. The app user can switch it from a widget: languages lists the app's active languages as " +
|
|
830
|
+
"[{ code, name, is_base }] (empty when the host offers none), and setLocale(code) switches the WHOLE app live, with no " +
|
|
831
|
+
"reload. A signed-in user's choice is saved to their account; a visitor who is not signed in keeps it on this device. " +
|
|
832
|
+
"setLocale(null) returns to the app's default language. setLocale resolves once the choice is saved and rejects with " +
|
|
833
|
+
"an Error whose .code is INVALID_LOCALE (the code is not one of languages) or UNSUPPORTED (this host cannot switch, " +
|
|
834
|
+
"e.g. the Builder canvas) — always catch it. canSetLocale is false on such a host: render the choices disabled then, never a dead button. " +
|
|
835
|
+
"Identical on web (Player) and the Expo export.",
|
|
827
836
|
returnShape: {
|
|
828
837
|
t: "(key: string, fallback?: string) => string",
|
|
829
838
|
locale: "string",
|
|
839
|
+
languages: "Array<{ code: string, name: string, is_base: boolean }>",
|
|
840
|
+
canSetLocale: "boolean",
|
|
841
|
+
setLocale: "(code: string | null) => Promise<void> // rejects with Error .code INVALID_LOCALE | UNSUPPORTED",
|
|
830
842
|
},
|
|
831
843
|
requiredContextSlice: ["i18n.t", "i18n.locale"],
|
|
832
844
|
scopes: null,
|
|
@@ -3920,12 +3932,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3920
3932
|
// available:false (same convention as ctx.toast / ctx.device) rather than
|
|
3921
3933
|
// throwing at render. Both the web Player and the Expo export inject it.
|
|
3922
3934
|
description:
|
|
3923
|
-
"{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
|
|
3935
|
+
"{ t(key, fallback?), locale, translate?, languages?, setLocale? } — `translate` is the injected " +
|
|
3924
3936
|
"@colixsystems/translation-client ({ translate(body), status() }) that backs " +
|
|
3925
|
-
"useTranslate() for user-generated content."
|
|
3937
|
+
"useTranslate() for user-generated content. `languages` ([{ code, name, is_base }]) and " +
|
|
3938
|
+
"`setLocale(code)` back useI18n()'s language switch (sc-11482); a host that cannot " +
|
|
3939
|
+
"switch omits setLocale.",
|
|
3926
3940
|
required: true,
|
|
3927
3941
|
fields: { t: "function", locale: "string" },
|
|
3928
|
-
optionalFields: { translate: "object" },
|
|
3942
|
+
optionalFields: { translate: "object", languages: "array", setLocale: "function" },
|
|
3929
3943
|
},
|
|
3930
3944
|
logger: {
|
|
3931
3945
|
description:
|
|
@@ -5974,7 +5988,11 @@ const CONTRACT = deepFreeze({
|
|
|
5974
5988
|
// that section's Fit to screen on), and `useFitScreen()` with its
|
|
5975
5989
|
// optional `fitScreen` host hint: the widget is a fit section's surface,
|
|
5976
5990
|
// so it drops its own frame. Existing manifests and hosts read false.
|
|
5977
|
-
|
|
5991
|
+
// 1.185.0: additive (sc-11482) — `useI18n()` also returns `languages`,
|
|
5992
|
+
// `canSetLocale` and `setLocale(code)`, backed by the optional
|
|
5993
|
+
// `i18n.languages` / `i18n.setLocale` host slice: a widget can switch
|
|
5994
|
+
// the app's language. Hosts without them read [] / false.
|
|
5995
|
+
version: "1.185.0",
|
|
5978
5996
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5979
5997
|
hooks: HOOKS,
|
|
5980
5998
|
primitives: PRIMITIVES,
|
|
@@ -71,6 +71,26 @@ export function focusHaloStyle(t, focused, radius) {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Web: a field shell draws its own ring, so the host's outer one stays off.
|
|
75
|
+
export const FIELD_OUTLINE_OFF = Object.freeze({ outlineColor: "transparent" });
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A field shell's state border (`<TextField>`, a `<Select>` trigger). Focus is
|
|
79
|
+
* the border plus an inset 1px ring, 2px in all and inside the box, so a parent
|
|
80
|
+
* clipping at the field's edge cannot cut it. An invalid field rings in the
|
|
81
|
+
* error colour while it is being fixed.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} t — resolved form-control tokens.
|
|
84
|
+
* @param {{focused: boolean, invalid: boolean}} state
|
|
85
|
+
*/
|
|
86
|
+
export function fieldStateStyle(t, { focused, invalid }) {
|
|
87
|
+
if (focused) {
|
|
88
|
+
const color = invalid ? t.errorColor : t.focusColor;
|
|
89
|
+
return { borderColor: color, boxShadow: `inset 0 0 0 1px ${color}` };
|
|
90
|
+
}
|
|
91
|
+
return invalid ? { borderColor: t.errorColor } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
/**
|
|
75
95
|
* The colours a switch hands the platform control. A caller's `trackColor`
|
|
76
96
|
* overrides the theme per state, and iOS, which paints the off track from
|
package/dist/hooks.js
CHANGED
|
@@ -954,17 +954,44 @@ export function useWidgetRoute(initial) {
|
|
|
954
954
|
return [state, setState];
|
|
955
955
|
}
|
|
956
956
|
|
|
957
|
+
const NO_LANGUAGES = Object.freeze([]);
|
|
958
|
+
|
|
959
|
+
function localeError(code, message) {
|
|
960
|
+
const err = new Error(message);
|
|
961
|
+
err.code = code;
|
|
962
|
+
return err;
|
|
963
|
+
}
|
|
964
|
+
|
|
957
965
|
/**
|
|
958
|
-
* Returns { t, locale }. `t(key,
|
|
959
|
-
* the host's translation table and
|
|
960
|
-
* the key is missing. The host's i18n.t
|
|
961
|
-
* form; we degrade gracefully either way.
|
|
966
|
+
* Returns { t, locale, languages, canSetLocale, setLocale }. `t(key,
|
|
967
|
+
* fallback)` resolves `{{t:key}}` against the host's translation table and
|
|
968
|
+
* falls back to `fallback ?? key` when the key is missing. The host's i18n.t
|
|
969
|
+
* may or may not honour the two-arg form; we degrade gracefully either way.
|
|
970
|
+
*
|
|
971
|
+
* sc-11482 — `setLocale(code)` switches the whole app through the host's own
|
|
972
|
+
* language switch (the same one its account screen uses).
|
|
962
973
|
*/
|
|
963
974
|
export function useI18n() {
|
|
964
975
|
const ctx = useWidgetContextOrThrow("useI18n");
|
|
965
976
|
const i18n = ctx.i18n || {};
|
|
966
977
|
const locale = typeof i18n.locale === "string" ? i18n.locale : "en";
|
|
967
978
|
const hostT = typeof i18n.t === "function" ? i18n.t : null;
|
|
979
|
+
const languages = Array.isArray(i18n.languages) ? i18n.languages : NO_LANGUAGES;
|
|
980
|
+
const hostSetLocale = typeof i18n.setLocale === "function" ? i18n.setLocale : null;
|
|
981
|
+
// Validated here so both hosts refuse an unknown code identically.
|
|
982
|
+
const setLocale = useCallback(
|
|
983
|
+
async (code) => {
|
|
984
|
+
if (!hostSetLocale) {
|
|
985
|
+
throw localeError("UNSUPPORTED", "The language cannot be changed here.");
|
|
986
|
+
}
|
|
987
|
+
const next = typeof code === "string" && code ? code : null;
|
|
988
|
+
if (next !== null && !languages.some((l) => l && l.code === next)) {
|
|
989
|
+
throw localeError("INVALID_LOCALE", `"${next}" is not one of this app's languages.`);
|
|
990
|
+
}
|
|
991
|
+
await hostSetLocale(next);
|
|
992
|
+
},
|
|
993
|
+
[hostSetLocale, languages],
|
|
994
|
+
);
|
|
968
995
|
// REQ-L10N-WIDGET: the widget's own manifest translations are stored in the
|
|
969
996
|
// tenant dictionary under `widget.<id>.<key>`. Derive the namespace from the
|
|
970
997
|
// host-provided widget id so the author calls `t("greeting")` and never
|
|
@@ -1022,7 +1049,7 @@ export function useI18n() {
|
|
|
1022
1049
|
},
|
|
1023
1050
|
[hostT, widgetId],
|
|
1024
1051
|
);
|
|
1025
|
-
return { t, locale };
|
|
1052
|
+
return { t, locale, languages, canSetLocale: hostSetLocale !== null, setLocale };
|
|
1026
1053
|
}
|
|
1027
1054
|
|
|
1028
1055
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1961,9 +1961,26 @@ export function useAssetsByTag(
|
|
|
1961
1961
|
refetch(): Promise<void>;
|
|
1962
1962
|
};
|
|
1963
1963
|
|
|
1964
|
+
/** One of the app's active languages, as `useI18n().languages` lists it. */
|
|
1965
|
+
export interface AppLanguage {
|
|
1966
|
+
code: string;
|
|
1967
|
+
name: string;
|
|
1968
|
+
is_base: boolean;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* The app's translations and language. `setLocale(code)` switches the whole
|
|
1973
|
+
* app live: a signed-in user's choice is saved to their account, a
|
|
1974
|
+
* visitor's stays on the device, and `null` returns to the app's default.
|
|
1975
|
+
* Rejects with an Error whose `.code` is `INVALID_LOCALE` or `UNSUPPORTED`
|
|
1976
|
+
* (`canSetLocale` is false on a host that cannot switch).
|
|
1977
|
+
*/
|
|
1964
1978
|
export function useI18n(): {
|
|
1965
1979
|
locale: string;
|
|
1966
1980
|
t(key: string, fallback?: string): string;
|
|
1981
|
+
languages: AppLanguage[];
|
|
1982
|
+
canSetLocale: boolean;
|
|
1983
|
+
setLocale(code: string | null): Promise<void>;
|
|
1967
1984
|
};
|
|
1968
1985
|
|
|
1969
1986
|
/**
|
package/dist/linter.cjs
CHANGED
|
@@ -1477,6 +1477,92 @@ function _flexBasisPercentRules(source) {
|
|
|
1477
1477
|
return findings;
|
|
1478
1478
|
}
|
|
1479
1479
|
|
|
1480
|
+
// sc-11277 — `width: "100%"` plus a side margin is wider than its parent.
|
|
1481
|
+
//
|
|
1482
|
+
// The percentage is the parent's whole content width and the margin is added
|
|
1483
|
+
// outside it, so the box overflows by the margin. The hosts clip a widget at
|
|
1484
|
+
// its own edge (the Player's sideways guard, a fixed-height slot), which cuts
|
|
1485
|
+
// off the right edge of a bordered card or input. A `View` already stretches
|
|
1486
|
+
// across a column, so the width is redundant; the inset belongs in the
|
|
1487
|
+
// parent's padding.
|
|
1488
|
+
//
|
|
1489
|
+
// A warning: the scan reads one style literal, not the tree, so a row parent
|
|
1490
|
+
// that absorbs the excess still matches.
|
|
1491
|
+
const _FULL_WIDTH_RE = /\bwidth\s*:\s*(["'`])100%\1/g;
|
|
1492
|
+
const _SIDE_MARGIN_KEY_RE =
|
|
1493
|
+
/\bmargin(?:Horizontal|Left|Right|Start|End|Inline(?:Start|End)?)?\s*:/g;
|
|
1494
|
+
const _ZERO_VALUE_RE =
|
|
1495
|
+
/^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
|
|
1496
|
+
|
|
1497
|
+
// The innermost `{ … }` around `at`, as [start, end); null outside any.
|
|
1498
|
+
function _enclosingObject(code, at) {
|
|
1499
|
+
let start = -1;
|
|
1500
|
+
for (let i = at - 1, depth = 0; i >= 0; i--) {
|
|
1501
|
+
if (code[i] === "}") depth++;
|
|
1502
|
+
else if (code[i] === "{" && depth-- === 0) {
|
|
1503
|
+
start = i;
|
|
1504
|
+
break;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
if (start < 0) return null;
|
|
1508
|
+
for (let j = at, depth = 0; j < code.length; j++) {
|
|
1509
|
+
if (code[j] === "{") depth++;
|
|
1510
|
+
else if (code[j] === "}" && depth-- === 0) return [start, j + 1];
|
|
1511
|
+
}
|
|
1512
|
+
return null;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// The object's own keys only: a nested object's margin is not this box's.
|
|
1516
|
+
// Blanked rather than cut, so an offset here is the same offset in the source.
|
|
1517
|
+
function _ownKeysMask(structure, [start, end]) {
|
|
1518
|
+
let body = structure.slice(start, end);
|
|
1519
|
+
let previous;
|
|
1520
|
+
do {
|
|
1521
|
+
previous = body;
|
|
1522
|
+
body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
|
|
1523
|
+
} while (body !== previous);
|
|
1524
|
+
return body;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function _hasSideMargin(code, structure, span) {
|
|
1528
|
+
const keys = _ownKeysMask(structure, span);
|
|
1529
|
+
_SIDE_MARGIN_KEY_RE.lastIndex = 0;
|
|
1530
|
+
let key;
|
|
1531
|
+
while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
|
|
1532
|
+
const valueAt = span[0] + key.index + key[0].length;
|
|
1533
|
+
if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
|
|
1534
|
+
}
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function _fullWidthMarginRules(source) {
|
|
1539
|
+
const findings = [];
|
|
1540
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
1541
|
+
// Strings blanked, so a brace inside a literal never unbalances the match.
|
|
1542
|
+
const structure = _stripNonCode(source);
|
|
1543
|
+
const sourceLines = source.split(/\r?\n/);
|
|
1544
|
+
_FULL_WIDTH_RE.lastIndex = 0;
|
|
1545
|
+
let hit;
|
|
1546
|
+
while ((hit = _FULL_WIDTH_RE.exec(code))) {
|
|
1547
|
+
const span = _enclosingObject(structure, hit.index);
|
|
1548
|
+
if (!span) continue;
|
|
1549
|
+
if (!_hasSideMargin(code, structure, span)) continue;
|
|
1550
|
+
const line = code.slice(0, hit.index).split(/\r?\n/).length;
|
|
1551
|
+
findings.push({
|
|
1552
|
+
rule: "full-width-margin-overflow",
|
|
1553
|
+
severity: "warning",
|
|
1554
|
+
label:
|
|
1555
|
+
`width: "100%" plus a side margin is wider than the parent by that ` +
|
|
1556
|
+
`margin, so the host clips the box's right edge and its border. Drop ` +
|
|
1557
|
+
`the width (a View already stretches) or move the inset to the ` +
|
|
1558
|
+
`parent's padding.`,
|
|
1559
|
+
line,
|
|
1560
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
return findings;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1480
1566
|
// sc-4913 — a measured frame INCLUDES the element's own padding, so cells sized
|
|
1481
1567
|
// from it overflow the content box and the last one wraps into an empty column.
|
|
1482
1568
|
// Mirror of linter.js (see there for the full rationale).
|
|
@@ -2100,6 +2186,8 @@ function lintSource(source, options) {
|
|
|
2100
2186
|
// sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a
|
|
2101
2187
|
// column it claims the parent's whole height and overflows.
|
|
2102
2188
|
findings.push(..._flexBasisPercentRules(source));
|
|
2189
|
+
// sc-11277 — soft warning: a full-width box with a side margin is clipped.
|
|
2190
|
+
findings.push(..._fullWidthMarginRules(source));
|
|
2103
2191
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
2104
2192
|
// padding wraps the last grid column into an empty one.
|
|
2105
2193
|
findings.push(..._measuredPaddingRules(source));
|
package/dist/linter.js
CHANGED
|
@@ -1518,6 +1518,92 @@ function _flexBasisPercentRules(source) {
|
|
|
1518
1518
|
return findings;
|
|
1519
1519
|
}
|
|
1520
1520
|
|
|
1521
|
+
// sc-11277 — `width: "100%"` plus a side margin is wider than its parent.
|
|
1522
|
+
//
|
|
1523
|
+
// The percentage is the parent's whole content width and the margin is added
|
|
1524
|
+
// outside it, so the box overflows by the margin. The hosts clip a widget at
|
|
1525
|
+
// its own edge (the Player's sideways guard, a fixed-height slot), which cuts
|
|
1526
|
+
// off the right edge of a bordered card or input. A `View` already stretches
|
|
1527
|
+
// across a column, so the width is redundant; the inset belongs in the
|
|
1528
|
+
// parent's padding.
|
|
1529
|
+
//
|
|
1530
|
+
// A warning: the scan reads one style literal, not the tree, so a row parent
|
|
1531
|
+
// that absorbs the excess still matches.
|
|
1532
|
+
const _FULL_WIDTH_RE = /\bwidth\s*:\s*(["'`])100%\1/g;
|
|
1533
|
+
const _SIDE_MARGIN_KEY_RE =
|
|
1534
|
+
/\bmargin(?:Horizontal|Left|Right|Start|End|Inline(?:Start|End)?)?\s*:/g;
|
|
1535
|
+
const _ZERO_VALUE_RE =
|
|
1536
|
+
/^\s*(?:(["'`]?)-?0(?:\.0*)?(?:px)?\1\s*(?:[,}]|$)|(?:undefined|null)\b)/m;
|
|
1537
|
+
|
|
1538
|
+
// The innermost `{ … }` around `at`, as [start, end); null outside any.
|
|
1539
|
+
function _enclosingObject(code, at) {
|
|
1540
|
+
let start = -1;
|
|
1541
|
+
for (let i = at - 1, depth = 0; i >= 0; i--) {
|
|
1542
|
+
if (code[i] === "}") depth++;
|
|
1543
|
+
else if (code[i] === "{" && depth-- === 0) {
|
|
1544
|
+
start = i;
|
|
1545
|
+
break;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (start < 0) return null;
|
|
1549
|
+
for (let j = at, depth = 0; j < code.length; j++) {
|
|
1550
|
+
if (code[j] === "{") depth++;
|
|
1551
|
+
else if (code[j] === "}" && depth-- === 0) return [start, j + 1];
|
|
1552
|
+
}
|
|
1553
|
+
return null;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// The object's own keys only: a nested object's margin is not this box's.
|
|
1557
|
+
// Blanked rather than cut, so an offset here is the same offset in the source.
|
|
1558
|
+
function _ownKeysMask(structure, [start, end]) {
|
|
1559
|
+
let body = structure.slice(start, end);
|
|
1560
|
+
let previous;
|
|
1561
|
+
do {
|
|
1562
|
+
previous = body;
|
|
1563
|
+
body = body.replace(/(?<!^)\{[^{}]*\}(?!$)/g, (nested) => " ".repeat(nested.length));
|
|
1564
|
+
} while (body !== previous);
|
|
1565
|
+
return body;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function _hasSideMargin(code, structure, span) {
|
|
1569
|
+
const keys = _ownKeysMask(structure, span);
|
|
1570
|
+
_SIDE_MARGIN_KEY_RE.lastIndex = 0;
|
|
1571
|
+
let key;
|
|
1572
|
+
while ((key = _SIDE_MARGIN_KEY_RE.exec(keys))) {
|
|
1573
|
+
const valueAt = span[0] + key.index + key[0].length;
|
|
1574
|
+
if (!_ZERO_VALUE_RE.test(code.slice(valueAt, valueAt + 40))) return true;
|
|
1575
|
+
}
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function _fullWidthMarginRules(source) {
|
|
1580
|
+
const findings = [];
|
|
1581
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
1582
|
+
// Strings blanked, so a brace inside a literal never unbalances the match.
|
|
1583
|
+
const structure = _stripNonCode(source);
|
|
1584
|
+
const sourceLines = source.split(/\r?\n/);
|
|
1585
|
+
_FULL_WIDTH_RE.lastIndex = 0;
|
|
1586
|
+
let hit;
|
|
1587
|
+
while ((hit = _FULL_WIDTH_RE.exec(code))) {
|
|
1588
|
+
const span = _enclosingObject(structure, hit.index);
|
|
1589
|
+
if (!span) continue;
|
|
1590
|
+
if (!_hasSideMargin(code, structure, span)) continue;
|
|
1591
|
+
const line = code.slice(0, hit.index).split(/\r?\n/).length;
|
|
1592
|
+
findings.push({
|
|
1593
|
+
rule: "full-width-margin-overflow",
|
|
1594
|
+
severity: "warning",
|
|
1595
|
+
label:
|
|
1596
|
+
`width: "100%" plus a side margin is wider than the parent by that ` +
|
|
1597
|
+
`margin, so the host clips the box's right edge and its border. Drop ` +
|
|
1598
|
+
`the width (a View already stretches) or move the inset to the ` +
|
|
1599
|
+
`parent's padding.`,
|
|
1600
|
+
line,
|
|
1601
|
+
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
return findings;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1521
1607
|
// sc-4913 — a measured frame INCLUDES the element's own padding.
|
|
1522
1608
|
//
|
|
1523
1609
|
// `onLayout` reports the frame width, and padding sits inside that frame. A
|
|
@@ -2168,6 +2254,8 @@ export function lintSource(source, options) {
|
|
|
2168
2254
|
// sc-7274 — soft warning: a percentage flexBasis sizes the main axis, so in a
|
|
2169
2255
|
// column it claims the parent's whole height and overflows.
|
|
2170
2256
|
findings.push(..._flexBasisPercentRules(source));
|
|
2257
|
+
// sc-11277 — soft warning: a full-width box with a side margin is clipped.
|
|
2258
|
+
findings.push(..._fullWidthMarginRules(source));
|
|
2171
2259
|
// sc-4913 — soft warning: a measured width that includes the widget's own
|
|
2172
2260
|
// padding wraps the last grid column into an empty one.
|
|
2173
2261
|
findings.push(..._measuredPaddingRules(source));
|
package/dist/select-view.js
CHANGED
|
@@ -13,6 +13,8 @@ import { Icon } from "./icon.js";
|
|
|
13
13
|
import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
|
|
14
14
|
import {
|
|
15
15
|
CONTROL_MIN_TARGET,
|
|
16
|
+
FIELD_OUTLINE_OFF,
|
|
17
|
+
fieldStateStyle,
|
|
16
18
|
nextOptionIndex,
|
|
17
19
|
resolveFormControlTokens,
|
|
18
20
|
selectedOption,
|
|
@@ -253,8 +255,8 @@ export function makeSelect(rn) {
|
|
|
253
255
|
borderRadius: t.radius,
|
|
254
256
|
backgroundColor: t.background,
|
|
255
257
|
},
|
|
256
|
-
|
|
257
|
-
|
|
258
|
+
FIELD_OUTLINE_OFF,
|
|
259
|
+
fieldStateStyle(t, { focused, invalid }),
|
|
258
260
|
style,
|
|
259
261
|
],
|
|
260
262
|
},
|
package/dist/text-field-view.js
CHANGED
|
@@ -6,7 +6,11 @@ import React from "react";
|
|
|
6
6
|
import { useHostTheme } from "./hooks.js";
|
|
7
7
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
8
8
|
import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
FIELD_OUTLINE_OFF,
|
|
11
|
+
fieldStateStyle,
|
|
12
|
+
resolveFormControlTokens,
|
|
13
|
+
} from "./form-control-tokens.js";
|
|
10
14
|
import {
|
|
11
15
|
errorMessage,
|
|
12
16
|
hasNote,
|
|
@@ -45,7 +49,6 @@ export function makeTextField(rn) {
|
|
|
45
49
|
...others
|
|
46
50
|
} = props;
|
|
47
51
|
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
48
|
-
// Native draws no focus ring, so the border itself shows focus.
|
|
49
52
|
const [focused, setFocused] = React.useState(false);
|
|
50
53
|
const overrides = {};
|
|
51
54
|
const rest = {};
|
|
@@ -80,10 +83,11 @@ export function makeTextField(rn) {
|
|
|
80
83
|
color: t.textColor,
|
|
81
84
|
fontFamily: t.fontFamily,
|
|
82
85
|
fontSize: t.fontSize,
|
|
86
|
+
// An input will not shrink below its text width in a row otherwise.
|
|
87
|
+
minWidth: 0,
|
|
83
88
|
},
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
invalid ? { borderColor: t.errorColor } : null,
|
|
89
|
+
FIELD_OUTLINE_OFF,
|
|
90
|
+
fieldStateStyle(t, { focused, invalid }),
|
|
87
91
|
// The frame dims a framed field as a whole.
|
|
88
92
|
disabled && !framed ? { opacity: t.disabledOpacity } : null,
|
|
89
93
|
style,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.230.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",
|
package/src/contract.js
CHANGED
|
@@ -824,9 +824,21 @@ const HOOKS = [
|
|
|
824
824
|
{
|
|
825
825
|
name: "useI18n",
|
|
826
826
|
signature: "useI18n()",
|
|
827
|
+
description:
|
|
828
|
+
"The app's translations and language. t(key, fallback?) resolves an author-written key; locale is the language the app " +
|
|
829
|
+
"renders in. The app user can switch it from a widget: languages lists the app's active languages as " +
|
|
830
|
+
"[{ code, name, is_base }] (empty when the host offers none), and setLocale(code) switches the WHOLE app live, with no " +
|
|
831
|
+
"reload. A signed-in user's choice is saved to their account; a visitor who is not signed in keeps it on this device. " +
|
|
832
|
+
"setLocale(null) returns to the app's default language. setLocale resolves once the choice is saved and rejects with " +
|
|
833
|
+
"an Error whose .code is INVALID_LOCALE (the code is not one of languages) or UNSUPPORTED (this host cannot switch, " +
|
|
834
|
+
"e.g. the Builder canvas) — always catch it. canSetLocale is false on such a host: render the choices disabled then, never a dead button. " +
|
|
835
|
+
"Identical on web (Player) and the Expo export.",
|
|
827
836
|
returnShape: {
|
|
828
837
|
t: "(key: string, fallback?: string) => string",
|
|
829
838
|
locale: "string",
|
|
839
|
+
languages: "Array<{ code: string, name: string, is_base: boolean }>",
|
|
840
|
+
canSetLocale: "boolean",
|
|
841
|
+
setLocale: "(code: string | null) => Promise<void> // rejects with Error .code INVALID_LOCALE | UNSUPPORTED",
|
|
830
842
|
},
|
|
831
843
|
requiredContextSlice: ["i18n.t", "i18n.locale"],
|
|
832
844
|
scopes: null,
|
|
@@ -3920,12 +3932,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3920
3932
|
// available:false (same convention as ctx.toast / ctx.device) rather than
|
|
3921
3933
|
// throwing at render. Both the web Player and the Expo export inject it.
|
|
3922
3934
|
description:
|
|
3923
|
-
"{ t(key, fallback?), locale, translate? } — `translate` is the injected " +
|
|
3935
|
+
"{ t(key, fallback?), locale, translate?, languages?, setLocale? } — `translate` is the injected " +
|
|
3924
3936
|
"@colixsystems/translation-client ({ translate(body), status() }) that backs " +
|
|
3925
|
-
"useTranslate() for user-generated content."
|
|
3937
|
+
"useTranslate() for user-generated content. `languages` ([{ code, name, is_base }]) and " +
|
|
3938
|
+
"`setLocale(code)` back useI18n()'s language switch (sc-11482); a host that cannot " +
|
|
3939
|
+
"switch omits setLocale.",
|
|
3926
3940
|
required: true,
|
|
3927
3941
|
fields: { t: "function", locale: "string" },
|
|
3928
|
-
optionalFields: { translate: "object" },
|
|
3942
|
+
optionalFields: { translate: "object", languages: "array", setLocale: "function" },
|
|
3929
3943
|
},
|
|
3930
3944
|
logger: {
|
|
3931
3945
|
description:
|
|
@@ -5974,7 +5988,11 @@ const CONTRACT = deepFreeze({
|
|
|
5974
5988
|
// that section's Fit to screen on), and `useFitScreen()` with its
|
|
5975
5989
|
// optional `fitScreen` host hint: the widget is a fit section's surface,
|
|
5976
5990
|
// so it drops its own frame. Existing manifests and hosts read false.
|
|
5977
|
-
|
|
5991
|
+
// 1.185.0: additive (sc-11482) — `useI18n()` also returns `languages`,
|
|
5992
|
+
// `canSetLocale` and `setLocale(code)`, backed by the optional
|
|
5993
|
+
// `i18n.languages` / `i18n.setLocale` host slice: a widget can switch
|
|
5994
|
+
// the app's language. Hosts without them read [] / false.
|
|
5995
|
+
version: "1.185.0",
|
|
5978
5996
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5979
5997
|
hooks: HOOKS,
|
|
5980
5998
|
primitives: PRIMITIVES,
|