@colixsystems/widget-sdk 0.118.0 → 0.120.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 +50 -2
- package/dist/contract.cjs +67 -4
- package/dist/contract.js +67 -4
- package/dist/hooks.js +148 -9
- package/dist/host.d.ts +37 -0
- package/dist/index.d.ts +55 -0
- package/dist/navigation.cjs +137 -3
- package/dist/navigation.js +137 -3
- package/package.json +1 -1
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,8 +70,56 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
70
70
|
|
|
71
71
|
## Status
|
|
72
72
|
|
|
73
|
-
`v0.
|
|
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
74
|
|
|
75
|
+
### What's new in 0.120.0 (contract 1.92.0)
|
|
76
|
+
|
|
77
|
+
**A `top-bar` app chooses which ROW its menu lives in, and how that row looks — `CONTRACT.themeTopBarMenuStyles` plus the `topBar` tab vocabulary (REQ-NAV-STRUCTURE).** The shape drew its menu as text links beside the brand, sharing the bar's one row wherever there was space. That reads as part of the header rather than as the app's global navigation. `topBarMenuStyle` now picks between `links` (that row, unchanged) and `tabs` — a dedicated tab row under the bar at every width, icon and label per page, scrolling sideways rather than dropping one. `normaliseNavigation` returns it beside `menuType`, so one resolver still answers both questions and the Player and the export cannot disagree about which row an app draws. Absent, unknown, or set on any other shape resolves to `links`.
|
|
78
|
+
|
|
79
|
+
`resolveTopBarTokens` gains the vocabulary that row is painted with:
|
|
80
|
+
|
|
81
|
+
- **`activeColor`** — the current page's mark: the active link's label, and an active tab's label and indicator. Its fallback chain is the FOOTER's rather than a new one: the bar's own value, else the **rail's**, else the brand. An app should not have to state the same navigation colour three times, and the rail is where an author already sets it. Unlike the footer's, this chain ends in the brand rather than in null — an active mark has no null state.
|
|
82
|
+
- **`tabStyle`** (`underline` | `attached`) and **`contentSurface`**. `attached` makes the current tab a folder tab joined to the page. The join is made by CONSTRUCTION rather than by matching: an opaque tab cannot track a page that is not flat, so the content area takes the same surface and becomes the panel the tab sits in. `contentSurface` is that shared value — always opaque, following a gradient to its start colour, and honouring an authored active surface so moving the tab moves the page with it.
|
|
83
|
+
- **`tabBackgroundColor`** / **`tabActiveBackgroundColor`** — a tab's own surface per state, authored or null. Null paints none and the bar shows through.
|
|
84
|
+
- **`tabIndicatorWidth`** (0-8, default 2) — the underline under the current tab. Zero draws none, so the WIDTH is its switch: the indicator shares `activeColor` with the label and has no null colour to switch on.
|
|
85
|
+
- **`tabCornerRadius`** (0-24) rounds the tab's TOP corners only — its feet stay square whatever the value, because a rounded foot notches the join — and **`tabPaddingX`** / **`tabPaddingY`** (0-32) replace the frozen 12/8, which remain the defaults.
|
|
86
|
+
|
|
87
|
+
Every measurement is CLAMPED rather than dropped: landing on the end of the range is what an author dragging a slider means. Host-integration surface only — no author-facing hook, prop, primitive, or manifest field changed.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
### What's new in 0.119.0 (contract 1.91.0)
|
|
91
|
+
|
|
92
|
+
**`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:
|
|
93
|
+
|
|
94
|
+
| Member | Shape | Notes |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `backgroundSupported` | `boolean` | **Check this before rendering the control.** |
|
|
97
|
+
| `backgroundWatching` | `boolean` | Whether a watch is running on this device. |
|
|
98
|
+
| `startBackgroundWatch` | `(options?) => Promise<void>` | `options`: `{ enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }`. |
|
|
99
|
+
| `stopBackgroundWatch` | `() => Promise<void>` | Releases the OS subscription. |
|
|
100
|
+
|
|
101
|
+
```jsx
|
|
102
|
+
const { latitude, longitude, backgroundSupported, backgroundWatching, startBackgroundWatch, stopBackgroundWatch } = useGeolocation();
|
|
103
|
+
|
|
104
|
+
{backgroundSupported && (
|
|
105
|
+
<Button
|
|
106
|
+
label={backgroundWatching ? "Stop trip" : "Start trip"}
|
|
107
|
+
onPress={() => (backgroundWatching ? stopBackgroundWatch() : startBackgroundWatch({ distanceIntervalMeters: 50 }))}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
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.
|
|
113
|
+
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
`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.
|
|
117
|
+
|
|
118
|
+
`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`.
|
|
119
|
+
|
|
120
|
+
**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.
|
|
121
|
+
|
|
122
|
+
`CONTRACT.version` → `1.91.0`. Additive — four new result members and six new optional `ctx.device.geolocation` broker members; no existing export changed signature.
|
|
75
123
|
### What's new in 0.118.0 (contract 1.90.0)
|
|
76
124
|
|
|
77
125
|
**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`.
|
package/dist/contract.cjs
CHANGED
|
@@ -228,6 +228,18 @@ const THEME_MENU_TYPES = Object.freeze({
|
|
|
228
228
|
quickBarMaxItems: null,
|
|
229
229
|
}),
|
|
230
230
|
});
|
|
231
|
+
const THEME_TOP_BAR_MENU_STYLES = Object.freeze({
|
|
232
|
+
links: Object.freeze({
|
|
233
|
+
name: "Links",
|
|
234
|
+
summary:
|
|
235
|
+
"Text links beside the brand, dropping to their own row only when a phone leaves them no space.",
|
|
236
|
+
}),
|
|
237
|
+
tabs: Object.freeze({
|
|
238
|
+
name: "Tabs",
|
|
239
|
+
summary:
|
|
240
|
+
"A dedicated tab row under the bar at every width -- icon and label per page, marked by the brand, scrolling sideways when it runs out of room.",
|
|
241
|
+
}),
|
|
242
|
+
});
|
|
231
243
|
const THEME_SPACING_SCALE = Object.freeze({
|
|
232
244
|
min: 0.5,
|
|
233
245
|
max: 2,
|
|
@@ -1570,7 +1582,17 @@ const HOOKS = [
|
|
|
1570
1582
|
"permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to { latitude, longitude, accuracy } " +
|
|
1571
1583
|
"and stores the same values on the hook; it rejects with a GeolocationError whose .code is one of PERMISSION_DENIED | " +
|
|
1572
1584
|
"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)."
|
|
1585
|
+
"maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location). " +
|
|
1586
|
+
"BACKGROUND WATCH (sc-6450): startBackgroundWatch(options?) keeps positions arriving while the app is BACKGROUNDED — the " +
|
|
1587
|
+
"field-work case (delivery tracking, site visits, mileage logging) — and stopBackgroundWatch() releases the OS " +
|
|
1588
|
+
"subscription. It is NATIVE-ONLY and opt-in per app: ALWAYS check `backgroundSupported` before rendering the control, " +
|
|
1589
|
+
"because the web Player reports false (a browser tab cannot track in the background) and so does an exported app whose " +
|
|
1590
|
+
"workspace has not enabled background location in Publishing Settings. The watch OUTLIVES the widget mount by design, so " +
|
|
1591
|
+
"it stops ONLY on stopBackgroundWatch(); `backgroundWatching` is seeded from the host so a remounted widget reports it " +
|
|
1592
|
+
"honestly. Background positions land in the SAME latitude/longitude/accuracy slots. startBackgroundWatch rejects with a " +
|
|
1593
|
+
"GeolocationError (.code UNSUPPORTED when the host or build does not offer it, PERMISSION_DENIED when the user refuses " +
|
|
1594
|
+
"always-on location). options: { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }. Tracking continues while the " +
|
|
1595
|
+
"app is running in the BACKGROUND; it does not survive the OS terminating the app, so do not promise an unattended log.",
|
|
1574
1596
|
returnShape: {
|
|
1575
1597
|
latitude: "number | null",
|
|
1576
1598
|
longitude: "number | null",
|
|
@@ -1579,6 +1601,13 @@ const HOOKS = [
|
|
|
1579
1601
|
error: "GeolocationError | null",
|
|
1580
1602
|
getCurrentPosition:
|
|
1581
1603
|
"() => Promise<{ latitude, longitude, accuracy }> // rejects with GeolocationError",
|
|
1604
|
+
backgroundSupported:
|
|
1605
|
+
"boolean // GATE THE CONTROL ON THIS: false on the web Player and in an export that did not opt in",
|
|
1606
|
+
backgroundWatching: "boolean // a background watch is running on this device",
|
|
1607
|
+
startBackgroundWatch:
|
|
1608
|
+
"(options?) => Promise<void> // NATIVE-ONLY; { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }; rejects with GeolocationError",
|
|
1609
|
+
stopBackgroundWatch:
|
|
1610
|
+
"() => Promise<void> // the ONLY release - the watch outlives the widget's mount",
|
|
1582
1611
|
},
|
|
1583
1612
|
requiredContextSlice: [],
|
|
1584
1613
|
scopes: null,
|
|
@@ -2259,7 +2288,10 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2259
2288
|
device: {
|
|
2260
2289
|
description:
|
|
2261
2290
|
"Optional host-brokered device capabilities. " +
|
|
2262
|
-
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }
|
|
2291
|
+
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }>, " +
|
|
2292
|
+
"isBackgroundSupported() -> boolean, startBackgroundWatch(options?) -> Promise<void>, stopBackgroundWatch() -> Promise<void>, " +
|
|
2293
|
+
"isBackgroundWatching() -> boolean, subscribeBackgroundPositions(cb) -> unsubscribe, " +
|
|
2294
|
+
"subscribeBackgroundWatchState(cb) -> unsubscribe }, " +
|
|
2263
2295
|
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2264
2296
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2265
2297
|
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
@@ -2269,7 +2301,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2269
2301
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
2270
2302
|
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
|
|
2271
2303
|
"camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
|
|
2272
|
-
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL)."
|
|
2304
|
+
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). " +
|
|
2305
|
+
"sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
|
|
2306
|
+
"did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
|
|
2307
|
+
"expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
|
|
2308
|
+
"sensor and prompt for nothing, so the watch can outlive any one widget mount. The HOST owns whether a watch is running: " +
|
|
2309
|
+
"it is primed asynchronously after a cold relaunch, the OS can end it on its own (a permission downgrade, a killed " +
|
|
2310
|
+
"foreground service), and a sibling widget may start or stop it — so subscribeBackgroundWatchState is how every mounted " +
|
|
2311
|
+
"widget stays truthful, and isBackgroundWatching() is only the synchronous first read.",
|
|
2273
2312
|
required: false,
|
|
2274
2313
|
fields: { geolocation: "object", speech: "object", camera: "object" },
|
|
2275
2314
|
},
|
|
@@ -3527,7 +3566,30 @@ const CONTRACT = deepFreeze({
|
|
|
3527
3566
|
// theme still outranks a widget's own baseline and a field the author never
|
|
3528
3567
|
// defaulted follows the theme exactly as before. The host still never
|
|
3529
3568
|
// applies style to elements — the widget owns placement.
|
|
3530
|
-
|
|
3569
|
+
// 1.91.0: additive (sc-6450) — the geolocation BACKGROUND watch:
|
|
3570
|
+
// `useGeolocation()` gains backgroundSupported / backgroundWatching /
|
|
3571
|
+
// startBackgroundWatch / stopBackgroundWatch, and the optional
|
|
3572
|
+
// `device.geolocation` broker gains isBackgroundSupported,
|
|
3573
|
+
// startBackgroundWatch, stopBackgroundWatch, isBackgroundWatching,
|
|
3574
|
+
// subscribeBackgroundPositions and subscribeBackgroundWatchState. Field-work
|
|
3575
|
+
// apps — delivery tracking, site visits, mileage — could not be built at all
|
|
3576
|
+
// while a position could only be read in the foreground. Native-only and
|
|
3577
|
+
// opt-in per app: the web Player reports backgroundSupported false because a
|
|
3578
|
+
// tab is suspended once backgrounded, and so does an export whose workspace
|
|
3579
|
+
// did not opt in. The HOST owns whether a watch is running (it is primed
|
|
3580
|
+
// asynchronously after an OS relaunch, the OS can end it, and a sibling
|
|
3581
|
+
// widget can start or stop it), so subscribeBackgroundWatchState is how every
|
|
3582
|
+
// mounted widget stays truthful.
|
|
3583
|
+
// 1.92.0: additive (REQ-NAV-STRUCTURE) -- `themeTopBarMenuStyles`, the closed
|
|
3584
|
+
// catalogue of how a `top-bar` app draws its menu: `links` (today's row of
|
|
3585
|
+
// text links beside the brand) or `tabs` (a dedicated tab row under the
|
|
3586
|
+
// bar at every width, so the menu reads as global navigation rather than
|
|
3587
|
+
// as part of the header). Resolved with the shape by `normaliseNavigation`,
|
|
3588
|
+
// which now returns `topBarMenuStyle` beside `menuType` -- one resolver, so
|
|
3589
|
+
// the Player and the export cannot disagree about which row an app draws.
|
|
3590
|
+
// Absent, unknown, or set on any other shape resolves to `links`, so every
|
|
3591
|
+
// app authored before the choice existed renders and compiles identically.
|
|
3592
|
+
version: "1.92.0",
|
|
3531
3593
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3532
3594
|
hooks: HOOKS,
|
|
3533
3595
|
primitives: PRIMITIVES,
|
|
@@ -3544,6 +3606,7 @@ const CONTRACT = deepFreeze({
|
|
|
3544
3606
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
3545
3607
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
3546
3608
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
3609
|
+
themeTopBarMenuStyles: THEME_TOP_BAR_MENU_STYLES,
|
|
3547
3610
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
3548
3611
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
3549
3612
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
package/dist/contract.js
CHANGED
|
@@ -228,6 +228,18 @@ const THEME_MENU_TYPES = Object.freeze({
|
|
|
228
228
|
quickBarMaxItems: null,
|
|
229
229
|
}),
|
|
230
230
|
});
|
|
231
|
+
const THEME_TOP_BAR_MENU_STYLES = Object.freeze({
|
|
232
|
+
links: Object.freeze({
|
|
233
|
+
name: "Links",
|
|
234
|
+
summary:
|
|
235
|
+
"Text links beside the brand, dropping to their own row only when a phone leaves them no space.",
|
|
236
|
+
}),
|
|
237
|
+
tabs: Object.freeze({
|
|
238
|
+
name: "Tabs",
|
|
239
|
+
summary:
|
|
240
|
+
"A dedicated tab row under the bar at every width -- icon and label per page, marked by the brand, scrolling sideways when it runs out of room.",
|
|
241
|
+
}),
|
|
242
|
+
});
|
|
231
243
|
const THEME_SPACING_SCALE = Object.freeze({
|
|
232
244
|
min: 0.5,
|
|
233
245
|
max: 2,
|
|
@@ -1570,7 +1582,17 @@ const HOOKS = [
|
|
|
1570
1582
|
"permission prompt on a gesture, so it NEVER fires on mount. The promise resolves to { latitude, longitude, accuracy } " +
|
|
1571
1583
|
"and stores the same values on the hook; it rejects with a GeolocationError whose .code is one of PERMISSION_DENIED | " +
|
|
1572
1584
|
"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)."
|
|
1585
|
+
"maximumAge }). Identical on web (navigator.geolocation) and the Expo export (expo-location). " +
|
|
1586
|
+
"BACKGROUND WATCH (sc-6450): startBackgroundWatch(options?) keeps positions arriving while the app is BACKGROUNDED — the " +
|
|
1587
|
+
"field-work case (delivery tracking, site visits, mileage logging) — and stopBackgroundWatch() releases the OS " +
|
|
1588
|
+
"subscription. It is NATIVE-ONLY and opt-in per app: ALWAYS check `backgroundSupported` before rendering the control, " +
|
|
1589
|
+
"because the web Player reports false (a browser tab cannot track in the background) and so does an exported app whose " +
|
|
1590
|
+
"workspace has not enabled background location in Publishing Settings. The watch OUTLIVES the widget mount by design, so " +
|
|
1591
|
+
"it stops ONLY on stopBackgroundWatch(); `backgroundWatching` is seeded from the host so a remounted widget reports it " +
|
|
1592
|
+
"honestly. Background positions land in the SAME latitude/longitude/accuracy slots. startBackgroundWatch rejects with a " +
|
|
1593
|
+
"GeolocationError (.code UNSUPPORTED when the host or build does not offer it, PERMISSION_DENIED when the user refuses " +
|
|
1594
|
+
"always-on location). options: { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }. Tracking continues while the " +
|
|
1595
|
+
"app is running in the BACKGROUND; it does not survive the OS terminating the app, so do not promise an unattended log.",
|
|
1574
1596
|
returnShape: {
|
|
1575
1597
|
latitude: "number | null",
|
|
1576
1598
|
longitude: "number | null",
|
|
@@ -1579,6 +1601,13 @@ const HOOKS = [
|
|
|
1579
1601
|
error: "GeolocationError | null",
|
|
1580
1602
|
getCurrentPosition:
|
|
1581
1603
|
"() => Promise<{ latitude, longitude, accuracy }> // rejects with GeolocationError",
|
|
1604
|
+
backgroundSupported:
|
|
1605
|
+
"boolean // GATE THE CONTROL ON THIS: false on the web Player and in an export that did not opt in",
|
|
1606
|
+
backgroundWatching: "boolean // a background watch is running on this device",
|
|
1607
|
+
startBackgroundWatch:
|
|
1608
|
+
"(options?) => Promise<void> // NATIVE-ONLY; { enableHighAccuracy, distanceIntervalMeters, timeIntervalMs }; rejects with GeolocationError",
|
|
1609
|
+
stopBackgroundWatch:
|
|
1610
|
+
"() => Promise<void> // the ONLY release - the watch outlives the widget's mount",
|
|
1582
1611
|
},
|
|
1583
1612
|
requiredContextSlice: [],
|
|
1584
1613
|
scopes: null,
|
|
@@ -2259,7 +2288,10 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2259
2288
|
device: {
|
|
2260
2289
|
description:
|
|
2261
2290
|
"Optional host-brokered device capabilities. " +
|
|
2262
|
-
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }
|
|
2291
|
+
"{ geolocation: { getCurrentPosition(options?) -> Promise<{ latitude, longitude, accuracy }>, " +
|
|
2292
|
+
"isBackgroundSupported() -> boolean, startBackgroundWatch(options?) -> Promise<void>, stopBackgroundWatch() -> Promise<void>, " +
|
|
2293
|
+
"isBackgroundWatching() -> boolean, subscribeBackgroundPositions(cb) -> unsubscribe, " +
|
|
2294
|
+
"subscribeBackgroundWatchState(cb) -> unsubscribe }, " +
|
|
2263
2295
|
"speech: { isSupported() -> boolean, start(options, { onResult, onError, onEnd }) -> Promise<{ stop(), abort() }> }, " +
|
|
2264
2296
|
"camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> } }. " +
|
|
2265
2297
|
"Backs useGeolocation(), useSpeechToText() and useCamera(). The web Player brokers them via navigator.geolocation, " +
|
|
@@ -2269,7 +2301,14 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2269
2301
|
"speech.start streams { transcript, isFinal } to onResult and runs ON DEVICE — it uploads no audio and spends no AI credit; " +
|
|
2270
2302
|
"its onError carries the Web Speech error vocabulary (not-allowed | no-speech | language-not-supported | network | aborted). " +
|
|
2271
2303
|
"camera.capture/pick resolve a normalised { uri, name, mimeType, width, height, size, file, release? } or NULL when the " +
|
|
2272
|
-
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL)."
|
|
2304
|
+
"user dismisses the picker, and reject with a CameraError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). " +
|
|
2305
|
+
"sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
|
|
2306
|
+
"did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
|
|
2307
|
+
"expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
|
|
2308
|
+
"sensor and prompt for nothing, so the watch can outlive any one widget mount. The HOST owns whether a watch is running: " +
|
|
2309
|
+
"it is primed asynchronously after a cold relaunch, the OS can end it on its own (a permission downgrade, a killed " +
|
|
2310
|
+
"foreground service), and a sibling widget may start or stop it — so subscribeBackgroundWatchState is how every mounted " +
|
|
2311
|
+
"widget stays truthful, and isBackgroundWatching() is only the synchronous first read.",
|
|
2273
2312
|
required: false,
|
|
2274
2313
|
fields: { geolocation: "object", speech: "object", camera: "object" },
|
|
2275
2314
|
},
|
|
@@ -3527,7 +3566,30 @@ const CONTRACT = deepFreeze({
|
|
|
3527
3566
|
// theme still outranks a widget's own baseline and a field the author never
|
|
3528
3567
|
// defaulted follows the theme exactly as before. The host still never
|
|
3529
3568
|
// applies style to elements — the widget owns placement.
|
|
3530
|
-
|
|
3569
|
+
// 1.91.0: additive (sc-6450) — the geolocation BACKGROUND watch:
|
|
3570
|
+
// `useGeolocation()` gains backgroundSupported / backgroundWatching /
|
|
3571
|
+
// startBackgroundWatch / stopBackgroundWatch, and the optional
|
|
3572
|
+
// `device.geolocation` broker gains isBackgroundSupported,
|
|
3573
|
+
// startBackgroundWatch, stopBackgroundWatch, isBackgroundWatching,
|
|
3574
|
+
// subscribeBackgroundPositions and subscribeBackgroundWatchState. Field-work
|
|
3575
|
+
// apps — delivery tracking, site visits, mileage — could not be built at all
|
|
3576
|
+
// while a position could only be read in the foreground. Native-only and
|
|
3577
|
+
// opt-in per app: the web Player reports backgroundSupported false because a
|
|
3578
|
+
// tab is suspended once backgrounded, and so does an export whose workspace
|
|
3579
|
+
// did not opt in. The HOST owns whether a watch is running (it is primed
|
|
3580
|
+
// asynchronously after an OS relaunch, the OS can end it, and a sibling
|
|
3581
|
+
// widget can start or stop it), so subscribeBackgroundWatchState is how every
|
|
3582
|
+
// mounted widget stays truthful.
|
|
3583
|
+
// 1.92.0: additive (REQ-NAV-STRUCTURE) -- `themeTopBarMenuStyles`, the closed
|
|
3584
|
+
// catalogue of how a `top-bar` app draws its menu: `links` (today's row of
|
|
3585
|
+
// text links beside the brand) or `tabs` (a dedicated tab row under the
|
|
3586
|
+
// bar at every width, so the menu reads as global navigation rather than
|
|
3587
|
+
// as part of the header). Resolved with the shape by `normaliseNavigation`,
|
|
3588
|
+
// which now returns `topBarMenuStyle` beside `menuType` -- one resolver, so
|
|
3589
|
+
// the Player and the export cannot disagree about which row an app draws.
|
|
3590
|
+
// Absent, unknown, or set on any other shape resolves to `links`, so every
|
|
3591
|
+
// app authored before the choice existed renders and compiles identically.
|
|
3592
|
+
version: "1.92.0",
|
|
3531
3593
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
3532
3594
|
hooks: HOOKS,
|
|
3533
3595
|
primitives: PRIMITIVES,
|
|
@@ -3544,6 +3606,7 @@ const CONTRACT = deepFreeze({
|
|
|
3544
3606
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
3545
3607
|
themeSpacingScale: THEME_SPACING_SCALE,
|
|
3546
3608
|
themeMenuTypes: THEME_MENU_TYPES,
|
|
3609
|
+
themeTopBarMenuStyles: THEME_TOP_BAR_MENU_STYLES,
|
|
3547
3610
|
themeWidgetStyles: THEME_WIDGET_STYLES,
|
|
3548
3611
|
widgetContextShape: WIDGET_CONTEXT_SHAPE,
|
|
3549
3612
|
bundleExportContract: BUNDLE_EXPORT_CONTRACT,
|
package/dist/hooks.js
CHANGED
|
@@ -987,9 +987,55 @@ function toGeolocationError(err) {
|
|
|
987
987
|
return new GeolocationError(code, message, { cause: err });
|
|
988
988
|
}
|
|
989
989
|
|
|
990
|
+
/** Normalise a host position onto the hook's three numeric slots. */
|
|
991
|
+
function normalizeGeolocationPosition(pos) {
|
|
992
|
+
return {
|
|
993
|
+
latitude: pos && typeof pos.latitude === "number" ? pos.latitude : null,
|
|
994
|
+
longitude: pos && typeof pos.longitude === "number" ? pos.longitude : null,
|
|
995
|
+
accuracy: pos && typeof pos.accuracy === "number" ? pos.accuracy : null,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Ask the host whether a background watch is running. The watch outlives the
|
|
1001
|
+
* widget's mount, so the host — not the hook — owns this truth.
|
|
1002
|
+
*/
|
|
1003
|
+
function readBackgroundWatching(client) {
|
|
1004
|
+
if (!client || typeof client.isBackgroundWatching !== "function") return false;
|
|
1005
|
+
try {
|
|
1006
|
+
return Boolean(client.isBackgroundWatching());
|
|
1007
|
+
} catch {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Whether this host offers the background watch. Never throws at render. */
|
|
1013
|
+
function readBackgroundSupported(client) {
|
|
1014
|
+
if (!client || typeof client.startBackgroundWatch !== "function") return false;
|
|
1015
|
+
if (typeof client.isBackgroundSupported !== "function") return true;
|
|
1016
|
+
try {
|
|
1017
|
+
return Boolean(client.isBackgroundSupported());
|
|
1018
|
+
} catch {
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Subscribe defensively; a host that throws simply yields no subscription. */
|
|
1024
|
+
function safeSubscribe(client, method, handler) {
|
|
1025
|
+
if (!client || typeof client[method] !== "function") return null;
|
|
1026
|
+
try {
|
|
1027
|
+
const unsubscribe = client[method](handler);
|
|
1028
|
+
return typeof unsubscribe === "function" ? unsubscribe : null;
|
|
1029
|
+
} catch {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
990
1034
|
/**
|
|
991
1035
|
* Read the device's current position. Returns
|
|
992
|
-
* `{ latitude, longitude, accuracy, loading, error, getCurrentPosition
|
|
1036
|
+
* `{ latitude, longitude, accuracy, loading, error, getCurrentPosition,
|
|
1037
|
+
* backgroundSupported, backgroundWatching, startBackgroundWatch,
|
|
1038
|
+
* stopBackgroundWatch }`.
|
|
993
1039
|
*
|
|
994
1040
|
* Capture is IMPERATIVE — call `getCurrentPosition()` from a user gesture (a
|
|
995
1041
|
* tap on a button). Browsers and the mobile OS gate the permission prompt on a
|
|
@@ -1004,6 +1050,21 @@ function toGeolocationError(err) {
|
|
|
1004
1050
|
* Safe-by-default: on a host that does not inject `ctx.device.geolocation`,
|
|
1005
1051
|
* `getCurrentPosition()` rejects with `code: "UNSUPPORTED"` rather than
|
|
1006
1052
|
* throwing at render, so a widget can call the hook unconditionally.
|
|
1053
|
+
*
|
|
1054
|
+
* BACKGROUND WATCH (sc-6450) — `startBackgroundWatch(options?)` keeps positions
|
|
1055
|
+
* arriving while the app is BACKGROUNDED, which is what field-work apps
|
|
1056
|
+
* (delivery tracking, site visits, mileage logging) need. It is capability-
|
|
1057
|
+
* gated: check `backgroundSupported` before offering the control. The web
|
|
1058
|
+
* Player reports false — a browser tab cannot track in the background — and so
|
|
1059
|
+
* does an exported app whose workspace has not opted into background location
|
|
1060
|
+
* in Publishing Settings, because a build that never uses it must declare no
|
|
1061
|
+
* background mode and stay clear of the extra store review.
|
|
1062
|
+
*
|
|
1063
|
+
* The watch OUTLIVES the widget's mount by design, so it is released only by
|
|
1064
|
+
* `stopBackgroundWatch()`, never on unmount; `backgroundWatching` is seeded
|
|
1065
|
+
* from the host so a remounted widget reports a running watch honestly.
|
|
1066
|
+
* Delivered positions land in the SAME `latitude` / `longitude` / `accuracy`
|
|
1067
|
+
* slots as the foreground read.
|
|
1007
1068
|
*/
|
|
1008
1069
|
export function useGeolocation(options) {
|
|
1009
1070
|
const ctx = useWidgetContextOrThrow("useGeolocation");
|
|
@@ -1019,6 +1080,45 @@ export function useGeolocation(options) {
|
|
|
1019
1080
|
optionsRef.current = options;
|
|
1020
1081
|
const runRef = useRef(0);
|
|
1021
1082
|
|
|
1083
|
+
const client = ctx.device && ctx.device.geolocation;
|
|
1084
|
+
const backgroundSupported = readBackgroundSupported(client);
|
|
1085
|
+
const [backgroundWatching, setBackgroundWatching] = useState(() =>
|
|
1086
|
+
readBackgroundWatching(client),
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
// Attach to the host's two background channels on MOUNT rather than inside
|
|
1090
|
+
// startBackgroundWatch: the watch survives unmount, so a remounted widget
|
|
1091
|
+
// must still receive its positions. Subscribing prompts for nothing and
|
|
1092
|
+
// starts no sensor — only startBackgroundWatch() does, from a user gesture.
|
|
1093
|
+
//
|
|
1094
|
+
// The host is the SINGLE source of `backgroundWatching`: the OS can end the
|
|
1095
|
+
// watch on its own (a permission downgrade, a killed service), the answer is
|
|
1096
|
+
// primed asynchronously after a cold relaunch, and a sibling widget may start
|
|
1097
|
+
// or stop it — none of which this hook could observe on its own. Keyed on the
|
|
1098
|
+
// client so a host that injects the slice late still gets wired up.
|
|
1099
|
+
useEffect(() => {
|
|
1100
|
+
if (!client) return undefined;
|
|
1101
|
+
setBackgroundWatching(readBackgroundWatching(client));
|
|
1102
|
+
const unsubscribers = [
|
|
1103
|
+
safeSubscribe(client, "subscribeBackgroundPositions", (pos) => {
|
|
1104
|
+
setCoords(normalizeGeolocationPosition(pos));
|
|
1105
|
+
}),
|
|
1106
|
+
safeSubscribe(client, "subscribeBackgroundWatchState", (watching) => {
|
|
1107
|
+
setBackgroundWatching(Boolean(watching));
|
|
1108
|
+
}),
|
|
1109
|
+
];
|
|
1110
|
+
return () => {
|
|
1111
|
+
for (const unsubscribe of unsubscribers) {
|
|
1112
|
+
if (!unsubscribe) continue;
|
|
1113
|
+
try {
|
|
1114
|
+
unsubscribe();
|
|
1115
|
+
} catch {
|
|
1116
|
+
/* the host already tore the subscription down */
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
}, [client]);
|
|
1121
|
+
|
|
1022
1122
|
const getCurrentPosition = useCallback(async () => {
|
|
1023
1123
|
const myRun = ++runRef.current;
|
|
1024
1124
|
const client = clientRef.current;
|
|
@@ -1037,14 +1137,7 @@ export function useGeolocation(options) {
|
|
|
1037
1137
|
setError(null);
|
|
1038
1138
|
try {
|
|
1039
1139
|
const pos = await client.getCurrentPosition(optionsRef.current);
|
|
1040
|
-
const next =
|
|
1041
|
-
latitude:
|
|
1042
|
-
pos && typeof pos.latitude === "number" ? pos.latitude : null,
|
|
1043
|
-
longitude:
|
|
1044
|
-
pos && typeof pos.longitude === "number" ? pos.longitude : null,
|
|
1045
|
-
accuracy:
|
|
1046
|
-
pos && typeof pos.accuracy === "number" ? pos.accuracy : null,
|
|
1047
|
-
};
|
|
1140
|
+
const next = normalizeGeolocationPosition(pos);
|
|
1048
1141
|
if (runRef.current !== myRun) return next;
|
|
1049
1142
|
setCoords(next);
|
|
1050
1143
|
setLoading(false);
|
|
@@ -1059,6 +1152,48 @@ export function useGeolocation(options) {
|
|
|
1059
1152
|
}
|
|
1060
1153
|
}, []);
|
|
1061
1154
|
|
|
1155
|
+
const startBackgroundWatch = useCallback(async (watchOptions) => {
|
|
1156
|
+
const client = clientRef.current;
|
|
1157
|
+
if (!client || typeof client.startBackgroundWatch !== "function") {
|
|
1158
|
+
const e = new GeolocationError(
|
|
1159
|
+
"UNSUPPORTED",
|
|
1160
|
+
"This host does not track location in the background.",
|
|
1161
|
+
);
|
|
1162
|
+
setError(e);
|
|
1163
|
+
throw e;
|
|
1164
|
+
}
|
|
1165
|
+
setError(null);
|
|
1166
|
+
try {
|
|
1167
|
+
await client.startBackgroundWatch(watchOptions);
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
const ge = toGeolocationError(err);
|
|
1170
|
+
setError(ge);
|
|
1171
|
+
throw ge;
|
|
1172
|
+
} finally {
|
|
1173
|
+
// Re-read rather than assume: the host knows whether the OS actually
|
|
1174
|
+
// took the subscription, and a failed start may still leave one.
|
|
1175
|
+
setBackgroundWatching(readBackgroundWatching(client));
|
|
1176
|
+
}
|
|
1177
|
+
}, []);
|
|
1178
|
+
|
|
1179
|
+
const stopBackgroundWatch = useCallback(async () => {
|
|
1180
|
+
const client = clientRef.current;
|
|
1181
|
+
if (!client || typeof client.stopBackgroundWatch !== "function") {
|
|
1182
|
+
setBackgroundWatching(false);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
await client.stopBackgroundWatch();
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
const ge = toGeolocationError(err);
|
|
1189
|
+
setError(ge);
|
|
1190
|
+
throw ge;
|
|
1191
|
+
} finally {
|
|
1192
|
+
// A REFUSED stop leaves the watch running; only the host knows.
|
|
1193
|
+
setBackgroundWatching(readBackgroundWatching(client));
|
|
1194
|
+
}
|
|
1195
|
+
}, []);
|
|
1196
|
+
|
|
1062
1197
|
return {
|
|
1063
1198
|
latitude: coords ? coords.latitude : null,
|
|
1064
1199
|
longitude: coords ? coords.longitude : null,
|
|
@@ -1066,6 +1201,10 @@ export function useGeolocation(options) {
|
|
|
1066
1201
|
loading,
|
|
1067
1202
|
error,
|
|
1068
1203
|
getCurrentPosition,
|
|
1204
|
+
backgroundSupported,
|
|
1205
|
+
backgroundWatching,
|
|
1206
|
+
startBackgroundWatch,
|
|
1207
|
+
stopBackgroundWatch,
|
|
1069
1208
|
};
|
|
1070
1209
|
}
|
|
1071
1210
|
|
package/dist/host.d.ts
CHANGED
|
@@ -155,8 +155,14 @@ export function createToastController(
|
|
|
155
155
|
|
|
156
156
|
export type ThemeMenuType = "sidebar" | "top-bar" | "bottom-tabs";
|
|
157
157
|
|
|
158
|
+
// How a `top-bar` app draws its menu — closed by
|
|
159
|
+
// `CONTRACT.themeTopBarMenuStyles`. Meaningless on the other two shapes, where
|
|
160
|
+
// the resolver always reports "links".
|
|
161
|
+
export type ThemeTopBarMenuStyle = "links" | "tabs";
|
|
162
|
+
|
|
158
163
|
export interface ResolvedNavigation {
|
|
159
164
|
menuType: ThemeMenuType;
|
|
165
|
+
topBarMenuStyle: ThemeTopBarMenuStyle;
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
/**
|
|
@@ -226,6 +232,37 @@ export interface TopBarTokens {
|
|
|
226
232
|
titleColor: string;
|
|
227
233
|
borderColor: string | null;
|
|
228
234
|
borderWidth: number | null;
|
|
235
|
+
/** The current page's mark — the active link's label, and an active tab's
|
|
236
|
+
* label plus its indicator. Falls back to the RAIL's `activeColor` and then
|
|
237
|
+
* to the brand, so an app states its navigation colour once. */
|
|
238
|
+
activeColor: string;
|
|
239
|
+
/** How a `tabs` menu row marks its current page. `underline` keeps the tab in
|
|
240
|
+
* the bar's surface behind an indicator; `attached` makes it a folder tab
|
|
241
|
+
* that takes `contentSurface` and sits over the row's divider. Meaningless
|
|
242
|
+
* under the `links` style, which draws no tabs. */
|
|
243
|
+
tabStyle: "underline" | "attached";
|
|
244
|
+
/** The surface an `attached` tab AND its content panel share — always
|
|
245
|
+
* opaque, because a translucent tab would show the bar through the page it
|
|
246
|
+
* is part of. Honours `tabActiveBackgroundColor`, so moving the tab moves
|
|
247
|
+
* the page with it. */
|
|
248
|
+
contentSurface: string;
|
|
249
|
+
/** A tab's own surface, authored or null. Null paints none and the bar shows
|
|
250
|
+
* through. */
|
|
251
|
+
tabBackgroundColor: string | null;
|
|
252
|
+
/** The underline under the current tab in px, 0-8. Zero draws none and
|
|
253
|
+
* leaves the tab marked by its label colour alone, so the WIDTH is this
|
|
254
|
+
* one's switch — it shares `activeColor` with the label and so has no null
|
|
255
|
+
* colour to switch on. */
|
|
256
|
+
tabIndicatorWidth: number;
|
|
257
|
+
/** The tab's TOP corner rounding in px, 0-24. Its feet stay square whatever
|
|
258
|
+
* this says — a rounded foot notches the join with the content. */
|
|
259
|
+
tabCornerRadius: number;
|
|
260
|
+
/** The room inside a tab in px, 0-32, defaulting to the row's shipped 12/8. */
|
|
261
|
+
tabPaddingX: number;
|
|
262
|
+
tabPaddingY: number;
|
|
263
|
+
/** The current tab's own surface, authored or null. Under `attached` the
|
|
264
|
+
* resolved surface is `contentSurface` above, which already honours it. */
|
|
265
|
+
tabActiveBackgroundColor: string | null;
|
|
229
266
|
}
|
|
230
267
|
|
|
231
268
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -645,6 +645,29 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
645
645
|
longitude: number;
|
|
646
646
|
accuracy: number;
|
|
647
647
|
}>;
|
|
648
|
+
/** sc-6450 — false on web and on an export that did not opt in. */
|
|
649
|
+
isBackgroundSupported?(): boolean;
|
|
650
|
+
startBackgroundWatch?(
|
|
651
|
+
options?: BackgroundLocationOptions,
|
|
652
|
+
): Promise<void>;
|
|
653
|
+
stopBackgroundWatch?(): Promise<void>;
|
|
654
|
+
isBackgroundWatching?(): boolean;
|
|
655
|
+
/** Attach to the running watch; starts no sensor and prompts for nothing. */
|
|
656
|
+
subscribeBackgroundPositions?(
|
|
657
|
+
onPosition: (pos: {
|
|
658
|
+
latitude: number;
|
|
659
|
+
longitude: number;
|
|
660
|
+
accuracy: number;
|
|
661
|
+
}) => void,
|
|
662
|
+
): () => void;
|
|
663
|
+
/**
|
|
664
|
+
* sc-6450 — the host pushes whether a watch is running: it is primed
|
|
665
|
+
* asynchronously after a cold relaunch, the OS can end it on its own, and
|
|
666
|
+
* a sibling widget may start or stop it.
|
|
667
|
+
*/
|
|
668
|
+
subscribeBackgroundWatchState?(
|
|
669
|
+
onChange: (watching: boolean) => void,
|
|
670
|
+
): () => void;
|
|
648
671
|
};
|
|
649
672
|
};
|
|
650
673
|
}
|
|
@@ -1429,6 +1452,15 @@ export interface GeolocationOptions {
|
|
|
1429
1452
|
maximumAge?: number;
|
|
1430
1453
|
}
|
|
1431
1454
|
|
|
1455
|
+
/** sc-6450 — pass-through options for `startBackgroundWatch(...)`. */
|
|
1456
|
+
export interface BackgroundLocationOptions {
|
|
1457
|
+
enableHighAccuracy?: boolean;
|
|
1458
|
+
/** Report only after the device has moved this far, in metres. */
|
|
1459
|
+
distanceIntervalMeters?: number;
|
|
1460
|
+
/** Report no more often than this, in milliseconds. */
|
|
1461
|
+
timeIntervalMs?: number;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1432
1464
|
export interface GeolocationResult {
|
|
1433
1465
|
latitude: number | null;
|
|
1434
1466
|
longitude: number | null;
|
|
@@ -1446,6 +1478,26 @@ export interface GeolocationResult {
|
|
|
1446
1478
|
longitude: number;
|
|
1447
1479
|
accuracy: number;
|
|
1448
1480
|
}>;
|
|
1481
|
+
/**
|
|
1482
|
+
* sc-6450 — whether this host can track location while BACKGROUNDED. False
|
|
1483
|
+
* on the web Player and on an exported app whose workspace did not opt into
|
|
1484
|
+
* background location. Check it before rendering the control.
|
|
1485
|
+
*/
|
|
1486
|
+
backgroundSupported: boolean;
|
|
1487
|
+
/** Whether a background watch is currently running on this device. */
|
|
1488
|
+
backgroundWatching: boolean;
|
|
1489
|
+
/**
|
|
1490
|
+
* Start tracking while backgrounded — call from a user gesture. The watch
|
|
1491
|
+
* OUTLIVES the widget's mount; only `stopBackgroundWatch()` releases it.
|
|
1492
|
+
* Delivered positions land in the same `latitude`/`longitude`/`accuracy`
|
|
1493
|
+
* slots. Rejects with a `GeolocationError`.
|
|
1494
|
+
*
|
|
1495
|
+
* Tracking continues while the app RUNS in the background; it does not
|
|
1496
|
+
* survive the OS terminating the app.
|
|
1497
|
+
*/
|
|
1498
|
+
startBackgroundWatch(options?: BackgroundLocationOptions): Promise<void>;
|
|
1499
|
+
/** Release the OS subscription. */
|
|
1500
|
+
stopBackgroundWatch(): Promise<void>;
|
|
1449
1501
|
}
|
|
1450
1502
|
|
|
1451
1503
|
/**
|
|
@@ -1455,6 +1507,9 @@ export interface GeolocationResult {
|
|
|
1455
1507
|
* `navigator.geolocation`, the Expo export via `expo-location`. Safe to call on
|
|
1456
1508
|
* a host that doesn't broker geolocation: `getCurrentPosition()` then rejects
|
|
1457
1509
|
* with `code: "UNSUPPORTED"`.
|
|
1510
|
+
*
|
|
1511
|
+
* sc-6450 — the same hook also drives the native-only background watch; see
|
|
1512
|
+
* `backgroundSupported` / `startBackgroundWatch` on the result.
|
|
1458
1513
|
*/
|
|
1459
1514
|
export function useGeolocation(options?: GeolocationOptions): GeolocationResult;
|
|
1460
1515
|
|
package/dist/navigation.cjs
CHANGED
|
@@ -30,6 +30,9 @@ const { CONTRACT, isHexColor } = require("./contract.cjs");
|
|
|
30
30
|
// Absent or unknown resolves here, so every app authored before menu types
|
|
31
31
|
// existed renders and compiles byte-identically.
|
|
32
32
|
const DEFAULT_MENU_TYPE = "sidebar";
|
|
33
|
+
// Today's row of text links, so an app that never made the choice draws exactly
|
|
34
|
+
// what it drew before it existed.
|
|
35
|
+
const DEFAULT_TOP_BAR_MENU_STYLE = "links";
|
|
33
36
|
|
|
34
37
|
function isPlainObject(value) {
|
|
35
38
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -42,15 +45,26 @@ function isPlainObject(value) {
|
|
|
42
45
|
* yields the default sidebar rather than something a host has to guard against.
|
|
43
46
|
*
|
|
44
47
|
* @param {unknown} navigation — the raw `theme_config.navigation` value.
|
|
45
|
-
* @returns {{ menuType: string }} the resolved
|
|
48
|
+
* @returns {{ menuType: string, topBarMenuStyle: string }} the resolved
|
|
49
|
+
* navigation structure.
|
|
46
50
|
*/
|
|
47
51
|
function normaliseNavigation(navigation) {
|
|
48
|
-
const
|
|
52
|
+
const block = isPlainObject(navigation) ? navigation : {};
|
|
53
|
+
const raw = block.menuType;
|
|
49
54
|
const menuType =
|
|
50
55
|
typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
|
|
51
56
|
? raw
|
|
52
57
|
: DEFAULT_MENU_TYPE;
|
|
53
|
-
|
|
58
|
+
const rawStyle = block.topBarMenuStyle;
|
|
59
|
+
// Resolved to `links` for every shape but `top-bar`, so a host reading it can
|
|
60
|
+
// never act on a value the app's own chrome has no row to draw.
|
|
61
|
+
const topBarMenuStyle =
|
|
62
|
+
menuType === "top-bar" &&
|
|
63
|
+
typeof rawStyle === "string" &&
|
|
64
|
+
Object.hasOwn(CONTRACT.themeTopBarMenuStyles, rawStyle)
|
|
65
|
+
? rawStyle
|
|
66
|
+
: DEFAULT_TOP_BAR_MENU_STYLE;
|
|
67
|
+
return { menuType, topBarMenuStyle };
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
/**
|
|
@@ -137,6 +151,69 @@ const DEFAULT_CHROME_SURFACE = "#ffffff";
|
|
|
137
151
|
const DEFAULT_CHROME_TEXT = "#475569";
|
|
138
152
|
// The web mobile header's icon colour, `text-slate-700`.
|
|
139
153
|
const DEFAULT_TOP_BAR_TINT = "#334155";
|
|
154
|
+
// The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
|
|
155
|
+
// (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
|
|
156
|
+
const DEFAULT_APP_SURFACE = "#f8fafc";
|
|
157
|
+
|
|
158
|
+
// An `attached` tab is joined to the page, so its surface must be OPAQUE: the
|
|
159
|
+
// 8-digit form a look uses to float a translucent rail would show the bar
|
|
160
|
+
// through the page the tab claims to be part of. Both hosts already drop alpha
|
|
161
|
+
// for the page itself (web `opaqueColor`, compiler `opaqueHex`); this is the
|
|
162
|
+
// same rule, in the one place the tab reads it from.
|
|
163
|
+
// The colour the PAGE actually shows where a tab meets it. A gradient owns the
|
|
164
|
+
// visible background wherever one is configured and the flat `backgroundColor`
|
|
165
|
+
// shows nowhere, so an attached tab matching the flat colour under a gradient is
|
|
166
|
+
// the mismatch this exists to avoid — it takes the gradient's START colour,
|
|
167
|
+
// which is what the page paints at the top edge the tab is joined to.
|
|
168
|
+
function pageSurface(config) {
|
|
169
|
+
const gradient = isPlainObject(config.backgroundGradient)
|
|
170
|
+
? config.backgroundGradient
|
|
171
|
+
: null;
|
|
172
|
+
const from = gradient ? opaqueOr(gradient.from) : null;
|
|
173
|
+
return hexOr(from || opaqueOr(config.backgroundColor), DEFAULT_APP_SURFACE);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The rail's raw block — the fallback every other chrome's active colour ends
|
|
177
|
+
// up at, so an app states its navigation colour once.
|
|
178
|
+
function sidebarBlock(config) {
|
|
179
|
+
return isPlainObject(config.sidebar) ? config.sidebar : {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// REQ-NAV-STRUCTURE: the tab's TOP corners, in px. Zero is the default and the
|
|
183
|
+
// square tab the row shipped with; the feet stay square whatever this says,
|
|
184
|
+
// because a rounded foot notches the join. Clamped rather than dropped, so a
|
|
185
|
+
// value past the end lands on the end instead of silently reverting to square.
|
|
186
|
+
const TAB_RADIUS_MAX = 24;
|
|
187
|
+
function tabRadiusOr(value) {
|
|
188
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
189
|
+
return Math.min(Math.max(Math.round(value), 0), TAB_RADIUS_MAX);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The room inside a tab, in px. The defaults are the row's shipped `px-3 py-2`.
|
|
193
|
+
const TAB_PADDING_MAX = 32;
|
|
194
|
+
const DEFAULT_TAB_PADDING_X = 12;
|
|
195
|
+
const DEFAULT_TAB_PADDING_Y = 8;
|
|
196
|
+
function tabSpaceOr(value, fallback) {
|
|
197
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
198
|
+
return Math.min(Math.max(Math.round(value), 0), TAB_PADDING_MAX);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The current tab's underline, in px. Two is what the row shipped with; zero is
|
|
202
|
+
// off. Clamped rather than dropped, like the tab's other measurements.
|
|
203
|
+
const TAB_INDICATOR_MAX = 8;
|
|
204
|
+
const DEFAULT_TAB_INDICATOR = 2;
|
|
205
|
+
function tabIndicatorOr(value) {
|
|
206
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
207
|
+
return DEFAULT_TAB_INDICATOR;
|
|
208
|
+
}
|
|
209
|
+
return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function opaqueOr(value) {
|
|
213
|
+
return isHexColor(value) && value.length !== 9 && value.length !== 5
|
|
214
|
+
? value
|
|
215
|
+
: null;
|
|
216
|
+
}
|
|
140
217
|
|
|
141
218
|
// The contract's own guard, which already admits the 8-digit form a look uses
|
|
142
219
|
// to float a translucent rail (#RRGGBBAA).
|
|
@@ -201,8 +278,65 @@ function resolveTopBarTokens(theme) {
|
|
|
201
278
|
backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
|
|
202
279
|
tintColor: authored || DEFAULT_TOP_BAR_TINT,
|
|
203
280
|
titleColor: authored || brandPrimary(config),
|
|
281
|
+
// REQ-NAV-STRUCTURE: the CURRENT page's mark — the active link's label, and
|
|
282
|
+
// an active tab's label plus its indicator. It read the brand directly
|
|
283
|
+
// before, which is right as a default and wrong as a rule: an attached tab
|
|
284
|
+
// wears the page's surface, and the brand that reads well on the bar can
|
|
285
|
+
// fail on the page.
|
|
286
|
+
//
|
|
287
|
+
// The fallback chain is the FOOTER's, deliberately: the bar's own value,
|
|
288
|
+
// else the RAIL's, else the brand. One app should not have to state the
|
|
289
|
+
// same navigation colour three times, and the rail is where an author
|
|
290
|
+
// already sets it — so the bar follows the app's navigation by default and
|
|
291
|
+
// departs from it only when asked. Unlike the footer's `pick`, this one
|
|
292
|
+
// ends in the brand rather than in null: an active mark has no null state.
|
|
293
|
+
activeColor: hexOr(
|
|
294
|
+
topBar.activeColor,
|
|
295
|
+
hexOr(sidebarBlock(config).activeColor, brandPrimary(config)),
|
|
296
|
+
),
|
|
204
297
|
borderColor,
|
|
205
298
|
borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
|
|
299
|
+
// REQ-NAV-STRUCTURE: how the tab row marks its current page.
|
|
300
|
+
// `underline` keeps the tab in the bar's surface and marks it with an
|
|
301
|
+
// indicator. `attached` makes it a real folder tab: it takes the CONTENT's
|
|
302
|
+
// surface and sits over the row's divider, so the tab and the page beneath
|
|
303
|
+
// read as one plane. Meaningless under the `links` style, which draws no
|
|
304
|
+
// tabs — a host reads it only where it has tabs to draw.
|
|
305
|
+
tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
|
|
306
|
+
// The surface an `attached` tab AND its content panel share. One value for
|
|
307
|
+
// both, so the join cannot come apart: whatever the current tab is painted,
|
|
308
|
+
// the page beneath it is painted too. An authored active surface therefore
|
|
309
|
+
// moves both. Resolved HERE rather than by each host, because a tab that
|
|
310
|
+
// fails to match the page it is joined to reads as a mismatched box instead
|
|
311
|
+
// of a tab, and two hosts computing it separately is exactly how that
|
|
312
|
+
// drifts. Opaque by construction: a translucent tab would show the bar
|
|
313
|
+
// through the page it claims to be part of.
|
|
314
|
+
contentSurface: hexOr(
|
|
315
|
+
opaqueOr(topBar.tabActiveBackgroundColor),
|
|
316
|
+
pageSurface(config),
|
|
317
|
+
),
|
|
318
|
+
// REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
|
|
319
|
+
// turns it OFF and leaves the tab marked by its label colour alone, which
|
|
320
|
+
// is a real choice rather than an unstyled state — so unlike the chrome
|
|
321
|
+
// divider, whose COLOUR is its switch, this one's width is. It takes
|
|
322
|
+
// `activeColor` above; a mark and its label disagreeing about which colour
|
|
323
|
+
// means "you are here" would be two marks, not one.
|
|
324
|
+
tabIndicatorWidth: tabIndicatorOr(topBar.tabIndicatorWidth),
|
|
325
|
+
// The tab's TOP corners. Its feet stay square regardless — see tabRadiusOr.
|
|
326
|
+
tabCornerRadius: tabRadiusOr(topBar.tabCornerRadius),
|
|
327
|
+
// The room inside a tab. Defaulted to what the row shipped with (12/8), so
|
|
328
|
+
// an app that never touches them is unchanged, and clamped rather than
|
|
329
|
+
// dropped for the same reason the radius is.
|
|
330
|
+
tabPaddingX: tabSpaceOr(topBar.tabPaddingX, DEFAULT_TAB_PADDING_X),
|
|
331
|
+
tabPaddingY: tabSpaceOr(topBar.tabPaddingY, DEFAULT_TAB_PADDING_Y),
|
|
332
|
+
// A tab's own surface, authored or nothing. Null means the tab paints none
|
|
333
|
+
// and the bar shows through it, which is what both styles did before the
|
|
334
|
+
// keys existed — so an app that never set them is unchanged.
|
|
335
|
+
tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
|
|
336
|
+
// Only meaningful where the style draws no panel: under `attached` the
|
|
337
|
+
// active surface IS `contentSurface` above. Null under `underline` leaves
|
|
338
|
+
// the current tab unpainted, marked by its indicator alone.
|
|
339
|
+
tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
|
|
206
340
|
};
|
|
207
341
|
}
|
|
208
342
|
|
package/dist/navigation.js
CHANGED
|
@@ -21,6 +21,9 @@ import { CONTRACT, isHexColor } from "./contract.js";
|
|
|
21
21
|
// Absent or unknown resolves here, so every app authored before menu types
|
|
22
22
|
// existed renders and compiles byte-identically.
|
|
23
23
|
const DEFAULT_MENU_TYPE = "sidebar";
|
|
24
|
+
// Today's row of text links, so an app that never made the choice draws exactly
|
|
25
|
+
// what it drew before it existed.
|
|
26
|
+
const DEFAULT_TOP_BAR_MENU_STYLE = "links";
|
|
24
27
|
|
|
25
28
|
function isPlainObject(value) {
|
|
26
29
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -33,15 +36,26 @@ function isPlainObject(value) {
|
|
|
33
36
|
* yields the default sidebar rather than something a host has to guard against.
|
|
34
37
|
*
|
|
35
38
|
* @param {unknown} navigation — the raw `theme_config.navigation` value.
|
|
36
|
-
* @returns {{ menuType: string }} the resolved
|
|
39
|
+
* @returns {{ menuType: string, topBarMenuStyle: string }} the resolved
|
|
40
|
+
* navigation structure.
|
|
37
41
|
*/
|
|
38
42
|
export function normaliseNavigation(navigation) {
|
|
39
|
-
const
|
|
43
|
+
const block = isPlainObject(navigation) ? navigation : {};
|
|
44
|
+
const raw = block.menuType;
|
|
40
45
|
const menuType =
|
|
41
46
|
typeof raw === "string" && Object.hasOwn(CONTRACT.themeMenuTypes, raw)
|
|
42
47
|
? raw
|
|
43
48
|
: DEFAULT_MENU_TYPE;
|
|
44
|
-
|
|
49
|
+
const rawStyle = block.topBarMenuStyle;
|
|
50
|
+
// Resolved to `links` for every shape but `top-bar`, so a host reading it can
|
|
51
|
+
// never act on a value the app's own chrome has no row to draw.
|
|
52
|
+
const topBarMenuStyle =
|
|
53
|
+
menuType === "top-bar" &&
|
|
54
|
+
typeof rawStyle === "string" &&
|
|
55
|
+
Object.hasOwn(CONTRACT.themeTopBarMenuStyles, rawStyle)
|
|
56
|
+
? rawStyle
|
|
57
|
+
: DEFAULT_TOP_BAR_MENU_STYLE;
|
|
58
|
+
return { menuType, topBarMenuStyle };
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
/**
|
|
@@ -128,6 +142,69 @@ const DEFAULT_CHROME_SURFACE = "#ffffff";
|
|
|
128
142
|
const DEFAULT_CHROME_TEXT = "#475569";
|
|
129
143
|
// The web mobile header's icon colour, `text-slate-700`.
|
|
130
144
|
const DEFAULT_TOP_BAR_TINT = "#334155";
|
|
145
|
+
// The page beneath the chrome — the web's `DEFAULT_APP_BACKGROUND_COLOR`
|
|
146
|
+
// (frontend/src/utils/theme.js) and the compiler's, which are the same slate-50.
|
|
147
|
+
const DEFAULT_APP_SURFACE = "#f8fafc";
|
|
148
|
+
|
|
149
|
+
// An `attached` tab is joined to the page, so its surface must be OPAQUE: the
|
|
150
|
+
// 8-digit form a look uses to float a translucent rail would show the bar
|
|
151
|
+
// through the page the tab claims to be part of. Both hosts already drop alpha
|
|
152
|
+
// for the page itself (web `opaqueColor`, compiler `opaqueHex`); this is the
|
|
153
|
+
// same rule, in the one place the tab reads it from.
|
|
154
|
+
// The colour the PAGE actually shows where a tab meets it. A gradient owns the
|
|
155
|
+
// visible background wherever one is configured and the flat `backgroundColor`
|
|
156
|
+
// shows nowhere, so an attached tab matching the flat colour under a gradient is
|
|
157
|
+
// the mismatch this exists to avoid — it takes the gradient's START colour,
|
|
158
|
+
// which is what the page paints at the top edge the tab is joined to.
|
|
159
|
+
function pageSurface(config) {
|
|
160
|
+
const gradient = isPlainObject(config.backgroundGradient)
|
|
161
|
+
? config.backgroundGradient
|
|
162
|
+
: null;
|
|
163
|
+
const from = gradient ? opaqueOr(gradient.from) : null;
|
|
164
|
+
return hexOr(from || opaqueOr(config.backgroundColor), DEFAULT_APP_SURFACE);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The rail's raw block — the fallback every other chrome's active colour ends
|
|
168
|
+
// up at, so an app states its navigation colour once.
|
|
169
|
+
function sidebarBlock(config) {
|
|
170
|
+
return isPlainObject(config.sidebar) ? config.sidebar : {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// REQ-NAV-STRUCTURE: the tab's TOP corners, in px. Zero is the default and the
|
|
174
|
+
// square tab the row shipped with; the feet stay square whatever this says,
|
|
175
|
+
// because a rounded foot notches the join. Clamped rather than dropped, so a
|
|
176
|
+
// value past the end lands on the end instead of silently reverting to square.
|
|
177
|
+
const TAB_RADIUS_MAX = 24;
|
|
178
|
+
function tabRadiusOr(value) {
|
|
179
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
180
|
+
return Math.min(Math.max(Math.round(value), 0), TAB_RADIUS_MAX);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The room inside a tab, in px. The defaults are the row's shipped `px-3 py-2`.
|
|
184
|
+
const TAB_PADDING_MAX = 32;
|
|
185
|
+
const DEFAULT_TAB_PADDING_X = 12;
|
|
186
|
+
const DEFAULT_TAB_PADDING_Y = 8;
|
|
187
|
+
function tabSpaceOr(value, fallback) {
|
|
188
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
189
|
+
return Math.min(Math.max(Math.round(value), 0), TAB_PADDING_MAX);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The current tab's underline, in px. Two is what the row shipped with; zero is
|
|
193
|
+
// off. Clamped rather than dropped, like the tab's other measurements.
|
|
194
|
+
const TAB_INDICATOR_MAX = 8;
|
|
195
|
+
const DEFAULT_TAB_INDICATOR = 2;
|
|
196
|
+
function tabIndicatorOr(value) {
|
|
197
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
198
|
+
return DEFAULT_TAB_INDICATOR;
|
|
199
|
+
}
|
|
200
|
+
return Math.min(Math.max(Math.round(value), 0), TAB_INDICATOR_MAX);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function opaqueOr(value) {
|
|
204
|
+
return isHexColor(value) && value.length !== 9 && value.length !== 5
|
|
205
|
+
? value
|
|
206
|
+
: null;
|
|
207
|
+
}
|
|
131
208
|
|
|
132
209
|
// The contract's own guard, which already admits the 8-digit form a look uses
|
|
133
210
|
// to float a translucent rail (#RRGGBBAA).
|
|
@@ -192,8 +269,65 @@ export function resolveTopBarTokens(theme) {
|
|
|
192
269
|
backgroundColor: hexOr(topBar.backgroundColor, DEFAULT_CHROME_SURFACE),
|
|
193
270
|
tintColor: authored || DEFAULT_TOP_BAR_TINT,
|
|
194
271
|
titleColor: authored || brandPrimary(config),
|
|
272
|
+
// REQ-NAV-STRUCTURE: the CURRENT page's mark — the active link's label, and
|
|
273
|
+
// an active tab's label plus its indicator. It read the brand directly
|
|
274
|
+
// before, which is right as a default and wrong as a rule: an attached tab
|
|
275
|
+
// wears the page's surface, and the brand that reads well on the bar can
|
|
276
|
+
// fail on the page.
|
|
277
|
+
//
|
|
278
|
+
// The fallback chain is the FOOTER's, deliberately: the bar's own value,
|
|
279
|
+
// else the RAIL's, else the brand. One app should not have to state the
|
|
280
|
+
// same navigation colour three times, and the rail is where an author
|
|
281
|
+
// already sets it — so the bar follows the app's navigation by default and
|
|
282
|
+
// departs from it only when asked. Unlike the footer's `pick`, this one
|
|
283
|
+
// ends in the brand rather than in null: an active mark has no null state.
|
|
284
|
+
activeColor: hexOr(
|
|
285
|
+
topBar.activeColor,
|
|
286
|
+
hexOr(sidebarBlock(config).activeColor, brandPrimary(config)),
|
|
287
|
+
),
|
|
195
288
|
borderColor,
|
|
196
289
|
borderWidth: borderColor ? borderWidthOr(topBar.borderWidth) : null,
|
|
290
|
+
// REQ-NAV-STRUCTURE: how the tab row marks its current page.
|
|
291
|
+
// `underline` keeps the tab in the bar's surface and marks it with an
|
|
292
|
+
// indicator. `attached` makes it a real folder tab: it takes the CONTENT's
|
|
293
|
+
// surface and sits over the row's divider, so the tab and the page beneath
|
|
294
|
+
// read as one plane. Meaningless under the `links` style, which draws no
|
|
295
|
+
// tabs — a host reads it only where it has tabs to draw.
|
|
296
|
+
tabStyle: topBar.tabStyle === "attached" ? "attached" : "underline",
|
|
297
|
+
// The surface an `attached` tab AND its content panel share. One value for
|
|
298
|
+
// both, so the join cannot come apart: whatever the current tab is painted,
|
|
299
|
+
// the page beneath it is painted too. An authored active surface therefore
|
|
300
|
+
// moves both. Resolved HERE rather than by each host, because a tab that
|
|
301
|
+
// fails to match the page it is joined to reads as a mismatched box instead
|
|
302
|
+
// of a tab, and two hosts computing it separately is exactly how that
|
|
303
|
+
// drifts. Opaque by construction: a translucent tab would show the bar
|
|
304
|
+
// through the page it claims to be part of.
|
|
305
|
+
contentSurface: hexOr(
|
|
306
|
+
opaqueOr(topBar.tabActiveBackgroundColor),
|
|
307
|
+
pageSurface(config),
|
|
308
|
+
),
|
|
309
|
+
// REQ-NAV-STRUCTURE: the underline that marks the current tab, in px. Zero
|
|
310
|
+
// turns it OFF and leaves the tab marked by its label colour alone, which
|
|
311
|
+
// is a real choice rather than an unstyled state — so unlike the chrome
|
|
312
|
+
// divider, whose COLOUR is its switch, this one's width is. It takes
|
|
313
|
+
// `activeColor` above; a mark and its label disagreeing about which colour
|
|
314
|
+
// means "you are here" would be two marks, not one.
|
|
315
|
+
tabIndicatorWidth: tabIndicatorOr(topBar.tabIndicatorWidth),
|
|
316
|
+
// The tab's TOP corners. Its feet stay square regardless — see tabRadiusOr.
|
|
317
|
+
tabCornerRadius: tabRadiusOr(topBar.tabCornerRadius),
|
|
318
|
+
// The room inside a tab. Defaulted to what the row shipped with (12/8), so
|
|
319
|
+
// an app that never touches them is unchanged, and clamped rather than
|
|
320
|
+
// dropped for the same reason the radius is.
|
|
321
|
+
tabPaddingX: tabSpaceOr(topBar.tabPaddingX, DEFAULT_TAB_PADDING_X),
|
|
322
|
+
tabPaddingY: tabSpaceOr(topBar.tabPaddingY, DEFAULT_TAB_PADDING_Y),
|
|
323
|
+
// A tab's own surface, authored or nothing. Null means the tab paints none
|
|
324
|
+
// and the bar shows through it, which is what both styles did before the
|
|
325
|
+
// keys existed — so an app that never set them is unchanged.
|
|
326
|
+
tabBackgroundColor: hexOrNull(topBar.tabBackgroundColor),
|
|
327
|
+
// Only meaningful where the style draws no panel: under `attached` the
|
|
328
|
+
// active surface IS `contentSurface` above. Null under `underline` leaves
|
|
329
|
+
// the current tab unpainted, marked by its indicator alone.
|
|
330
|
+
tabActiveBackgroundColor: hexOrNull(topBar.tabActiveBackgroundColor),
|
|
197
331
|
};
|
|
198
332
|
}
|
|
199
333
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.120.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",
|