@colixsystems/widget-sdk 0.117.0 → 0.119.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
@@ -35,7 +35,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
35
35
  | **CORE** | `useRefresh(handler)` | `void` | `ctx.refresh.subscribe` — no scope. Subscribes the handler to the page-level refresh tick (pull-to-refresh on mobile). Handler may return a Promise — the host waits for `allSettled` before clearing the spinner. The three datastore hooks auto-subscribe their own `refetch`; widgets only call this directly to re-run non-datastore work. No-op on a host that doesn't implement refresh. |
36
36
  | **CORE** | `useClipboard()` | `{ copy, paste, hasContent }` | platform clipboard (web `navigator.clipboard` / native `expo-clipboard`); rejects with `ClipboardError` — no scope |
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
- | **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`). |
38
+ | **CORE** | `useGeolocation(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. |
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
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`). |
41
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. |
@@ -70,7 +70,61 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
70
70
 
71
71
  ## Status
72
72
 
73
- `v0.117.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.119.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.119.0 (contract 1.91.0)
76
+
77
+ **`useGeolocation()` can now track location while the app is BACKGROUNDED (sc-6450).** The hook only ever read a position while the app was in the foreground, so the whole class of field-work apps — delivery tracking, site visits, mileage and timesheet logging — could not be built. Its result gains four members; the existing foreground API is untouched:
78
+
79
+ | Member | Shape | Notes |
80
+ | --- | --- | --- |
81
+ | `backgroundSupported` | `boolean` | **Check this before rendering the control.** |
82
+ | `backgroundWatching` | `boolean` | Whether a watch is running on this device. |
83
+ | `startBackgroundWatch` | `(options?) => Promise<void>` | `options`: `{ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }`. |
84
+ | `stopBackgroundWatch` | `() => Promise<void>` | Releases the OS subscription. |
85
+
86
+ ```jsx
87
+ const { latitude, longitude, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch } = useGeolocation();
88
+
89
+ {backgroundSupported && (
90
+ <Button
91
+ label={backgroundWatching ? "Stop trip" : "Start trip"}
92
+ onPress={() => (backgroundWatching ? stopBackgroundWatch() : startBackgroundWatch({ distanceIntervalMeters: 50 }))}
93
+ />
94
+ )}
95
+ ```
96
+
97
+ The watch is **native-only and opt-in per app**. `backgroundSupported` is `false` on the web Player — a browser tab genuinely cannot track in the background, the same capability-gated honesty `useSpeechToText` applies on Firefox — and it is also `false` in an exported app whose workspace has not enabled background location in **Publishing Settings**. That opt-in exists so an app that never uses the capability declares no background mode and stays clear of the extra store review.
98
+
99
+ Three behaviours to design around. Tracking covers the app **running in the background**; it does not survive the OS terminating the app, so don't promise an unattended log. The watch **outlives the widget's mount** — that is the point — so it is released only by `stopBackgroundWatch()`, never on unmount; `backgroundWatching` is seeded from the host so a remounted widget reports a running watch honestly. And background positions land in the **same** `latitude` / `longitude` / `accuracy` slots as the foreground read, so a widget renders one position regardless of how it arrived.
100
+
101
+ `startBackgroundWatch()` rejects with the existing `GeolocationError`: `.code` `UNSUPPORTED` when the host or build does not offer the capability, `PERMISSION_DENIED` when the user refuses always-on location. On Android 11+ the always-on grant cannot be made from a runtime dialog — the user has to pick "Allow all the time" in system Settings — so treat `PERMISSION_DENIED` as a prompt to explain that, not as a dead end.
102
+
103
+ `backgroundWatching` is a mirror of the host, not of your calls: the OS can end the watch on its own (a permission downgrade, a killed foreground service) and a sibling widget can start or stop it, so render from the flag rather than from whether you called `start`.
104
+
105
+ **Store review:** Apple and Google both require a *visible user benefit* plus a justification for background location. Your app must show the user that tracking is running and let them stop it, and your store listing must explain why the app needs it. A build that turns the opt-in on without that is rejected at review.
106
+
107
+ `CONTRACT.version` → `1.91.0`. Additive — four new result members and six new optional `ctx.device.geolocation` broker members; no existing export changed signature.
108
+ ### What's new in 0.118.0 (contract 1.90.0)
109
+
110
+ **A `styleSchema` field's `default` now actually applies.** Declaring `default` on a style field wrote it into the manifest and nothing ever read it back, so a widget's own styling baseline — and any design saved from the Widget Builder preview — was silently dropped on the next render. The host now resolves it onto `props.style`.
111
+
112
+ It is the **weakest** layer, deliberately: it applies only when nothing above it sets that field.
113
+
114
+ ```
115
+ styleSchema `default` -> palette / components.<scope> -> widgetStyles[manifestId] -> per-instance props.style
116
+ ```
117
+
118
+ So a workspace theme still outranks a widget's own baseline, and a field you leave undefaulted keeps following the theme exactly as before. Nothing changes for a widget that declares no style defaults.
119
+
120
+ The host still does **not** apply style to elements — your widget owns placement and keeps reading `props.style.<field>` (or `useWidgetStyle()`) and applying each value where it chooses:
121
+
122
+ ```jsx
123
+ const style = useWidgetStyle();
124
+ <View style={[styles.card, style.cardBackground && { backgroundColor: style.cardBackground }]}>
125
+ ```
126
+
127
+ Keep `themeDefault` for a fallback that IS a theme token: it stays display-only (a greyed placeholder in the Style panel) so the field tracks the workspace theme. Use `default` for a literal constant your code genuinely falls back to — that value now reaches `props.style`, so it must match what your code applies.
74
128
 
75
129
  ### What's new in 0.117.0 (contract 1.89.0)
76
130
 
package/dist/contract.cjs CHANGED
@@ -1570,7 +1570,17 @@ const HOOKS = [
1570
1570
  "permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to { latitude, longitude, accuracy } " +
1571
1571
  "and stores the same values on the hook; it rejects with a GeolocationError whose .code is one of PERMISSION_DENIED | " +
1572
1572
  "UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL. options pass through to the host ({ enableHighAccuracy, timeout, " +
1573
- "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location).",
1573
+ "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location). " +
1574
+ "BACKGROUND WATCH (sc-6450): startBackgroundWatch(options?) keeps positions arriving while the app is BACKGROUNDED — the " +
1575
+ "field-work case (delivery tracking, site visits, mileage logging) — and stopBackgroundWatch() releases the OS " +
1576
+ "subscription. It is NATIVE-ONLY and opt-in per app: ALWAYS check `backgroundSupported` before rendering the control, " +
1577
+ "because the web Player reports false (a browser tab cannot track in the background) and so does an exported app whose " +
1578
+ "workspace has not enabled background location in Publishing Settings. The watch OUTLIVES the widget mount by design, so " +
1579
+ "it stops ONLY on stopBackgroundWatch(); `backgroundWatching` is seeded from the host so a remounted widget reports it " +
1580
+ "honestly. Background positions land in the SAME latitude/longitude/accuracy slots. startBackgroundWatch rejects with a " +
1581
+ "GeolocationError (.code UNSUPPORTED when the host or build does not offer it, PERMISSION_DENIED when the user refuses " +
1582
+ "always-on location). options: { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }. Tracking continues while the " +
1583
+ "app is running in the BACKGROUND; it does not survive the OS terminating the app, so do not promise an unattended log.",
1574
1584
  returnShape: {
1575
1585
  latitude: "number | null",
1576
1586
  longitude: "number | null",
@@ -1579,6 +1589,13 @@ const HOOKS = [
1579
1589
  error: "GeolocationError | null",
1580
1590
  getCurrentPosition:
1581
1591
  "() => Promise<{ latitude, longitude, accuracy }> // rejects with GeolocationError",
1592
+ backgroundSupported:
1593
+ "boolean // GATE THE CONTROL ON THIS: false on the web Player and in an export that did not opt in",
1594
+ backgroundWatching: "boolean // a background watch is running on this device",
1595
+ startBackgroundWatch:
1596
+ "(options?) => Promise<void> // NATIVE-ONLY; { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }; rejects with GeolocationError",
1597
+ stopBackgroundWatch:
1598
+ "() => Promise<void> // the ONLY release - the watch outlives the widget's mount",
1582
1599
  },
1583
1600
  requiredContextSlice: [],
1584
1601
  scopes: null,
@@ -1967,8 +1984,11 @@ const MANIFEST_SCHEMA = {
1967
1984
  "exposes; the Studio Properties Panel renders a \"Style\" section from " +
1968
1985
  "it. The author's resolved values are delivered to the widget under " +
1969
1986
  "props.style (one object keyed by style-field name); the widget reads " +
1970
- "props.style.<field> and applies each wherever it chooses. The host " +
1971
- "never auto-applies style the widget owns placement.",
1987
+ "props.style.<field> and applies each wherever it chooses. A field's " +
1988
+ "own `default` is resolved onto props.style as the WEAKEST layer " +
1989
+ "(sc-6750): it applies only when no theme layer and no per-instance " +
1990
+ "value sets that field. The host never applies style to elements " +
1991
+ "— the widget owns placement.",
1972
1992
  default: {},
1973
1993
  },
1974
1994
  rendersOwnChrome: {
@@ -2256,7 +2276,10 @@ const WIDGET_CONTEXT_SHAPE = {
2256
2276
  device: {
2257
2277
  description:
2258
2278
  "Optional host-brokered device capabilities. " +
2259
- "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
2279
+ "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }>, " +
2280
+ "isBackgroundSupported() -> boolean, startBackgroundWatch(options?) -> Promise<void>, stopBackgroundWatch() -> Promise<void>, " +
2281
+ "isBackgroundWatching() -> boolean, subscribeBackgroundPositions(cb) -> unsubscribe, " +
2282
+ "subscribeBackgroundWatchState(cb) -> unsubscribe }, " +
2260
2283
  "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
2261
2284
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
2262
2285
  "Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
@@ -2266,7 +2289,14 @@ const WIDGET_CONTEXT_SHAPE = {
2266
2289
  "speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
2267
2290
  "its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
2268
2291
  "camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
2269
- "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
2292
+ "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). " +
2293
+ "sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
2294
+ "did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
2295
+ "expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
2296
+ "sensor and prompt for nothing, so the watch can outlive any one widget mount. The HOST owns whether a watch is running: " +
2297
+ "it is primed asynchronously after a cold relaunch, the OS can end it on its own (a permission downgrade, a killed " +
2298
+ "foreground service), and a sibling widget may start or stop it — so subscribeBackgroundWatchState is how every mounted " +
2299
+ "widget stays truthful, and isBackgroundWatching() is only the synchronous first read.",
2270
2300
  required: false,
2271
2301
  fields: { geolocation: "object", speech: "object", camera: "object" },
2272
2302
  },
@@ -3515,7 +3545,30 @@ const CONTRACT = deepFreeze({
3515
3545
  // web build reads deviceorientation angles rather than real acceleration —
3516
3546
  // the web half is a widget.web.jsx over window.DeviceMotionEvent. Pinned in
3517
3547
  // the compiler's export dependencies, like every other native-module member.
3518
- version: "1.89.0",
3548
+ // 1.90.0: additive (sc-6750) — a `styleSchema` field's declared `default`
3549
+ // now RESOLVES onto `props.style`, as the weakest style layer. It was
3550
+ // written into the manifest and then read by nothing, so an author's saved
3551
+ // colour was silently dropped on the next render. Precedence is unchanged
3552
+ // above it: styleSchema `default` -> palette/`components.<scope>` ->
3553
+ // `widgetStyles[manifestId]` -> per-instance `props.style`, so a workspace
3554
+ // theme still outranks a widget's own baseline and a field the author never
3555
+ // defaulted follows the theme exactly as before. The host still never
3556
+ // applies style to elements — the widget owns placement.
3557
+ // 1.91.0: additive (sc-6450) — the geolocation BACKGROUND watch:
3558
+ // `useGeolocation()` gains backgroundSupported / backgroundWatching /
3559
+ // startBackgroundWatch / stopBackgroundWatch, and the optional
3560
+ // `device.geolocation` broker gains isBackgroundSupported,
3561
+ // startBackgroundWatch, stopBackgroundWatch, isBackgroundWatching,
3562
+ // subscribeBackgroundPositions and subscribeBackgroundWatchState. Field-work
3563
+ // apps — delivery tracking, site visits, mileage — could not be built at all
3564
+ // while a position could only be read in the foreground. Native-only and
3565
+ // opt-in per app: the web Player reports backgroundSupported false because a
3566
+ // tab is suspended once backgrounded, and so does an export whose workspace
3567
+ // did not opt in. The HOST owns whether a watch is running (it is primed
3568
+ // asynchronously after an OS relaunch, the OS can end it, and a sibling
3569
+ // widget can start or stop it), so subscribeBackgroundWatchState is how every
3570
+ // mounted widget stays truthful.
3571
+ version: "1.91.0",
3519
3572
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3520
3573
  hooks: HOOKS,
3521
3574
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1570,7 +1570,17 @@ const HOOKS = [
1570
1570
  "permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to { latitude, longitude, accuracy } " +
1571
1571
  "and stores the same values on the hook; it rejects with a GeolocationError whose .code is one of PERMISSION_DENIED | " +
1572
1572
  "UNAVAILABLE | TIMEOUT | UNSUPPORTED | INTERNAL. options pass through to the host ({ enableHighAccuracy, timeout, " +
1573
- "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location).",
1573
+ "maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location). " +
1574
+ "BACKGROUND WATCH (sc-6450): startBackgroundWatch(options?) keeps positions arriving while the app is BACKGROUNDED — the " +
1575
+ "field-work case (delivery tracking, site visits, mileage logging) — and stopBackgroundWatch() releases the OS " +
1576
+ "subscription. It is NATIVE-ONLY and opt-in per app: ALWAYS check `backgroundSupported` before rendering the control, " +
1577
+ "because the web Player reports false (a browser tab cannot track in the background) and so does an exported app whose " +
1578
+ "workspace has not enabled background location in Publishing Settings. The watch OUTLIVES the widget mount by design, so " +
1579
+ "it stops ONLY on stopBackgroundWatch(); `backgroundWatching` is seeded from the host so a remounted widget reports it " +
1580
+ "honestly. Background positions land in the SAME latitude/longitude/accuracy slots. startBackgroundWatch rejects with a " +
1581
+ "GeolocationError (.code UNSUPPORTED when the host or build does not offer it, PERMISSION_DENIED when the user refuses " +
1582
+ "always-on location). options: { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }. Tracking continues while the " +
1583
+ "app is running in the BACKGROUND; it does not survive the OS terminating the app, so do not promise an unattended log.",
1574
1584
  returnShape: {
1575
1585
  latitude: "number | null",
1576
1586
  longitude: "number | null",
@@ -1579,6 +1589,13 @@ const HOOKS = [
1579
1589
  error: "GeolocationError | null",
1580
1590
  getCurrentPosition:
1581
1591
  "() => Promise<{ latitude, longitude, accuracy }> // rejects with GeolocationError",
1592
+ backgroundSupported:
1593
+ "boolean // GATE THE CONTROL ON THIS: false on the web Player and in an export that did not opt in",
1594
+ backgroundWatching: "boolean // a background watch is running on this device",
1595
+ startBackgroundWatch:
1596
+ "(options?) => Promise<void> // NATIVE-ONLY; { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }; rejects with GeolocationError",
1597
+ stopBackgroundWatch:
1598
+ "() => Promise<void> // the ONLY release - the watch outlives the widget's mount",
1582
1599
  },
1583
1600
  requiredContextSlice: [],
1584
1601
  scopes: null,
@@ -1967,8 +1984,11 @@ const MANIFEST_SCHEMA = {
1967
1984
  "exposes; the Studio Properties Panel renders a \"Style\" section from " +
1968
1985
  "it. The author's resolved values are delivered to the widget under " +
1969
1986
  "props.style (one object keyed by style-field name); the widget reads " +
1970
- "props.style.<field> and applies each wherever it chooses. The host " +
1971
- "never auto-applies style the widget owns placement.",
1987
+ "props.style.<field> and applies each wherever it chooses. A field's " +
1988
+ "own `default` is resolved onto props.style as the WEAKEST layer " +
1989
+ "(sc-6750): it applies only when no theme layer and no per-instance " +
1990
+ "value sets that field. The host never applies style to elements " +
1991
+ "— the widget owns placement.",
1972
1992
  default: {},
1973
1993
  },
1974
1994
  rendersOwnChrome: {
@@ -2256,7 +2276,10 @@ const WIDGET_CONTEXT_SHAPE = {
2256
2276
  device: {
2257
2277
  description:
2258
2278
  "Optional host-brokered device capabilities. " +
2259
- "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }> }, " +
2279
+ "{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }>, " +
2280
+ "isBackgroundSupported() -> boolean, startBackgroundWatch(options?) -> Promise<void>, stopBackgroundWatch() -> Promise<void>, " +
2281
+ "isBackgroundWatching() -> boolean, subscribeBackgroundPositions(cb) -> unsubscribe, " +
2282
+ "subscribeBackgroundWatchState(cb) -> unsubscribe }, " +
2260
2283
  "speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
2261
2284
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
2262
2285
  "Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
@@ -2266,7 +2289,14 @@ const WIDGET_CONTEXT_SHAPE = {
2266
2289
  "speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
2267
2290
  "its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
2268
2291
  "camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
2269
- "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL).",
2292
+ "user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). " +
2293
+ "sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
2294
+ "did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
2295
+ "expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
2296
+ "sensor and prompt for nothing, so the watch can outlive any one widget mount. The HOST owns whether a watch is running: " +
2297
+ "it is primed asynchronously after a cold relaunch, the OS can end it on its own (a permission downgrade, a killed " +
2298
+ "foreground service), and a sibling widget may start or stop it — so subscribeBackgroundWatchState is how every mounted " +
2299
+ "widget stays truthful, and isBackgroundWatching() is only the synchronous first read.",
2270
2300
  required: false,
2271
2301
  fields: { geolocation: "object", speech: "object", camera: "object" },
2272
2302
  },
@@ -3515,7 +3545,30 @@ const CONTRACT = deepFreeze({
3515
3545
  // web build reads deviceorientation angles rather than real acceleration —
3516
3546
  // the web half is a widget.web.jsx over window.DeviceMotionEvent. Pinned in
3517
3547
  // the compiler's export dependencies, like every other native-module member.
3518
- version: "1.89.0",
3548
+ // 1.90.0: additive (sc-6750) — a `styleSchema` field's declared `default`
3549
+ // now RESOLVES onto `props.style`, as the weakest style layer. It was
3550
+ // written into the manifest and then read by nothing, so an author's saved
3551
+ // colour was silently dropped on the next render. Precedence is unchanged
3552
+ // above it: styleSchema `default` -> palette/`components.<scope>` ->
3553
+ // `widgetStyles[manifestId]` -> per-instance `props.style`, so a workspace
3554
+ // theme still outranks a widget's own baseline and a field the author never
3555
+ // defaulted follows the theme exactly as before. The host still never
3556
+ // applies style to elements — the widget owns placement.
3557
+ // 1.91.0: additive (sc-6450) — the geolocation BACKGROUND watch:
3558
+ // `useGeolocation()` gains backgroundSupported / backgroundWatching /
3559
+ // startBackgroundWatch / stopBackgroundWatch, and the optional
3560
+ // `device.geolocation` broker gains isBackgroundSupported,
3561
+ // startBackgroundWatch, stopBackgroundWatch, isBackgroundWatching,
3562
+ // subscribeBackgroundPositions and subscribeBackgroundWatchState. Field-work
3563
+ // apps — delivery tracking, site visits, mileage — could not be built at all
3564
+ // while a position could only be read in the foreground. Native-only and
3565
+ // opt-in per app: the web Player reports backgroundSupported false because a
3566
+ // tab is suspended once backgrounded, and so does an export whose workspace
3567
+ // did not opt in. The HOST owns whether a watch is running (it is primed
3568
+ // asynchronously after an OS relaunch, the OS can end it, and a sibling
3569
+ // widget can start or stop it), so subscribeBackgroundWatchState is how every
3570
+ // mounted widget stays truthful.
3571
+ version: "1.91.0",
3519
3572
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
3520
3573
  hooks: HOOKS,
3521
3574
  primitives: PRIMITIVES,
package/dist/devserver.js CHANGED
@@ -67,32 +67,38 @@ import {
67
67
  shimSpecifierFromSlug,
68
68
  } from "./dev-shims.js";
69
69
  import { bundleWebEntry } from "./webbundle.js";
70
+ import {
71
+ findImportStatements,
72
+ findRelativeImportStatements,
73
+ } from "./flatten-entry.js";
70
74
 
71
75
  // Bare-import / relative-import detection. The loader rewrites bare specifiers
72
76
  // in the ENTRY to client-side blob shims, so they resolve fine. Relative
73
77
  // imports in a SINGLE-FILE entry have nowhere to resolve — single-file mode
74
78
  // refuses them with guidance. In directory mode, relative imports in the entry
75
79
  // AND in siblings are rewritten to absolute dev-server URLs.
76
- const RELATIVE_IMPORT_RE =
77
- /(?:^|\n)\s*(?:import|export)[^;\n]*?from\s*['"](\.\.?\/[^'"]+)['"]/g;
78
-
79
- // Static-import / static-export-from anchored at a statement boundary
80
- // (start-of-line, `;`, or newline before `import`/`export`) so the rewriter
81
- // doesn't touch occurrences inside string literals or comment bodies. The
82
- // dynamic-import form (`import("...")`) is matched separately by
83
- // `DYNAMIC_IMPORT_RE` below — it's safe to anchor in the same way because
84
- // it's also valid only as an expression.
85
80
  //
86
- // Capture groups for both regexes:
87
- // 1 = leading keyword + whitespace (and the leading boundary char, which we
88
- // preserve verbatim during replace so the surrounding statement isn't
89
- // glued onto the previous line)
81
+ // STATIC imports are found by the packer's scanner (`findImportStatements`),
82
+ // never by a local regex. sc-6086 removed RELATIVE_IMPORT_RE and sc-6670 the
83
+ // rewriter's STATIC_IMPORT_RE for the same defect: a `[^'";\n]` clause body
84
+ // cannot span newlines, so a multi-line `import {\n a,\n} from "react"` — the
85
+ // norm in this codebase — read as no import at all and was served verbatim.
86
+ // The scanner walks the clause character by character, so line breaks are
87
+ // nothing special, and it returns the specifier's quote offsets so the
88
+ // rewriter splices exactly the specifier and can never bleed into an adjacent
89
+ // statement. One scanner for the guard, the packer, and the rewriter (§3).
90
+
91
+ // Dynamic import (`import("…")`) is an EXPRESSION, valid at any brace depth,
92
+ // so the statement scanner does not see it — it keeps its own regex. That
93
+ // regex separates the specifier from `import` with `\s*`, which does match
94
+ // newlines, so it has no multi-line gap (sc-6670 verified).
95
+ //
96
+ // Capture groups:
97
+ // 1 = `import` + whitespace + `(` (and the leading boundary char, preserved
98
+ // verbatim during replace so the call isn't glued onto what precedes it)
90
99
  // 2 = opening quote
91
100
  // 3 = specifier
92
- const STATIC_IMPORT_RE =
93
- /((?:^|[\n;])\s*(?:import|export)\s+(?:[^'";\n]*?\s+from\s*)?)(['"])([^'"]+)\2/gm;
94
- const DYNAMIC_IMPORT_RE =
95
- /((?:^|[\s;\(,!?:=])import\s*\(\s*)(['"])([^'"]+)\2/g;
101
+ const DYNAMIC_IMPORT_RE = /((?:^|[\s;\(,!?:=])import\s*\(\s*)(['"])([^'"]+)\2/g;
96
102
 
97
103
  /**
98
104
  * Lazily resolve `sucrase`'s `transform`. Kept out of the module's static
@@ -137,15 +143,21 @@ export function transpile(transform, source) {
137
143
  * single-file mode to reject split-impl bundles with a clear message instead
138
144
  * of serving a module the browser can't resolve.
139
145
  *
146
+ * sc-6086: this delegates to the packer's statement scanner rather than
147
+ * `RELATIVE_IMPORT_RE`, whose `[^;\n]` body cannot span newlines — so a
148
+ * MULTI-LINE `import {\n a,\n b,\n} from "./lib/x.js"` was invisible to it.
149
+ * That blind spot is why `worktime-employer` passed every local check and
150
+ * still shipped an unresolvable import to the marketplace. One scanner, so
151
+ * the dev guard and the packer agree on what a relative import is
152
+ * (CLAUDE.md §3).
153
+ *
140
154
  * @param {string} source
141
155
  * @returns {string[]} unique relative specifiers
142
156
  */
143
157
  export function findRelativeImports(source) {
144
- const out = new Set();
145
- let m;
146
- RELATIVE_IMPORT_RE.lastIndex = 0;
147
- while ((m = RELATIVE_IMPORT_RE.exec(source))) out.add(m[1]);
148
- return Array.from(out);
158
+ return Array.from(
159
+ new Set(findRelativeImportStatements(source).map((s) => s.specifier)),
160
+ );
149
161
  }
150
162
 
151
163
  /**
@@ -201,15 +213,16 @@ export function resolveRelativeImport(fromRel, specifier) {
201
213
  export function rewriteImportsForDirectoryMode(source, ctx) {
202
214
  const { fromRel, baseUrl, shimmable } = ctx;
203
215
  const unresolved = new Set();
204
- const handler = (match, prefix, quote, spec) => {
216
+ // The URL a specifier should become, or null to leave it exactly as written.
217
+ const resolveSpecifier = (spec) => {
205
218
  // Relative — resolve under the widget dir.
206
219
  if (spec.startsWith("./") || spec.startsWith("../")) {
207
220
  const rel = resolveRelativeImport(fromRel, spec);
208
221
  if (!rel) {
209
222
  unresolved.add(spec);
210
- return match;
223
+ return null;
211
224
  }
212
- return `${prefix}${quote}${baseUrl}/file/${rel}${quote}`;
225
+ return `${baseUrl}/file/${rel}`;
213
226
  }
214
227
  // Absolute or already-resolved — leave alone.
215
228
  if (
@@ -219,21 +232,36 @@ export function rewriteImportsForDirectoryMode(source, ctx) {
219
232
  spec.startsWith("blob:") ||
220
233
  spec.startsWith("data:")
221
234
  ) {
222
- return match;
235
+ return null;
223
236
  }
224
237
  // Bare — rewrite if shimmable, else surface as unresolved.
225
238
  if (shimmable.includes(spec)) {
226
- return `${prefix}${quote}${baseUrl}/shim/${shimSlugFor(spec)}${quote}`;
239
+ return `${baseUrl}/shim/${shimSlugFor(spec)}`;
227
240
  }
228
241
  unresolved.add(spec);
229
- return match;
242
+ return null;
230
243
  };
231
- // Two passes: anchored static-import + anchored dynamic-import. The split
232
- // (vs. one mega-regex) keeps each pattern simple AND lets us anchor each at
233
- // its real statement boundary so substrings inside string literals like
234
- // `const s = "import x from 'react'"` never match.
235
- let out = source.replace(STATIC_IMPORT_RE, handler);
236
- out = out.replace(DYNAMIC_IMPORT_RE, handler);
244
+
245
+ // Pass 1 static imports, spliced by the scanner's quote offsets. Splicing
246
+ // only what sits BETWEEN the quotes is what makes span bleed impossible:
247
+ // the clause, the trailing `;`, and any adjacent statement are never part of
248
+ // the replaced range. Walk back-to-front so earlier offsets stay valid.
249
+ const statements = findImportStatements(source);
250
+ let out = source;
251
+ for (let i = statements.length - 1; i >= 0; i -= 1) {
252
+ const { specifier, quoteStart, quoteEnd } = statements[i];
253
+ const url = resolveSpecifier(specifier);
254
+ if (url === null) continue;
255
+ out = out.slice(0, quoteStart + 1) + url + out.slice(quoteEnd);
256
+ }
257
+
258
+ // Pass 2 — dynamic `import("…")`, an expression the statement scanner does
259
+ // not report. Its own anchor keeps it off substrings inside string literals
260
+ // like `const s = "import('react')"`.
261
+ out = out.replace(DYNAMIC_IMPORT_RE, (match, prefix, quote, spec) => {
262
+ const url = resolveSpecifier(spec);
263
+ return url === null ? match : `${prefix}${quote}${url}${quote}`;
264
+ });
237
265
  return { code: out, unresolved: Array.from(unresolved) };
238
266
  }
239
267
 
@@ -315,7 +343,10 @@ export function loadWidgetJson(widgetDir) {
315
343
  const candidates = [
316
344
  resolve(widgetDir, p),
317
345
  p.startsWith(`first-party-widgets/${widgetSlug}/`)
318
- ? resolve(widgetDir, p.slice(`first-party-widgets/${widgetSlug}/`.length))
346
+ ? resolve(
347
+ widgetDir,
348
+ p.slice(`first-party-widgets/${widgetSlug}/`.length),
349
+ )
319
350
  : null,
320
351
  resolve(widgetDir, "..", "..", p),
321
352
  ].filter(Boolean);
@@ -326,7 +357,10 @@ export function loadWidgetJson(widgetDir) {
326
357
  `${configPath}: ${label} must point at a file under ${widgetDir} (got "${p}")`,
327
358
  );
328
359
  }
329
- const manifestAbs = _resolveUnderWidget("manifestSource", config.manifestSource);
360
+ const manifestAbs = _resolveUnderWidget(
361
+ "manifestSource",
362
+ config.manifestSource,
363
+ );
330
364
 
331
365
  let entryAbs;
332
366
  if (config.componentSources && typeof config.componentSources === "object") {
@@ -353,7 +387,12 @@ export function loadWidgetJson(widgetDir) {
353
387
  );
354
388
  }
355
389
  const entryRel = relative(widgetDir, entryAbs).split(sep).join("/");
356
- return { widgetDir, manifestPath: manifestAbs, entryPath: entryAbs, entryRel };
390
+ return {
391
+ widgetDir,
392
+ manifestPath: manifestAbs,
393
+ entryPath: entryAbs,
394
+ entryRel,
395
+ };
357
396
  }
358
397
 
359
398
  function _isUnder(parentAbs, childAbs) {
@@ -444,7 +483,13 @@ export function createDevServer({
444
483
  // entry's own ancestor node_modules by default, so a widget that vendored
445
484
  // its deps locally still resolves.
446
485
  const bundleNodePaths = [];
447
- const _frontendNm = resolve(watchRoot, "..", "..", "frontend", "node_modules");
486
+ const _frontendNm = resolve(
487
+ watchRoot,
488
+ "..",
489
+ "..",
490
+ "frontend",
491
+ "node_modules",
492
+ );
448
493
  if (existsSync(_frontendNm)) bundleNodePaths.push(_frontendNm);
449
494
 
450
495
  const sseClients = new Set();
@@ -700,7 +745,13 @@ export function createDevServer({
700
745
  if (url === "/__dev/events") return serveEvents(req, res);
701
746
  if (url === "/__dev/health") {
702
747
  res.writeHead(200, { "Content-Type": "application/json" });
703
- res.end(JSON.stringify({ ok: true, manifestId, mode: directoryMode ? "directory" : "single-file" }));
748
+ res.end(
749
+ JSON.stringify({
750
+ ok: true,
751
+ manifestId,
752
+ mode: directoryMode ? "directory" : "single-file",
753
+ }),
754
+ );
704
755
  return;
705
756
  }
706
757
  if (directoryMode && url.startsWith("/file/")) {
@@ -736,7 +787,8 @@ export function createDevServer({
736
787
  if (ok) onLint("lint: clean");
737
788
  else {
738
789
  onLint(`lint: ${findings.length} finding(s)`);
739
- for (const f of findings) onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
790
+ for (const f of findings)
791
+ onLint(` [${f.rule}] line ${f.line}: ${f.label}`);
740
792
  }
741
793
  }
742
794
  }