@colixsystems/widget-sdk 0.134.2 → 0.135.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 CHANGED
@@ -70,7 +70,28 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.134.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
+ `v0.135.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
74
+
75
+ ### What's new in 0.135.0 (contract 1.104.0)
76
+
77
+ **NFC tags are vetted, native-only (`react-native-nfc-manager`).** A widget can now read and write NFC tags on the Expo export — NDEF over `NfcManager.requestTechnology(NfcTech.Ndef)`, plus the raw technologies a badge, asset tag or transit card uses: `NfcTech.IsoDep` on both hosts, `NfcTech.MifareClassic` on Android, `NfcTech.MifareIOS` / `FelicaIOS` / `Iso15693IOS` on iOS. (There is no `NfcTech.FeliCa` — check `index.d.ts` before naming one.) This is the last common "tap a physical thing" input the allowlist was missing; barcode and QR already had `useBarcodeScanner()`.
78
+
79
+ - **Native-only, and the browser is why.** Web NFC ships in Chrome on Android alone — Safari, Firefox and every desktop browser omit it — so there is no web build to pair with. Put the import in `widget.native.jsx` and give `widget.web.jsx` a real way in: a typed code field, or the SDK's `useBarcodeScanner()` QR path. The linter's `import-platform-mismatch` enforces the split.
80
+ - **Opt-in per workspace — and `isSupported()` cannot see that.** The export claims the iOS NFC entitlement and the Android `NFC` permission only when the publisher enables the **NFC tags** device capability in Publish settings. `isSupported()` probes the **device** (`readingAvailable` on iOS, `hasSystemFeature` on Android), not the gate: on a capable phone in an app that never asked, it returns `true` and the read then fails — Android throws a `SecurityException` from `registerTagEvent`, iOS cannot open the reader session. So wrap `start()` / `requestTechnology()` in a `try` and render the **same** fallback from the `catch` that `widget.web.jsx` uses. That `catch` is the degradation path.
81
+ - **Clean up the session.** `cancelTechnologyRequest()` in your effect cleanup; an abandoned request leaves the reader sheet up on iOS.
82
+ - **It is a write capability too.** Read with `getNdefMessage()`; write with `writeNdefMessage(bytes)`, `formatNdef(bytes)` (Android only, for a blank tag) and `transceive(bytes)` on the `IsoDep` / `NfcA` / `NfcV` handlers. One capability covers both — Android expresses read and write through the single `android.permission.NFC` — so the workspace owner who enables it is consenting to every widget in the workspace being able to rewrite the tags their users tap. Raw ISO7816 `transceive` on iOS additionally needs `select-identifiers` on the entitlement, which the export does not declare; NDEF read and write work on both hosts.
83
+ - **`makeReadOnly()` is irreversible.** It permanently locks the tag — no later write will ever succeed, on any device, by any app. Call it only where the feature genuinely wants a one-time-programmable tag, and never as a cleanup step after writing.
84
+
85
+ ### What's new in 0.134.2 (contract 1.103.0 — unchanged)
86
+
87
+ **Defect fix: `useImageEditor()` now rejects the same inputs on both hosts, and two blob/state leaks are gone (sc-7205).** Review of the 0.126.0 hook found a real web↔native divergence and two bookkeeping bugs it had inherited from `useCamera`. `CONTRACT.version` does **not** move — the contract already promises identical validation across hosts; this makes the implementation honour it.
88
+
89
+ - **An out-of-bounds crop now rejects `INVALID_ACTION` on web too.** A canvas silently padded the region outside the source with transparent — black once JPEG-encoded — and reported the padded dimensions, while both native platforms threw (and the throw was relabelled `DECODE_FAILED`). Dragging a crop past the edge is the commonest thing a crop UI produces.
90
+ - **`compress` is range-checked on both hosts.** Out of range silently fell back to the browser's default quality on web and threw from `Bitmap.compress` on Android.
91
+ - **`reset()` during an edit no longer wedges `editing` at `true`**, and **an edit still running at unmount now releases its blob**. Both bugs exist in `useCamera` too and are fixed there in the same change (CLAUDE.md §3).
92
+ - A failed `base64` encode no longer leaks its object URL; a derived resize dimension is floored to match the native transformers; the web decode retries without CORS so it accepts the same sources native does at DECODE time (a cross-origin image whose host sends no `Access-Control-Allow-Origin` still taints the canvas and fails at encode with `ENCODE_FAILED`, where native succeeds — the browser's rule, not ours); and `isSupported()` no longer allocates a DOM node per render.
93
+
94
+ The web broker also gains the test file it shipped without — the previous 16 cases all drove a *fake* broker, so none of the transform maths ever ran.
74
95
 
75
96
  ### What's new in 0.134.0 (contract 1.103.0)
76
97
 
package/dist/contract.cjs CHANGED
@@ -1735,8 +1735,11 @@ const HOOKS = [
1735
1735
  "append result.file to a FormData as `file` and pass it to ctx.assets.upload(fd). " +
1736
1736
  "Each action entry carries exactly one of { resize: { width?, height? } } (aspect preserved when only one is given), " +
1737
1737
  "{ crop: { originX, originY, width, height } }, { rotate: degrees } (positive is clockwise), or " +
1738
- "{ flip: \"horizontal\" | \"vertical\" }. options: { format: \"jpeg\" | \"png\" | \"webp\" (default jpeg), " +
1739
- "compress: 0..1 (default 0.8, ignored for png), base64: boolean (off by default it is expensive) }. " +
1738
+ "{ flip: \"horizontal\" | \"vertical\" }. A crop rect must lie INSIDE the surface the earlier actions produced, and " +
1739
+ "compress must be 0..1. Both hosts answer INVALID_ACTION the web Player by checking its own canvas before " +
1740
+ "it draws, the export by mapping what its native transformer throws — so branch on the CODE, not the message. " +
1741
+ "options: { format: \"jpeg\" | \"png\" | \"webp\" (default jpeg), " +
1742
+ "compress: 0..1 (default 0.8; validated on both hosts, and the value itself is ignored for png), base64: boolean (off by default — it is expensive) }. " +
1740
1743
  "Rejects with an ImageEditorError whose .code is one of UNSUPPORTED | INVALID_ACTION | DECODE_FAILED | ENCODE_FAILED | " +
1741
1744
  "INTERNAL. There is deliberately NO `extent` action: it exists only on web, and a web-only capability is the direction " +
1742
1745
  "CLAUDE.md §8 forbids. Check `supported` before rendering an edit control. Identical on web (a canvas in the host, so " +
@@ -2747,6 +2750,13 @@ const VETTED_IMPORTS = [
2747
2750
  description:
2748
2751
  "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx and pair it with a widget.web.jsx reading window.DeviceMotionEvent (accelerationIncludingGravity / rotationRate), the browser API the same hardware exposes. Both hosts need a user gesture before readings start, and iOS Safari additionally needs an explicit DeviceMotionEvent.requestPermission() grant — so gate the reading behind a Pressable, never start it on mount.",
2749
2752
  },
2753
+ {
2754
+ specifier: "react-native-nfc-manager",
2755
+ platforms: ["native"],
2756
+ category: "system",
2757
+ description:
2758
+ "Read and write NFC tags on the Expo export: NDEF records via NfcManager.requestTechnology(NfcTech.Ndef), plus the raw technologies a badge, asset tag or transit card uses - NfcTech.IsoDep on both hosts, NfcTech.MifareClassic on Android, NfcTech.MifareIOS / FelicaIOS / Iso15693IOS on iOS (there is no NfcTech.FeliCa; check index.d.ts before naming one). Read with ndefHandler.getNdefMessage(); WRITE with writeNdefMessage(bytes), formatNdef(bytes) (Android only, for a blank tag) and transceive(bytes) on the IsoDep / NfcA / NfcV handlers. makeReadOnly() PERMANENTLY locks a tag - it cannot be undone and no later write will ever succeed, so call it only where the brief actually asks for a one-time-programmable tag, never as a tidy-up step. Raw ISO7816 transceive on iOS additionally needs select-identifiers on the entitlement, which this export does not declare - NDEF read and write work on both hosts. Always cancelTechnologyRequest() in a cleanup - an abandoned session leaves the reader sheet up on iOS. Native-only because no browser hands the Player a reader: Web NFC exists in Chrome on Android alone (Safari, Firefox and every desktop browser omit it), so there is no web build to pair with. Author it in widget.native.jsx and give widget.web.jsx a real way in instead - a typed code field, or the SDK's useBarcodeScanner() QR path. The radio is ALSO opt-in per workspace: the export claims the iOS NFC entitlement and the Android NFC permission only when the publisher enables the 'nfc' device capability in Publish settings. isSupported() does NOT see that gate - it probes the DEVICE (readingAvailable on iOS, hasSystemFeature on Android), so on a capable phone in an app that never asked it returns true and the read then fails: Android throws a SecurityException from registerTagEvent, iOS cannot open the reader session. So wrap start() / requestTechnology() in a try and render the SAME fallback from the catch that widget.web.jsx uses - that catch IS the degradation path, not a nicety. Pinned at 3.17.2, the current stable release (4.x is still beta); a community package, so Expo bundledNativeModules has no version opinion on it.",
2759
+ },
2750
2760
  {
2751
2761
  specifier: "decimal.js",
2752
2762
  platforms: ["web", "native"],
@@ -3896,7 +3906,14 @@ const CONTRACT = deepFreeze({
3896
3906
  // identically: a line needs a `| --- |` divider to become a table, and an
3897
3907
  // `![alt](src)` image is still read as an image, never a link. Minor bump
3898
3908
  // on the pre-1.0 channel.
3899
- version: "1.103.0",
3909
+ // 1.104.0: additive - `react-native-nfc-manager` joins VETTED_IMPORTS as a
3910
+ // native-only `system` package. Widgets had no way to read a tag at all, so
3911
+ // badge check-in, asset tracking and inventory counts against NFC labels were
3912
+ // unbuildable. Native-only because Web NFC exists only in Chrome on Android -
3913
+ // the web half is a widget.web.jsx typed-code or QR path. Pinned in the
3914
+ // export and gated behind the `nfc` export capability, because its iOS
3915
+ // entitlement obliges every App ID that carries it.
3916
+ version: "1.104.0",
3900
3917
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3901
3918
  hooks: HOOKS,
3902
3919
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1735,8 +1735,11 @@ const HOOKS = [
1735
1735
  "append result.file to a FormData as `file` and pass it to ctx.assets.upload(fd). " +
1736
1736
  "Each action entry carries exactly one of { resize: { width?, height? } } (aspect preserved when only one is given), " +
1737
1737
  "{ crop: { originX, originY, width, height } }, { rotate: degrees } (positive is clockwise), or " +
1738
- "{ flip: \"horizontal\" | \"vertical\" }. options: { format: \"jpeg\" | \"png\" | \"webp\" (default jpeg), " +
1739
- "compress: 0..1 (default 0.8, ignored for png), base64: boolean (off by default it is expensive) }. " +
1738
+ "{ flip: \"horizontal\" | \"vertical\" }. A crop rect must lie INSIDE the surface the earlier actions produced, and " +
1739
+ "compress must be 0..1. Both hosts answer INVALID_ACTION the web Player by checking its own canvas before " +
1740
+ "it draws, the export by mapping what its native transformer throws — so branch on the CODE, not the message. " +
1741
+ "options: { format: \"jpeg\" | \"png\" | \"webp\" (default jpeg), " +
1742
+ "compress: 0..1 (default 0.8; validated on both hosts, and the value itself is ignored for png), base64: boolean (off by default — it is expensive) }. " +
1740
1743
  "Rejects with an ImageEditorError whose .code is one of UNSUPPORTED | INVALID_ACTION | DECODE_FAILED | ENCODE_FAILED | " +
1741
1744
  "INTERNAL. There is deliberately NO `extent` action: it exists only on web, and a web-only capability is the direction " +
1742
1745
  "CLAUDE.md §8 forbids. Check `supported` before rendering an edit control. Identical on web (a canvas in the host, so " +
@@ -2747,6 +2750,13 @@ const VETTED_IMPORTS = [
2747
2750
  description:
2748
2751
  "Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx and pair it with a widget.web.jsx reading window.DeviceMotionEvent (accelerationIncludingGravity / rotationRate), the browser API the same hardware exposes. Both hosts need a user gesture before readings start, and iOS Safari additionally needs an explicit DeviceMotionEvent.requestPermission() grant — so gate the reading behind a Pressable, never start it on mount.",
2749
2752
  },
2753
+ {
2754
+ specifier: "react-native-nfc-manager",
2755
+ platforms: ["native"],
2756
+ category: "system",
2757
+ description:
2758
+ "Read and write NFC tags on the Expo export: NDEF records via NfcManager.requestTechnology(NfcTech.Ndef), plus the raw technologies a badge, asset tag or transit card uses - NfcTech.IsoDep on both hosts, NfcTech.MifareClassic on Android, NfcTech.MifareIOS / FelicaIOS / Iso15693IOS on iOS (there is no NfcTech.FeliCa; check index.d.ts before naming one). Read with ndefHandler.getNdefMessage(); WRITE with writeNdefMessage(bytes), formatNdef(bytes) (Android only, for a blank tag) and transceive(bytes) on the IsoDep / NfcA / NfcV handlers. makeReadOnly() PERMANENTLY locks a tag - it cannot be undone and no later write will ever succeed, so call it only where the brief actually asks for a one-time-programmable tag, never as a tidy-up step. Raw ISO7816 transceive on iOS additionally needs select-identifiers on the entitlement, which this export does not declare - NDEF read and write work on both hosts. Always cancelTechnologyRequest() in a cleanup - an abandoned session leaves the reader sheet up on iOS. Native-only because no browser hands the Player a reader: Web NFC exists in Chrome on Android alone (Safari, Firefox and every desktop browser omit it), so there is no web build to pair with. Author it in widget.native.jsx and give widget.web.jsx a real way in instead - a typed code field, or the SDK's useBarcodeScanner() QR path. The radio is ALSO opt-in per workspace: the export claims the iOS NFC entitlement and the Android NFC permission only when the publisher enables the 'nfc' device capability in Publish settings. isSupported() does NOT see that gate - it probes the DEVICE (readingAvailable on iOS, hasSystemFeature on Android), so on a capable phone in an app that never asked it returns true and the read then fails: Android throws a SecurityException from registerTagEvent, iOS cannot open the reader session. So wrap start() / requestTechnology() in a try and render the SAME fallback from the catch that widget.web.jsx uses - that catch IS the degradation path, not a nicety. Pinned at 3.17.2, the current stable release (4.x is still beta); a community package, so Expo bundledNativeModules has no version opinion on it.",
2759
+ },
2750
2760
  {
2751
2761
  specifier: "decimal.js",
2752
2762
  platforms: ["web", "native"],
@@ -3896,7 +3906,14 @@ const CONTRACT = deepFreeze({
3896
3906
  // identically: a line needs a `| --- |` divider to become a table, and an
3897
3907
  // `![alt](src)` image is still read as an image, never a link. Minor bump
3898
3908
  // on the pre-1.0 channel.
3899
- version: "1.103.0",
3909
+ // 1.104.0: additive - `react-native-nfc-manager` joins VETTED_IMPORTS as a
3910
+ // native-only `system` package. Widgets had no way to read a tag at all, so
3911
+ // badge check-in, asset tracking and inventory counts against NFC labels were
3912
+ // unbuildable. Native-only because Web NFC exists only in Chrome on Android -
3913
+ // the web half is a widget.web.jsx typed-code or QR path. Pinned in the
3914
+ // export and gated behind the `nfc` export capability, because its iOS
3915
+ // entitlement obliges every App ID that carries it.
3916
+ version: "1.104.0",
3900
3917
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3901
3918
  hooks: HOOKS,
3902
3919
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -1519,6 +1519,10 @@ export function useCamera(options) {
1519
1519
  // life of the document, so the hook owns revoking the one it replaced.
1520
1520
  const releaseRef = useRef(null);
1521
1521
  const runRef = useRef(0);
1522
+ // How many requests are still open. `loading` tracks "any request running",
1523
+ // which a run token alone cannot express: reset() bumps the token, so the
1524
+ // superseded request's finally-block could never clear the flag.
1525
+ const inFlightRef = useRef(0);
1522
1526
 
1523
1527
  const supported = Boolean(
1524
1528
  clientRef.current &&
@@ -1539,7 +1543,17 @@ export function useCamera(options) {
1539
1543
  }
1540
1544
  }, []);
1541
1545
 
1542
- useEffect(() => () => release(), [release]);
1546
+ // Bump the token as well as releasing: a request still open at unmount
1547
+ // resolves into the supersede branch (which releases the asset it got)
1548
+ // instead of the success branch, which would write to a dead ref and strand
1549
+ // the blob for the life of the document.
1550
+ useEffect(
1551
+ () => () => {
1552
+ runRef.current += 1;
1553
+ release();
1554
+ },
1555
+ [release],
1556
+ );
1543
1557
 
1544
1558
  const reset = useCallback(() => {
1545
1559
  runRef.current += 1;
@@ -1570,6 +1584,7 @@ export function useCamera(options) {
1570
1584
  setLoading(true);
1571
1585
  setError(null);
1572
1586
  try {
1587
+ inFlightRef.current += 1;
1573
1588
  const next = await client[method](optionsRef.current || {});
1574
1589
  // A reset() or a newer request landed while this one was open — drop
1575
1590
  // the result rather than clobbering what the widget now shows.
@@ -1588,7 +1603,14 @@ export function useCamera(options) {
1588
1603
  if (run === runRef.current) setError(ce);
1589
1604
  throw ce;
1590
1605
  } finally {
1591
- if (run === runRef.current) setLoading(false);
1606
+ inFlightRef.current -= 1;
1607
+ // Clear when the NEWEST request settles (the widget is now showing its
1608
+ // result) or when the last one does. Only the newest would leave the
1609
+ // flag stuck after a reset(); only the last would keep it true while a
1610
+ // superseded straggler is still open.
1611
+ if (run === runRef.current || inFlightRef.current === 0) {
1612
+ setLoading(false);
1613
+ }
1592
1614
  }
1593
1615
  },
1594
1616
  [release],
@@ -1837,6 +1859,9 @@ export function useImageEditor() {
1837
1859
  // life of the document, so the hook owns revoking the one it replaced.
1838
1860
  const releaseRef = useRef(null);
1839
1861
  const runRef = useRef(0);
1862
+ // See useCamera: `editing` means "any edit running", which the run token
1863
+ // alone cannot express once reset() has bumped it.
1864
+ const inFlightRef = useRef(0);
1840
1865
 
1841
1866
  const supported = Boolean(
1842
1867
  clientRef.current &&
@@ -1857,7 +1882,15 @@ export function useImageEditor() {
1857
1882
  }
1858
1883
  }, []);
1859
1884
 
1860
- useEffect(() => () => release(), [release]);
1885
+ // Bump the token as well as releasing, so an edit still open at unmount
1886
+ // takes the supersede branch and its blob is revoked rather than stranded.
1887
+ useEffect(
1888
+ () => () => {
1889
+ runRef.current += 1;
1890
+ release();
1891
+ },
1892
+ [release],
1893
+ );
1861
1894
 
1862
1895
  const reset = useCallback(() => {
1863
1896
  runRef.current += 1;
@@ -1893,6 +1926,7 @@ export function useImageEditor() {
1893
1926
  setEditing(true);
1894
1927
  setError(null);
1895
1928
  try {
1929
+ inFlightRef.current += 1;
1896
1930
  const next = await client.edit(
1897
1931
  uri,
1898
1932
  Array.isArray(actions) ? actions : [],
@@ -1915,7 +1949,14 @@ export function useImageEditor() {
1915
1949
  if (run === runRef.current) setError(ie);
1916
1950
  throw ie;
1917
1951
  } finally {
1918
- if (run === runRef.current) setEditing(false);
1952
+ inFlightRef.current -= 1;
1953
+ // Clear when the NEWEST edit settles (the widget is now showing its
1954
+ // result) or when the last one does. Only the newest would leave the
1955
+ // flag stuck after a reset(); only the last would keep it true while a
1956
+ // superseded straggler is still open.
1957
+ if (run === runRef.current || inFlightRef.current === 0) {
1958
+ setEditing(false);
1959
+ }
1919
1960
  }
1920
1961
  },
1921
1962
  [release],
package/dist/host.d.ts CHANGED
@@ -308,3 +308,29 @@ export function decodeWidgetRoute(
308
308
  export function collectWidgetRoutes(
309
309
  rawParams: Record<string, string> | null | undefined,
310
310
  ): Record<string, Record<string, WidgetRouteValue>>;
311
+
312
+ /** Longest heading/subheading the sign-in card accepts. */
313
+ export const LOGIN_TEXT_MAX_LENGTH: number;
314
+
315
+ export interface ThemeGradient {
316
+ type: "linear" | "radial";
317
+ from: string;
318
+ to: string;
319
+ angle: number;
320
+ stop?: number;
321
+ }
322
+
323
+ /** The app-wide background gradient, or null when unset/malformed. */
324
+ export function normaliseThemeGradient(raw: unknown): ThemeGradient | null;
325
+
326
+ export interface LoginDesign {
327
+ backgroundColor: string | null;
328
+ backgroundGradient: ThemeGradient | null;
329
+ backgroundImageUrl: string | null;
330
+ backgroundOverlay: string | null;
331
+ heading: string | null;
332
+ subheading: string | null;
333
+ }
334
+
335
+ /** The sign-in screen's authored design, or null when nothing is configured. */
336
+ export function normaliseLoginDesign(raw: unknown): LoginDesign | null;
package/dist/host.js CHANGED
@@ -79,3 +79,14 @@ export {
79
79
  collectWidgetRoutes,
80
80
  sameWidgetRouteValue,
81
81
  } from "./widget-route.js";
82
+
83
+ // REQ-THEME-08 / REQ-PLR-05-CUSTOM (sc-7502): the app background gradient and
84
+ // the sign-in screen's authored design. `normaliseThemeGradient` used to be a
85
+ // hand-mirrored pair (a frontend util and a backend util, each told to stay in
86
+ // lockstep with the other); both now delegate here, which is also what lets the
87
+ // login design reuse it instead of becoming a fourth copy of the same rules.
88
+ export {
89
+ LOGIN_TEXT_MAX_LENGTH,
90
+ normaliseThemeGradient,
91
+ normaliseLoginDesign,
92
+ } from "./theme-background.js";
@@ -0,0 +1,138 @@
1
+ // CommonJS mirror of theme-background.js — the compiler (Expo export) and the
2
+ // Mason build runner are CJS and must resolve an app background and a login
3
+ // design against the SAME rules the web hosts apply, or the two renders diverge.
4
+ //
5
+ // The BODY below is copied VERBATIM from theme-background.js; only the module
6
+ // syntax differs. theme-background-parity.test.js pins both facts — identical
7
+ // bodies AND identical behaviour over a shared case table — so drift fails CI.
8
+
9
+ // REQ-THEME-08 / REQ-PLR-05-CUSTOM — the ONE implementation of the app's
10
+ // background gradient and of the sign-in screen's design, shared by both hosts.
11
+ //
12
+ // Host-integration surface, re-exported from `host.js`: consumed only by the
13
+ // platform hosts that render an app (the web Player / Studio Design page, and
14
+ // the compiler that bakes the Expo export), never by a widget author.
15
+ //
16
+ // `normaliseThemeGradient` was a hand-mirrored PAIR — `frontend/src/utils/
17
+ // theme.js` and `backend/src/core/utils/theme-gradient.ts`, each carrying a
18
+ // "keep the two in lockstep" comment. Both now delegate here (CLAUDE.md §3),
19
+ // which is also what lets the login design below reuse it rather than become a
20
+ // fourth copy of the same rules.
21
+
22
+ // The compiler's own `_HEX_COLOR`: 3-, 6- or 8-digit. Only the 8-digit form
23
+ // carries alpha, which is what makes a tint a tint.
24
+ const HEX_ANY = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
25
+ // The gradient stops predate alpha support and stay 3/6-digit (REQ-THEME-17:
26
+ // the app background is read at full strength).
27
+ const HEX_OPAQUE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
28
+
29
+ // Long enough for a tagline, short enough that it cannot become a payload or
30
+ // blow out the card. The Studio counts against the same constant.
31
+ const LOGIN_TEXT_MAX_LENGTH = 120;
32
+
33
+ /**
34
+ * Shape: { type: "linear"|"radial", from: "#hex", to: "#hex", angle: 0-359,
35
+ * stop: 0-100 } — `stop` (sc-6543) is where the shift begins: the start colour
36
+ * holds solid up to stop% of the gradient line, then blends to `to`. 0 (the
37
+ * default) is the classic full-span blend and is omitted from the result.
38
+ * Returns null when the value is missing or malformed so callers short-circuit.
39
+ */
40
+ function normaliseThemeGradient(raw) {
41
+ if (!raw || typeof raw !== "object") return null;
42
+ const from =
43
+ typeof raw.from === "string" && HEX_OPAQUE.test(raw.from) ? raw.from : null;
44
+ const to =
45
+ typeof raw.to === "string" && HEX_OPAQUE.test(raw.to) ? raw.to : null;
46
+ if (!from || !to) return null;
47
+ const type = raw.type === "radial" ? "radial" : "linear";
48
+ let angle = Number.isFinite(raw.angle) ? Math.round(raw.angle) : 180;
49
+ angle = ((angle % 360) + 360) % 360;
50
+ const stop = Number.isFinite(raw.stop)
51
+ ? Math.min(100, Math.max(0, Math.round(raw.stop)))
52
+ : 0;
53
+ // 0 is the default, so storing it would only be noise every consumer already
54
+ // assumes — and it keeps an existing gradient's stored shape unchanged.
55
+ return stop > 0 ? { type, from, to, angle, stop } : { type, from, to, angle };
56
+ }
57
+
58
+ function hexOrNull(raw) {
59
+ return typeof raw === "string" && HEX_ANY.test(raw.trim())
60
+ ? raw.trim()
61
+ : null;
62
+ }
63
+
64
+ // The URL is author-supplied and reaches a CSS `url(...)` on web and a baked
65
+ // `{ uri: '...' }` literal on native, so the scheme is an allowlist: a
66
+ // `javascript:`/`data:`/`vbscript:` value must never survive to either host.
67
+ function normaliseImageUrl(raw) {
68
+ if (typeof raw !== "string") return null;
69
+ const url = raw.trim();
70
+ if (!url) return null;
71
+ // A workspace upload is served same-origin as an absolute path.
72
+ if (url.startsWith("/") && !url.startsWith("//")) return url;
73
+ return /^https?:\/\/\S+$/i.test(url) ? url : null;
74
+ }
75
+
76
+ // A single tint laid over the image. Alpha lives in the 8-digit hex, so the
77
+ // author controls opacity with the colour itself rather than a second field —
78
+ // the same way `siteHeader.backgroundColor` already floats a header.
79
+ function normaliseOverlay(raw) {
80
+ return hexOrNull(raw);
81
+ }
82
+
83
+ function normaliseText(raw) {
84
+ if (typeof raw !== "string") return null;
85
+ const text = raw.trim();
86
+ if (!text) return null;
87
+ return text.slice(0, LOGIN_TEXT_MAX_LENGTH);
88
+ }
89
+
90
+ /**
91
+ * The sign-in screen's authored design, read off `themeConfig.login`.
92
+ *
93
+ * Every field is optional and every omission means "keep what the app already
94
+ * does": an unset background inherits the app's own `backgroundColor` /
95
+ * `backgroundGradient`, and unset text leaves the card on the workspace name
96
+ * and its stock subtitle. Returns null when NOTHING is configured, so both
97
+ * hosts short-circuit to exactly their pre-sc-7502 render.
98
+ *
99
+ * @returns {{
100
+ * backgroundColor: string|null,
101
+ * backgroundGradient: object|null,
102
+ * backgroundImageUrl: string|null,
103
+ * backgroundOverlay: string|null,
104
+ * heading: string|null,
105
+ * subheading: string|null,
106
+ * }|null}
107
+ */
108
+ function normaliseLoginDesign(raw) {
109
+ if (!raw || typeof raw !== "object") return null;
110
+ // LINEAR ONLY, deliberately. The app background offers radial because App.js
111
+ // can afford the SVG figure that draws it (expo-linear-gradient has none);
112
+ // emitting a second copy of that figure into the login screen would be the
113
+ // duplication §3 forbids. Both hosts therefore support exactly linear here,
114
+ // so the two cannot diverge — radial can follow when the figure is shared.
115
+ const gradient = normaliseThemeGradient(raw.backgroundGradient);
116
+ const design = {
117
+ backgroundColor: hexOrNull(raw.backgroundColor),
118
+ backgroundGradient: gradient ? { ...gradient, type: "linear" } : null,
119
+ // REQ-THEME-10's asset-or-URL contract, as the app logo already uses it: a
120
+ // picked Studio asset wins, the pasted URL is the fallback. Each host
121
+ // resolves the asset id into `...Resolved` before calling — `useAssetUrl`
122
+ // on web, the compiler's asset lookup on native — because neither the SDK
123
+ // nor a pure function can reach the asset table.
124
+ backgroundImageUrl:
125
+ normaliseImageUrl(raw.backgroundImageUrlResolved) ||
126
+ normaliseImageUrl(raw.backgroundImageUrl),
127
+ backgroundOverlay: normaliseOverlay(raw.backgroundOverlay),
128
+ heading: normaliseText(raw.heading),
129
+ subheading: normaliseText(raw.subheading),
130
+ };
131
+ // A tint with no image would dim the app background for no reason the author
132
+ // asked for — it is a treatment OF the image, so it goes with it.
133
+ if (!design.backgroundImageUrl) design.backgroundOverlay = null;
134
+ const configured = Object.values(design).some((value) => value !== null);
135
+ return configured ? design : null;
136
+ }
137
+
138
+ module.exports = { LOGIN_TEXT_MAX_LENGTH, normaliseThemeGradient, normaliseLoginDesign };
@@ -0,0 +1,128 @@
1
+ // REQ-THEME-08 / REQ-PLR-05-CUSTOM — the ONE implementation of the app's
2
+ // background gradient and of the sign-in screen's design, shared by both hosts.
3
+ //
4
+ // Host-integration surface, re-exported from `host.js`: consumed only by the
5
+ // platform hosts that render an app (the web Player / Studio Design page, and
6
+ // the compiler that bakes the Expo export), never by a widget author.
7
+ //
8
+ // `normaliseThemeGradient` was a hand-mirrored PAIR — `frontend/src/utils/
9
+ // theme.js` and `backend/src/core/utils/theme-gradient.ts`, each carrying a
10
+ // "keep the two in lockstep" comment. Both now delegate here (CLAUDE.md §3),
11
+ // which is also what lets the login design below reuse it rather than become a
12
+ // fourth copy of the same rules.
13
+
14
+ // The compiler's own `_HEX_COLOR`: 3-, 6- or 8-digit. Only the 8-digit form
15
+ // carries alpha, which is what makes a tint a tint.
16
+ const HEX_ANY = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
17
+ // The gradient stops predate alpha support and stay 3/6-digit (REQ-THEME-17:
18
+ // the app background is read at full strength).
19
+ const HEX_OPAQUE = /^#[0-9a-f]{3}([0-9a-f]{3})?$/i;
20
+
21
+ // Long enough for a tagline, short enough that it cannot become a payload or
22
+ // blow out the card. The Studio counts against the same constant.
23
+ export const LOGIN_TEXT_MAX_LENGTH = 120;
24
+
25
+ /**
26
+ * Shape: { type: "linear"|"radial", from: "#hex", to: "#hex", angle: 0-359,
27
+ * stop: 0-100 } — `stop` (sc-6543) is where the shift begins: the start colour
28
+ * holds solid up to stop% of the gradient line, then blends to `to`. 0 (the
29
+ * default) is the classic full-span blend and is omitted from the result.
30
+ * Returns null when the value is missing or malformed so callers short-circuit.
31
+ */
32
+ export function normaliseThemeGradient(raw) {
33
+ if (!raw || typeof raw !== "object") return null;
34
+ const from =
35
+ typeof raw.from === "string" && HEX_OPAQUE.test(raw.from) ? raw.from : null;
36
+ const to =
37
+ typeof raw.to === "string" && HEX_OPAQUE.test(raw.to) ? raw.to : null;
38
+ if (!from || !to) return null;
39
+ const type = raw.type === "radial" ? "radial" : "linear";
40
+ let angle = Number.isFinite(raw.angle) ? Math.round(raw.angle) : 180;
41
+ angle = ((angle % 360) + 360) % 360;
42
+ const stop = Number.isFinite(raw.stop)
43
+ ? Math.min(100, Math.max(0, Math.round(raw.stop)))
44
+ : 0;
45
+ // 0 is the default, so storing it would only be noise every consumer already
46
+ // assumes — and it keeps an existing gradient's stored shape unchanged.
47
+ return stop > 0 ? { type, from, to, angle, stop } : { type, from, to, angle };
48
+ }
49
+
50
+ function hexOrNull(raw) {
51
+ return typeof raw === "string" && HEX_ANY.test(raw.trim())
52
+ ? raw.trim()
53
+ : null;
54
+ }
55
+
56
+ // The URL is author-supplied and reaches a CSS `url(...)` on web and a baked
57
+ // `{ uri: '...' }` literal on native, so the scheme is an allowlist: a
58
+ // `javascript:`/`data:`/`vbscript:` value must never survive to either host.
59
+ function normaliseImageUrl(raw) {
60
+ if (typeof raw !== "string") return null;
61
+ const url = raw.trim();
62
+ if (!url) return null;
63
+ // A workspace upload is served same-origin as an absolute path.
64
+ if (url.startsWith("/") && !url.startsWith("//")) return url;
65
+ return /^https?:\/\/\S+$/i.test(url) ? url : null;
66
+ }
67
+
68
+ // A single tint laid over the image. Alpha lives in the 8-digit hex, so the
69
+ // author controls opacity with the colour itself rather than a second field —
70
+ // the same way `siteHeader.backgroundColor` already floats a header.
71
+ function normaliseOverlay(raw) {
72
+ return hexOrNull(raw);
73
+ }
74
+
75
+ function normaliseText(raw) {
76
+ if (typeof raw !== "string") return null;
77
+ const text = raw.trim();
78
+ if (!text) return null;
79
+ return text.slice(0, LOGIN_TEXT_MAX_LENGTH);
80
+ }
81
+
82
+ /**
83
+ * The sign-in screen's authored design, read off `themeConfig.login`.
84
+ *
85
+ * Every field is optional and every omission means "keep what the app already
86
+ * does": an unset background inherits the app's own `backgroundColor` /
87
+ * `backgroundGradient`, and unset text leaves the card on the workspace name
88
+ * and its stock subtitle. Returns null when NOTHING is configured, so both
89
+ * hosts short-circuit to exactly their pre-sc-7502 render.
90
+ *
91
+ * @returns {{
92
+ * backgroundColor: string|null,
93
+ * backgroundGradient: object|null,
94
+ * backgroundImageUrl: string|null,
95
+ * backgroundOverlay: string|null,
96
+ * heading: string|null,
97
+ * subheading: string|null,
98
+ * }|null}
99
+ */
100
+ export function normaliseLoginDesign(raw) {
101
+ if (!raw || typeof raw !== "object") return null;
102
+ // LINEAR ONLY, deliberately. The app background offers radial because App.js
103
+ // can afford the SVG figure that draws it (expo-linear-gradient has none);
104
+ // emitting a second copy of that figure into the login screen would be the
105
+ // duplication §3 forbids. Both hosts therefore support exactly linear here,
106
+ // so the two cannot diverge — radial can follow when the figure is shared.
107
+ const gradient = normaliseThemeGradient(raw.backgroundGradient);
108
+ const design = {
109
+ backgroundColor: hexOrNull(raw.backgroundColor),
110
+ backgroundGradient: gradient ? { ...gradient, type: "linear" } : null,
111
+ // REQ-THEME-10's asset-or-URL contract, as the app logo already uses it: a
112
+ // picked Studio asset wins, the pasted URL is the fallback. Each host
113
+ // resolves the asset id into `...Resolved` before calling — `useAssetUrl`
114
+ // on web, the compiler's asset lookup on native — because neither the SDK
115
+ // nor a pure function can reach the asset table.
116
+ backgroundImageUrl:
117
+ normaliseImageUrl(raw.backgroundImageUrlResolved) ||
118
+ normaliseImageUrl(raw.backgroundImageUrl),
119
+ backgroundOverlay: normaliseOverlay(raw.backgroundOverlay),
120
+ heading: normaliseText(raw.heading),
121
+ subheading: normaliseText(raw.subheading),
122
+ };
123
+ // A tint with no image would dim the app background for no reason the author
124
+ // asked for — it is a treatment OF the image, so it goes with it.
125
+ if (!design.backgroundImageUrl) design.backgroundOverlay = null;
126
+ const configured = Object.values(design).some((value) => value !== null);
127
+ return configured ? design : null;
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.134.2",
3
+ "version": "0.135.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",