@colixsystems/widget-sdk 0.150.0 → 0.151.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 +36 -8
- package/dist/contract.cjs +10 -6
- package/dist/contract.js +10 -6
- package/dist/host.d.ts +29 -0
- package/dist/host.js +5 -0
- package/dist/linter.cjs +7 -2
- package/dist/linter.js +7 -2
- package/dist/navigation.cjs +76 -1
- package/dist/navigation.js +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,9 +72,27 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
72
72
|
|
|
73
73
|
## Status
|
|
74
74
|
|
|
75
|
-
`v0.
|
|
75
|
+
`v0.151.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**.
|
|
76
76
|
|
|
77
|
-
### What's new in 0.
|
|
77
|
+
### What's new in 0.151.0 (contract 1.118.0 — unchanged)
|
|
78
|
+
|
|
79
|
+
**`resolveSidebarUnfold` joins the host navigation surface: where a page's local menu unfolds inside the app's own sidebar (sc-7696, REQ-NAV-LOCAL).** A page's local navigation could only be a bar, above or below its content. `local_nav` now also takes `SIDEBAR`, which takes no strip of the page: the saved menu unfolds in the rail and the drawer, under the global menu entry it belongs to.
|
|
80
|
+
|
|
81
|
+
- `resolveSidebarUnfold({ entries, active, sections })` answers `{ under, items, standalone }` from host-neutral keys — page slugs on web, screen names in the export — so it is the ONE derivation both chromes read. Web calls it per render; the compiler asks it once per screen at build time, because the only runtime input is which screen is showing.
|
|
82
|
+
- A page belongs to a section in either of two ways, and both keep the tree open: it carries the model, or the section's menu lists it. Clicking a destination must not collapse the menu that led there, and an author should not have to repeat the setting on every sub-page.
|
|
83
|
+
- `standalone` names the case where the global menu carries no page of the section: the section's own page is the entry, for as long as the visitor is inside it, so the menu is never lost.
|
|
84
|
+
- Each returned item carries `current`, so neither host recomputes which destination is the visitor's.
|
|
85
|
+
- **`CONTRACT` is untouched** (`version` stays `1.118.0`): this is a host resolver, not a widget-facing surface, and no existing export changed. `resolveTopBarTokens`, `resolveFooterTokens` and `resolveSidebarTokens` are unchanged — an unfolded row is a sidebar row and wears the rail's own tokens.
|
|
86
|
+
|
|
87
|
+
### What's new in 0.150.1 (contract 1.118.0 — unchanged)
|
|
88
|
+
|
|
89
|
+
**A capability's fallback belongs to the web build, not to native (sc-8381).** The barcode guidance here, in the in-Studio hooks reference and in both AI agents' prompts told you to gate the scan button on `supported` **and** keep a manual-entry path — unconditionally, while the reason given was a browser-only gap. `supported` is always `true` on the Expo export, so that read shipped a redundant "or type the code" field on every phone: the exact typing the scanner exists to remove. The rule is now scoped to the host that actually fails the gate.
|
|
90
|
+
|
|
91
|
+
- **Gate the substitute on the same flag that gates the button.** Where `supported` is `false` — the web Player in Safari and Firefox, which ship no `BarcodeDetector` — render the typed field instead of the scan button. Where it is `true`, the capability is the whole input. Nothing about the API changed; only the guidance did.
|
|
92
|
+
- **A native `catch` is an error state, not a web substitute.** The `react-native-nfc-manager` notes said to render "the same fallback from the `catch` that `widget.web.jsx` uses". A workspace that has not switched the NFC device capability on in Publish settings is a **misconfiguration** the widget should name, not a reason to park a permanent manual-entry field beside the tap target.
|
|
93
|
+
- **The web half is still required.** This narrows where a *substitute* belongs; it does not relax the parity rule. A native-only capability still ships a real way in on `widget.web.jsx` — a typed code, a QR scan, a non-sensor path — and still degrades visibly rather than rendering blank.
|
|
94
|
+
|
|
95
|
+
### What's new in 0.150.0 (contract 1.118.0)
|
|
78
96
|
|
|
79
97
|
**New primitive `<EmojiPicker>` — pick an emoji without leaving for the OS keyboard (sc-8104).** Typed emoji always worked: a plain `<TextInput>` stores and renders them on both hosts, and nothing about that changes. What was missing was an in-app way to *choose* one — for a message composer, or to react to something.
|
|
80
98
|
|
|
@@ -99,6 +117,10 @@ It presents inside `<Overlay>`, so it escapes the widget's clipping box on both
|
|
|
99
117
|
|
|
100
118
|
**Also new: a shared reaction model.** `groupReactions(rows, { typeField, userField, userId })` turns reaction rows into per-emoji pill groups — one row per (target, user, emoji), de-duplicated per user — and `readReactionEmoji(value)` maps a stored value to its emoji, ignoring anything it does not recognise. Widgets storing reactions should group through these rather than rolling their own, so pills behave identically wherever they appear.
|
|
101
119
|
|
|
120
|
+
### What's new in 0.149.0 (contract 1.117.0)
|
|
121
|
+
|
|
122
|
+
**Six text roles on the theme, so every widget speaks one typographic voice (sc-8058).** See the theme section above for the roles and how to read them.
|
|
123
|
+
|
|
102
124
|
### What's new in 0.148.0 (contract 1.116.0)
|
|
103
125
|
|
|
104
126
|
**New `useAgent(agentRef)` hook — a widget can talk to the WORKSPACE's own AI agent (sc-7991).** A new `agents` host client (`@colixsystems/agents-client`), a new `agentRef` propertySchema type and a new `ai.invoke:*` scope. Everything is additive; nothing existing changes shape.
|
|
@@ -299,7 +321,7 @@ Also tightened in this release: the `publicGrant` flags are now type-checked. Th
|
|
|
299
321
|
**NFC tags are vetted, native-only (`react-native-nfc-manager`).** A widget can now read and write NFC tags on the Expo export — NDEF over `NfcManager.requestTechnology(NfcTech.Ndef)`, plus the raw technologies a badge, asset tag or transit card uses: `NfcTech.IsoDep` on both hosts, `NfcTech.MifareClassic` on Android, `NfcTech.MifareIOS` / `FelicaIOS` / `Iso15693IOS` on iOS. (There is no `NfcTech.FeliCa` — check `index.d.ts` before naming one.) This is the last common "tap a physical thing" input the allowlist was missing; barcode and QR already had `useBarcodeScanner()`.
|
|
300
322
|
|
|
301
323
|
- **Native-only, and the browser is why.** Web NFC ships in Chrome on Android alone — Safari, Firefox and every desktop browser omit it — so there is no web build to pair with. Put the import in `widget.native.jsx` and give `widget.web.jsx` a real way in: a typed code field, or the SDK's `useBarcodeScanner()` QR path. The linter's `import-platform-mismatch` enforces the split.
|
|
302
|
-
- **Opt-in per workspace — and `isSupported()` cannot see that.** The export claims the iOS NFC entitlement and the Android `NFC` permission only when the publisher enables the **NFC tags** device capability in Publish settings. `isSupported()` probes the **device** (`readingAvailable` on iOS, `hasSystemFeature` on Android), not the gate: on a capable phone in an app that never asked, it returns `true` and the read then fails — Android throws a `SecurityException` from `registerTagEvent`, iOS cannot open the reader session. So wrap `start()` / `requestTechnology()` in a `try` and render
|
|
324
|
+
- **Opt-in per workspace — and `isSupported()` cannot see that.** The export claims the iOS NFC entitlement and the Android `NFC` permission only when the publisher enables the **NFC tags** device capability in Publish settings. `isSupported()` probes the **device** (`readingAvailable` on iOS, `hasSystemFeature` on Android), not the gate: on a capable phone in an app that never asked, it returns `true` and the read then fails — Android throws a `SecurityException` from `registerTagEvent`, iOS cannot open the reader session. So wrap `start()` / `requestTechnology()` in a `try` and render a clear **error state** from the `catch` — name what is missing and say the capability has to be switched on in Publish settings. That `catch` is an error path, **not** the web build's typed-code substitute: do not give `widget.native.jsx` a permanent manual-entry field beside the tap target, because in a correctly-configured app the radio works and the field is the typing the tag was meant to replace.
|
|
303
325
|
- **Clean up the session.** `cancelTechnologyRequest()` in your effect cleanup; an abandoned request leaves the reader sheet up on iOS.
|
|
304
326
|
- **It is a write capability too.** Read with `getNdefMessage()`; write with `writeNdefMessage(bytes)`, `formatNdef(bytes)` (Android only, for a blank tag) and `transceive(bytes)` on the `IsoDep` / `NfcA` / `NfcV` handlers. One capability covers both — Android expresses read and write through the single `android.permission.NFC` — so the workspace owner who enables it is consenting to every widget in the workspace being able to rewrite the tags their users tap. Raw ISO7816 `transceive` on iOS additionally needs `select-identifiers` on the entitlement, which the export does not declare; NDEF read and write work on both hosts.
|
|
305
327
|
- **`makeReadOnly()` is irreversible.** It permanently locks the tag — no later write will ever succeed, on any device, by any app. Call it only where the feature genuinely wants a one-time-programmable tag, and never as a cleanup step after writing.
|
|
@@ -378,13 +400,19 @@ const hit = await scan(); // { value, format } | null
|
|
|
378
400
|
|
|
379
401
|
It is deliberately **one-shot** rather than a start/stop subscription — to read several codes, call `scan()` again. There is no loop to leave running and no camera to forget to release.
|
|
380
402
|
|
|
381
|
-
**Gate the button on `supported`, and
|
|
403
|
+
**Gate the button on `supported`, and keep the manual-entry path for the host that fails that gate.** `BarcodeDetector` is Chromium-only today, so the web Player reports `supported: false` in Safari and Firefox — a genuine browser gap, not a missing feature, and there the scanner must not be the only way to enter a code. The Expo export always reports `true`, so do **not** mirror the typed field onto native: on the phone the scanner is the whole input, and the twin field is the typing the scan was meant to remove. Gate the substitute on the same flag that gates the button:
|
|
382
404
|
|
|
383
405
|
```jsx
|
|
384
|
-
|
|
385
|
-
|
|
406
|
+
{supported
|
|
407
|
+
? <Pressable onPress={scan}><Text>Scan</Text></Pressable>
|
|
408
|
+
: <TextInput value={code} onChangeText={setCode} placeholder="Enter the code" />}
|
|
409
|
+
{/* On the phone there is no typed field to fall back to, so a denied
|
|
410
|
+
camera has to say so rather than leave a button that does nothing. */}
|
|
411
|
+
{error ? <Text>Camera access is off — turn it on in Settings to scan.</Text> : null}
|
|
386
412
|
```
|
|
387
413
|
|
|
414
|
+
That error line is not optional on native. `supported` is `true` there, so the scan button is the only way in — a `PERMISSION_DENIED` the widget swallows leaves the user tapping a control that silently does nothing.
|
|
415
|
+
|
|
388
416
|
**Host-brokered, not a vetted import.** Like the camera and speech-to-text, your widget never imports the native module: `expo-camera` is pinned by the Expo export only, so a widget that does not scan gains no native dependency and nothing new enters widget bundles.
|
|
389
417
|
|
|
390
418
|
### What's new in 0.128.0 (contract 1.99.0 — unchanged)
|
|
@@ -1669,8 +1697,8 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
|
|
|
1669
1697
|
- **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
|
|
1670
1698
|
- **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
|
|
1671
1699
|
- **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
|
|
1672
|
-
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget.
|
|
1673
|
-
- **Compose forms — pair fields into rows, don't stack one per row.** Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
|
|
1700
|
+
- **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `onLayout={(e) => setWidth(e.nativeEvent.layout.width)}` on the root `View` (a React Native primitive, so it behaves identically on both hosts), render nothing size-dependent while `width === 0`, and compute every threshold from the measured value: a widget gets no declared breakpoint prop, but it can always measure. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces above ~720px measured width** (`flexDirection: width >= 720 ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead. **A metric / stat-tile row WRAPS** — it is a card grid with smaller cells (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }` plus `maxWidth: TILE_MIN * 1.6`), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
|
|
1701
|
+
- **Compose forms — pair fields into rows, don't stack one per row.** Cap the form column itself around 560–720px and centre it with the three declarations above, so it neither stretches across a full-width desktop section nor sits flush left with half the canvas empty beside it. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. When a field carries an icon beside it (a search glyph, a clear button), the border belongs on the WRAPPER row and the `TextInput` inside it goes borderless and transparent with `flex: 1, minWidth: 0` (the `minWidth: 0` stops a long value pushing the border past its container) — React Native has no `:focus-within`, so drive the wrapper's `borderColor` between `colors.border` and `colors.primary` from the input's own `onFocus` / `onBlur`. A border left on the input rings only its own `<input>` box on web, leaving the icon outside the ring; never absolutely-position the icon over the field to work around it.
|
|
1674
1702
|
- **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
|
|
1675
1703
|
- **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` below `isNarrowWidth`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
|
|
1676
1704
|
- **Use icons for clarity.** Pair a `lucide-react-native` icon with its label at a consistent size, coloured from the theme. The label never repeats the icon as a character — with a `Plus` icon the button says "Add item", never "+ Add item" (that renders a doubled plus).
|
package/dist/contract.cjs
CHANGED
|
@@ -1860,9 +1860,12 @@ const HOOKS = [
|
|
|
1860
1860
|
"PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `value` is the decoded text; `format` is a lowercase symbology name " +
|
|
1861
1861
|
"(qr_code, code_128, ean_13, …) — treat it as a hint, not a promise, because the two hosts detect different sets. " +
|
|
1862
1862
|
"It is deliberately ONE-SHOT rather than a start/stop subscription: to scan several codes, call scan() again. " +
|
|
1863
|
-
"reset() clears the last result and error. ALWAYS check `supported` before rendering a scan button and give the " +
|
|
1864
|
-
"widget a manual-entry path — the
|
|
1865
|
-
"Firefox today),
|
|
1863
|
+
"reset() clears the last result and error. ALWAYS check `supported` before rendering a scan button, and give the " +
|
|
1864
|
+
"widget a manual-entry path WHERE THAT GUARD FAILS — which is the WEB Player, wherever the browser ships no " +
|
|
1865
|
+
"BarcodeDetector (Safari and Firefox today), a real browser gap rather than a missing feature. The Expo export " +
|
|
1866
|
+
"ALWAYS reports true, so do NOT pair the scan button with a typed-code twin there: on the phone the scanner is the " +
|
|
1867
|
+
"whole input, and a redundant 'or type it in' field is the manual step the capability exists to remove. " +
|
|
1868
|
+
"options: { formats } is a HINT narrowing the " +
|
|
1866
1869
|
"symbologies to look for. The Expo export scans via expo-camera; the web Player via BarcodeDetector over the same " +
|
|
1867
1870
|
"getUserMedia preview useCamera() uses. The camera is released on every exit path, including a dismissal.",
|
|
1868
1871
|
returnShape: {
|
|
@@ -2634,7 +2637,8 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2634
2637
|
"BarcodeError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). Its formats hint and its format output BOTH speak " +
|
|
2635
2638
|
"BarcodeDetector names (qr_code, code_128, ean_13, ...) on both hosts; the Expo export maps them to expo-camera own " +
|
|
2636
2639
|
"vocabulary internally and DROPS a name it cannot map. isSupported() is false wherever the browser ships no " +
|
|
2637
|
-
"BarcodeDetector (Safari, Firefox), so a
|
|
2640
|
+
"BarcodeDetector (Safari, Firefox), so a WEB build must offer manual entry beside a scan button; the Expo export " +
|
|
2641
|
+
"always reports true and takes the scanner alone — no typed-code twin on native. " +
|
|
2638
2642
|
"sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
|
|
2639
2643
|
"did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
|
|
2640
2644
|
"expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
|
|
@@ -2957,14 +2961,14 @@ const VETTED_IMPORTS = [
|
|
|
2957
2961
|
platforms: ["native"],
|
|
2958
2962
|
category: "sensors",
|
|
2959
2963
|
description:
|
|
2960
|
-
"Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx
|
|
2964
|
+
"Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx. There is NO web path through this package OR through the browser motion API: the browser exposes it as window.DeviceMotionEvent, and `window` is a BANNED widget identifier (see BANNED_APIS), so a widget.web.jsx reading it is rejected by the linter. Give the web half a non-sensor way in instead — manual entry, or a control that does not need motion. On native, readings still need a user gesture, so gate them behind a Pressable and never start them on mount.",
|
|
2961
2965
|
},
|
|
2962
2966
|
{
|
|
2963
2967
|
specifier: "react-native-nfc-manager",
|
|
2964
2968
|
platforms: ["native"],
|
|
2965
2969
|
category: "system",
|
|
2966
2970
|
description:
|
|
2967
|
-
"Read and write NFC tags on the Expo export: NDEF records via NfcManager.requestTechnology(NfcTech.Ndef), plus the raw technologies a badge, asset tag or transit card uses - NfcTech.IsoDep on both hosts, NfcTech.MifareClassic on Android, NfcTech.MifareIOS / FelicaIOS / Iso15693IOS on iOS (there is no NfcTech.FeliCa; check index.d.ts before naming one). Read with ndefHandler.getNdefMessage(); WRITE with writeNdefMessage(bytes), formatNdef(bytes) (Android only, for a blank tag) and transceive(bytes) on the IsoDep / NfcA / NfcV handlers. makeReadOnly() PERMANENTLY locks a tag - it cannot be undone and no later write will ever succeed, so call it only where the brief actually asks for a one-time-programmable tag, never as a tidy-up step. Raw ISO7816 transceive on iOS additionally needs select-identifiers on the entitlement, which this export does not declare - NDEF read and write work on both hosts. Always cancelTechnologyRequest() in a cleanup - an abandoned session leaves the reader sheet up on iOS. Native-only because no browser hands the Player a reader: Web NFC exists in Chrome on Android alone (Safari, Firefox and every desktop browser omit it), so there is no web build to pair with. Author it in widget.native.jsx and give widget.web.jsx a real way in instead - a typed code field, or the SDK's useBarcodeScanner() QR path. The radio is ALSO opt-in per workspace: the export claims the iOS NFC entitlement and the Android NFC permission only when the publisher enables the 'nfc' device capability in Publish settings. isSupported() does NOT see that gate - it probes the DEVICE (readingAvailable on iOS, hasSystemFeature on Android), so on a capable phone in an app that never asked it returns true and the read then fails: Android throws a SecurityException from registerTagEvent, iOS cannot open the reader session. So wrap start() / requestTechnology() in a try and render
|
|
2971
|
+
"Read and write NFC tags on the Expo export: NDEF records via NfcManager.requestTechnology(NfcTech.Ndef), plus the raw technologies a badge, asset tag or transit card uses - NfcTech.IsoDep on both hosts, NfcTech.MifareClassic on Android, NfcTech.MifareIOS / FelicaIOS / Iso15693IOS on iOS (there is no NfcTech.FeliCa; check index.d.ts before naming one). Read with ndefHandler.getNdefMessage(); WRITE with writeNdefMessage(bytes), formatNdef(bytes) (Android only, for a blank tag) and transceive(bytes) on the IsoDep / NfcA / NfcV handlers. makeReadOnly() PERMANENTLY locks a tag - it cannot be undone and no later write will ever succeed, so call it only where the brief actually asks for a one-time-programmable tag, never as a tidy-up step. Raw ISO7816 transceive on iOS additionally needs select-identifiers on the entitlement, which this export does not declare - NDEF read and write work on both hosts. Always cancelTechnologyRequest() in a cleanup - an abandoned session leaves the reader sheet up on iOS. Native-only because no browser hands the Player a reader: Web NFC exists in Chrome on Android alone (Safari, Firefox and every desktop browser omit it), so there is no web build to pair with. Author it in widget.native.jsx and give widget.web.jsx a real way in instead - a typed code field, or the SDK's useBarcodeScanner() QR path. The radio is ALSO opt-in per workspace: the export claims the iOS NFC entitlement and the Android NFC permission only when the publisher enables the 'nfc' device capability in Publish settings. isSupported() does NOT see that gate - it probes the DEVICE (readingAvailable on iOS, hasSystemFeature on Android), so on a capable phone in an app that never asked it returns true and the read then fails: Android throws a SecurityException from registerTagEvent, iOS cannot open the reader session. So wrap start() / requestTechnology() in a try and render a clear ERROR STATE from the catch - name what is missing and say the maker must switch 'nfc' on in Publish settings. That catch is an ERROR path, NOT the web build's typed-code substitute: do not give widget.native.jsx a permanent manual-entry field beside the tap target, because in a correctly-configured app the radio works and the field is the typing the tag was meant to replace. Pinned at 3.17.2, the current stable release (4.x is still beta); a community package, so Expo bundledNativeModules has no version opinion on it.",
|
|
2968
2972
|
},
|
|
2969
2973
|
{
|
|
2970
2974
|
specifier: "decimal.js",
|
package/dist/contract.js
CHANGED
|
@@ -1860,9 +1860,12 @@ const HOOKS = [
|
|
|
1860
1860
|
"PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `value` is the decoded text; `format` is a lowercase symbology name " +
|
|
1861
1861
|
"(qr_code, code_128, ean_13, …) — treat it as a hint, not a promise, because the two hosts detect different sets. " +
|
|
1862
1862
|
"It is deliberately ONE-SHOT rather than a start/stop subscription: to scan several codes, call scan() again. " +
|
|
1863
|
-
"reset() clears the last result and error. ALWAYS check `supported` before rendering a scan button and give the " +
|
|
1864
|
-
"widget a manual-entry path — the
|
|
1865
|
-
"Firefox today),
|
|
1863
|
+
"reset() clears the last result and error. ALWAYS check `supported` before rendering a scan button, and give the " +
|
|
1864
|
+
"widget a manual-entry path WHERE THAT GUARD FAILS — which is the WEB Player, wherever the browser ships no " +
|
|
1865
|
+
"BarcodeDetector (Safari and Firefox today), a real browser gap rather than a missing feature. The Expo export " +
|
|
1866
|
+
"ALWAYS reports true, so do NOT pair the scan button with a typed-code twin there: on the phone the scanner is the " +
|
|
1867
|
+
"whole input, and a redundant 'or type it in' field is the manual step the capability exists to remove. " +
|
|
1868
|
+
"options: { formats } is a HINT narrowing the " +
|
|
1866
1869
|
"symbologies to look for. The Expo export scans via expo-camera; the web Player via BarcodeDetector over the same " +
|
|
1867
1870
|
"getUserMedia preview useCamera() uses. The camera is released on every exit path, including a dismissal.",
|
|
1868
1871
|
returnShape: {
|
|
@@ -2630,7 +2633,8 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2630
2633
|
"BarcodeError (.code PERMISSION_DENIED | UNSUPPORTED | INTERNAL). Its formats hint and its format output BOTH speak " +
|
|
2631
2634
|
"BarcodeDetector names (qr_code, code_128, ean_13, ...) on both hosts; the Expo export maps them to expo-camera own " +
|
|
2632
2635
|
"vocabulary internally and DROPS a name it cannot map. isSupported() is false wherever the browser ships no " +
|
|
2633
|
-
"BarcodeDetector (Safari, Firefox), so a
|
|
2636
|
+
"BarcodeDetector (Safari, Firefox), so a WEB build must offer manual entry beside a scan button; the Expo export " +
|
|
2637
|
+
"always reports true and takes the scanner alone — no typed-code twin on native. " +
|
|
2634
2638
|
"sc-6450 — the geolocation background-watch members are NATIVE-ONLY and opt-in per app: the web Player and an export that " +
|
|
2635
2639
|
"did not opt in both report isBackgroundSupported() false, and the Expo export backs it with expo-location + " +
|
|
2636
2640
|
"expo-task-manager. subscribeBackgroundPositions and subscribeBackgroundWatchState are plain subscriptions — they start no " +
|
|
@@ -2953,14 +2957,14 @@ const VETTED_IMPORTS = [
|
|
|
2953
2957
|
platforms: ["native"],
|
|
2954
2958
|
category: "sensors",
|
|
2955
2959
|
description:
|
|
2956
|
-
"Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx
|
|
2960
|
+
"Device motion hardware on the Expo export: Accelerometer, Gyroscope, Magnetometer, DeviceMotion, Barometer, Pedometer and LightSensor, each read as an addListener subscription with setUpdateInterval — always remove the subscription on unmount, a sensor left running drains the battery. Expo SDK 56 ships 56.0.x. Native-only on purpose: the package's own web build derives acceleration from deviceorientation ANGLES rather than real motion, so a shake or tilt threshold tuned on one host would read differently on the other. Author it in widget.native.jsx. There is NO web path through this package OR through the browser motion API: the browser exposes it as window.DeviceMotionEvent, and `window` is a BANNED widget identifier (see BANNED_APIS), so a widget.web.jsx reading it is rejected by the linter. Give the web half a non-sensor way in instead — manual entry, or a control that does not need motion. On native, readings still need a user gesture, so gate them behind a Pressable and never start them on mount.",
|
|
2957
2961
|
},
|
|
2958
2962
|
{
|
|
2959
2963
|
specifier: "react-native-nfc-manager",
|
|
2960
2964
|
platforms: ["native"],
|
|
2961
2965
|
category: "system",
|
|
2962
2966
|
description:
|
|
2963
|
-
"Read and write NFC tags on the Expo export: NDEF records via NfcManager.requestTechnology(NfcTech.Ndef), plus the raw technologies a badge, asset tag or transit card uses - NfcTech.IsoDep on both hosts, NfcTech.MifareClassic on Android, NfcTech.MifareIOS / FelicaIOS / Iso15693IOS on iOS (there is no NfcTech.FeliCa; check index.d.ts before naming one). Read with ndefHandler.getNdefMessage(); WRITE with writeNdefMessage(bytes), formatNdef(bytes) (Android only, for a blank tag) and transceive(bytes) on the IsoDep / NfcA / NfcV handlers. makeReadOnly() PERMANENTLY locks a tag - it cannot be undone and no later write will ever succeed, so call it only where the brief actually asks for a one-time-programmable tag, never as a tidy-up step. Raw ISO7816 transceive on iOS additionally needs select-identifiers on the entitlement, which this export does not declare - NDEF read and write work on both hosts. Always cancelTechnologyRequest() in a cleanup - an abandoned session leaves the reader sheet up on iOS. Native-only because no browser hands the Player a reader: Web NFC exists in Chrome on Android alone (Safari, Firefox and every desktop browser omit it), so there is no web build to pair with. Author it in widget.native.jsx and give widget.web.jsx a real way in instead - a typed code field, or the SDK's useBarcodeScanner() QR path. The radio is ALSO opt-in per workspace: the export claims the iOS NFC entitlement and the Android NFC permission only when the publisher enables the 'nfc' device capability in Publish settings. isSupported() does NOT see that gate - it probes the DEVICE (readingAvailable on iOS, hasSystemFeature on Android), so on a capable phone in an app that never asked it returns true and the read then fails: Android throws a SecurityException from registerTagEvent, iOS cannot open the reader session. So wrap start() / requestTechnology() in a try and render
|
|
2967
|
+
"Read and write NFC tags on the Expo export: NDEF records via NfcManager.requestTechnology(NfcTech.Ndef), plus the raw technologies a badge, asset tag or transit card uses - NfcTech.IsoDep on both hosts, NfcTech.MifareClassic on Android, NfcTech.MifareIOS / FelicaIOS / Iso15693IOS on iOS (there is no NfcTech.FeliCa; check index.d.ts before naming one). Read with ndefHandler.getNdefMessage(); WRITE with writeNdefMessage(bytes), formatNdef(bytes) (Android only, for a blank tag) and transceive(bytes) on the IsoDep / NfcA / NfcV handlers. makeReadOnly() PERMANENTLY locks a tag - it cannot be undone and no later write will ever succeed, so call it only where the brief actually asks for a one-time-programmable tag, never as a tidy-up step. Raw ISO7816 transceive on iOS additionally needs select-identifiers on the entitlement, which this export does not declare - NDEF read and write work on both hosts. Always cancelTechnologyRequest() in a cleanup - an abandoned session leaves the reader sheet up on iOS. Native-only because no browser hands the Player a reader: Web NFC exists in Chrome on Android alone (Safari, Firefox and every desktop browser omit it), so there is no web build to pair with. Author it in widget.native.jsx and give widget.web.jsx a real way in instead - a typed code field, or the SDK's useBarcodeScanner() QR path. The radio is ALSO opt-in per workspace: the export claims the iOS NFC entitlement and the Android NFC permission only when the publisher enables the 'nfc' device capability in Publish settings. isSupported() does NOT see that gate - it probes the DEVICE (readingAvailable on iOS, hasSystemFeature on Android), so on a capable phone in an app that never asked it returns true and the read then fails: Android throws a SecurityException from registerTagEvent, iOS cannot open the reader session. So wrap start() / requestTechnology() in a try and render a clear ERROR STATE from the catch - name what is missing and say the maker must switch 'nfc' on in Publish settings. That catch is an ERROR path, NOT the web build's typed-code substitute: do not give widget.native.jsx a permanent manual-entry field beside the tap target, because in a correctly-configured app the radio works and the field is the typing the tag was meant to replace. Pinned at 3.17.2, the current stable release (4.x is still beta); a community package, so Expo bundledNativeModules has no version opinion on it.",
|
|
2964
2968
|
},
|
|
2965
2969
|
{
|
|
2966
2970
|
specifier: "decimal.js",
|
package/dist/host.d.ts
CHANGED
|
@@ -224,6 +224,35 @@ export interface SidebarTokens {
|
|
|
224
224
|
*/
|
|
225
225
|
export function resolveSidebarTokens(theme: unknown): SidebarTokens;
|
|
226
226
|
|
|
227
|
+
/** One destination of a page's local menu, as a host hands it over. `key`
|
|
228
|
+
* identifies the target in the caller's own vocabulary (a page slug on web, a
|
|
229
|
+
* screen name in the export); a URL item carries none. */
|
|
230
|
+
export interface SidebarUnfoldItem {
|
|
231
|
+
key?: string | null;
|
|
232
|
+
[field: string]: unknown;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Where a page's `SIDEBAR` local menu unfolds (REQ-NAV-LOCAL, sc-7696).
|
|
236
|
+
* `under` is the global menu entry the items hang beneath, or null when
|
|
237
|
+
* nothing unfolds. `standalone` marks the case where the global menu does not
|
|
238
|
+
* list the page, so the page itself is the entry. */
|
|
239
|
+
export interface SidebarUnfold {
|
|
240
|
+
under: string | null;
|
|
241
|
+
items: Array<SidebarUnfoldItem & { current: boolean }>;
|
|
242
|
+
standalone: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Resolve where the viewed page's local menu unfolds in the sidebar and the
|
|
246
|
+
* drawer. The one derivation both hosts read: the Player calls it per render,
|
|
247
|
+
* the compiler once per screen, so the rail and the exported drawer cannot
|
|
248
|
+
* disagree. */
|
|
249
|
+
export function resolveSidebarUnfold(input: {
|
|
250
|
+
entries?: readonly string[];
|
|
251
|
+
active?: string | null;
|
|
252
|
+
sections?: Record<string, { menu?: unknown; items?: readonly unknown[] }>;
|
|
253
|
+
}): SidebarUnfold;
|
|
254
|
+
|
|
255
|
+
|
|
227
256
|
/** The top app bar's resolved tokens. `tintColor` and `titleColor` are separate
|
|
228
257
|
* because an unthemed bar paints its icons slate and its app name in the brand
|
|
229
258
|
* colour; an authored `topBar.textColor` drives both. The bar carries NO
|
package/dist/host.js
CHANGED
|
@@ -63,12 +63,17 @@ export {
|
|
|
63
63
|
// REQ-THEME-14: `resolveSidebarTokens` and `resolveTopBarTokens` are the same
|
|
64
64
|
// idea for the rail and the app bar, whose defaults used to live inline in each
|
|
65
65
|
// host and had already drifted apart.
|
|
66
|
+
// REQ-NAV-LOCAL (sc-7696): `resolveSidebarUnfold` answers where a page's
|
|
67
|
+
// SIDEBAR local menu hangs in the rail and the drawer. The compiler asks it
|
|
68
|
+
// once per screen at build time and the Player once per render, so the two
|
|
69
|
+
// chromes derive the same tree from the same code.
|
|
66
70
|
export {
|
|
67
71
|
normaliseNavigation,
|
|
68
72
|
menuItemCap,
|
|
69
73
|
resolveFooterTokens,
|
|
70
74
|
resolveSidebarTokens,
|
|
71
75
|
resolveTopBarTokens,
|
|
76
|
+
resolveSidebarUnfold,
|
|
72
77
|
} from "./navigation.js";
|
|
73
78
|
|
|
74
79
|
// sc-5717: the codec behind `useWidgetRoute()` — how a widget's own internal
|
package/dist/linter.cjs
CHANGED
|
@@ -1498,11 +1498,16 @@ const _GENERIC_STYLE_GROUPS = new Set([
|
|
|
1498
1498
|
"general",
|
|
1499
1499
|
]);
|
|
1500
1500
|
|
|
1501
|
-
// The
|
|
1501
|
+
// The five the basic-style gate mandates; they restyle the widget as a whole.
|
|
1502
|
+
// Lockstep with BASIC_STYLE_FIELDS (backend/src/core/services/widget-basic-style.ts)
|
|
1503
|
+
// — a mandated field missing here reads as per-element coverage the widget does
|
|
1504
|
+
// not have, which silently retires this rule for EVERY generated widget.
|
|
1502
1505
|
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
1503
1506
|
"background",
|
|
1504
1507
|
"textColor",
|
|
1505
1508
|
"align",
|
|
1509
|
+
"padding",
|
|
1510
|
+
"margin",
|
|
1506
1511
|
]);
|
|
1507
1512
|
|
|
1508
1513
|
// A second element an author would style apart from the text. Pressable and
|
|
@@ -1697,7 +1702,7 @@ function _styleCoverageFindings(manifest, codes) {
|
|
|
1697
1702
|
severity: "warning",
|
|
1698
1703
|
label:
|
|
1699
1704
|
`The widget renders more than one visual element but its styleSchema ` +
|
|
1700
|
-
`stops at
|
|
1705
|
+
`stops at the whole-widget basics. Add 2-4 grouped per-element ` +
|
|
1701
1706
|
`fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
|
|
1702
1707
|
`each applied ONLY when set.`,
|
|
1703
1708
|
line: 0,
|
package/dist/linter.js
CHANGED
|
@@ -1569,11 +1569,16 @@ const _GENERIC_STYLE_GROUPS = new Set([
|
|
|
1569
1569
|
"general",
|
|
1570
1570
|
]);
|
|
1571
1571
|
|
|
1572
|
-
// The
|
|
1572
|
+
// The five the basic-style gate mandates; they restyle the widget as a whole.
|
|
1573
|
+
// Lockstep with BASIC_STYLE_FIELDS (backend/src/core/services/widget-basic-style.ts)
|
|
1574
|
+
// — a mandated field missing here reads as per-element coverage the widget does
|
|
1575
|
+
// not have, which silently retires this rule for EVERY generated widget.
|
|
1573
1576
|
const _WHOLE_WIDGET_STYLE_FIELDS = new Set([
|
|
1574
1577
|
"background",
|
|
1575
1578
|
"textColor",
|
|
1576
1579
|
"align",
|
|
1580
|
+
"padding",
|
|
1581
|
+
"margin",
|
|
1577
1582
|
]);
|
|
1578
1583
|
|
|
1579
1584
|
// A second element an author would style apart from the text. Pressable and
|
|
@@ -1768,7 +1773,7 @@ function _styleCoverageFindings(manifest, codes) {
|
|
|
1768
1773
|
severity: "warning",
|
|
1769
1774
|
label:
|
|
1770
1775
|
`The widget renders more than one visual element but its styleSchema ` +
|
|
1771
|
-
`stops at
|
|
1776
|
+
`stops at the whole-widget basics. Add 2-4 grouped per-element ` +
|
|
1772
1777
|
`fields (cardBackground, titleColor, rowBackground, imageRadius), ` +
|
|
1773
1778
|
`each applied ONLY when set.`,
|
|
1774
1779
|
line: 0,
|
package/dist/navigation.cjs
CHANGED
|
@@ -366,6 +366,81 @@ function resolveTopBarTokens(theme) {
|
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* REQ-NAV-LOCAL (sc-7696) — where a page's `SIDEBAR` local menu unfolds.
|
|
371
|
+
*
|
|
372
|
+
* The one derivation both hosts read, so the web rail, the web drawer and the
|
|
373
|
+
* exported app's drawer cannot disagree about what hangs under what. Keys are
|
|
374
|
+
* the caller's own (the web Player passes page slugs, the compiler passes
|
|
375
|
+
* screen names); this only compares them.
|
|
376
|
+
*
|
|
377
|
+
* A page belongs to a section in either of two ways, and both keep the tree
|
|
378
|
+
* open: it CARRIES the model itself, or the section's menu LISTS it. The
|
|
379
|
+
* second is what makes the model usable — clicking a destination would
|
|
380
|
+
* otherwise collapse the menu that led there unless the author repeated the
|
|
381
|
+
* setting on every sub-page.
|
|
382
|
+
*
|
|
383
|
+
* The unfold point is the first GLOBAL menu entry carrying that section's
|
|
384
|
+
* saved menu, so the tree hangs in the same place from every page of the
|
|
385
|
+
* section. When the global menu lists no such page, the section's own page
|
|
386
|
+
* becomes a top-level entry (`standalone`) for as long as the visitor is
|
|
387
|
+
* inside it, so the menu is never lost — a section off the menu is exactly
|
|
388
|
+
* the case that would otherwise render blank.
|
|
389
|
+
*
|
|
390
|
+
* @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
|
|
391
|
+
* `entries` — the global menu's keys, in menu order.
|
|
392
|
+
* `active` — the key of the page being viewed.
|
|
393
|
+
* `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
|
|
394
|
+
* `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
|
|
395
|
+
* @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
|
|
396
|
+
* `under` is null when nothing unfolds; when `standalone` it names the
|
|
397
|
+
* section's own page, which the host draws as the entry. Each item is
|
|
398
|
+
* returned with a `current` flag rather than leaving each host to
|
|
399
|
+
* recompute it.
|
|
400
|
+
*/
|
|
401
|
+
function resolveSidebarUnfold(input) {
|
|
402
|
+
const { entries, active, sections } = isPlainObject(input) ? input : {};
|
|
403
|
+
const nothing = { under: null, items: [], standalone: false };
|
|
404
|
+
if (typeof active !== "string" || !active) return nothing;
|
|
405
|
+
const bank = isPlainObject(sections) ? sections : {};
|
|
406
|
+
const order = Array.isArray(entries) ? entries : [];
|
|
407
|
+
const sectionAt = (key) => {
|
|
408
|
+
const candidate = isPlainObject(bank[key]) ? bank[key] : null;
|
|
409
|
+
const items = candidate && Array.isArray(candidate.items) ? candidate.items : [];
|
|
410
|
+
return items.length ? { key, menu: candidate.menu, items } : null;
|
|
411
|
+
};
|
|
412
|
+
const lists = (key) => {
|
|
413
|
+
const candidate = sectionAt(key);
|
|
414
|
+
return (
|
|
415
|
+
Boolean(candidate) &&
|
|
416
|
+
candidate.items.some(
|
|
417
|
+
(item) => isPlainObject(item) && item.key === active,
|
|
418
|
+
)
|
|
419
|
+
);
|
|
420
|
+
};
|
|
421
|
+
// The page's own section wins; otherwise the section whose menu lists it,
|
|
422
|
+
// preferring one a global entry carries so the tree hangs where it always
|
|
423
|
+
// does. Both are the same section from the visitor's side.
|
|
424
|
+
const section =
|
|
425
|
+
sectionAt(active) ||
|
|
426
|
+
sectionAt(order.find(lists)) ||
|
|
427
|
+
sectionAt(Object.keys(bank).find(lists));
|
|
428
|
+
if (!section) return nothing;
|
|
429
|
+
const marked = section.items.map((item) => ({
|
|
430
|
+
...(isPlainObject(item) ? item : {}),
|
|
431
|
+
current:
|
|
432
|
+
isPlainObject(item) && typeof item.key === "string" && item.key === active,
|
|
433
|
+
}));
|
|
434
|
+
const owner = order.find((key) => {
|
|
435
|
+
const candidate = isPlainObject(bank[key]) ? bank[key] : null;
|
|
436
|
+
return Boolean(candidate) && candidate.menu === section.menu;
|
|
437
|
+
});
|
|
438
|
+
if (typeof owner === "string" && owner) {
|
|
439
|
+
return { under: owner, items: marked, standalone: false };
|
|
440
|
+
}
|
|
441
|
+
return { under: section.key, items: marked, standalone: true };
|
|
442
|
+
}
|
|
443
|
+
|
|
369
444
|
// REQ-THEME-LOOK: the divider's width in px, bounded HERE so both hosts inherit
|
|
370
445
|
// one range. The export clamped it on its way into the StyleSheet
|
|
371
446
|
// (`coerceBorderWidth(...) ?? 1`) where the Player passed any finite number
|
|
@@ -402,4 +477,4 @@ function borderWidthOr(...values) {
|
|
|
402
477
|
return BORDER_WIDTH_MIN;
|
|
403
478
|
}
|
|
404
479
|
|
|
405
|
-
module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopBarTokens };
|
|
480
|
+
module.exports = { normaliseNavigation, menuItemCap, resolveFooterTokens, resolveSidebarTokens, resolveTopBarTokens, resolveSidebarUnfold };
|
package/dist/navigation.js
CHANGED
|
@@ -357,6 +357,81 @@ export function resolveTopBarTokens(theme) {
|
|
|
357
357
|
};
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* REQ-NAV-LOCAL (sc-7696) — where a page's `SIDEBAR` local menu unfolds.
|
|
362
|
+
*
|
|
363
|
+
* The one derivation both hosts read, so the web rail, the web drawer and the
|
|
364
|
+
* exported app's drawer cannot disagree about what hangs under what. Keys are
|
|
365
|
+
* the caller's own (the web Player passes page slugs, the compiler passes
|
|
366
|
+
* screen names); this only compares them.
|
|
367
|
+
*
|
|
368
|
+
* A page belongs to a section in either of two ways, and both keep the tree
|
|
369
|
+
* open: it CARRIES the model itself, or the section's menu LISTS it. The
|
|
370
|
+
* second is what makes the model usable — clicking a destination would
|
|
371
|
+
* otherwise collapse the menu that led there unless the author repeated the
|
|
372
|
+
* setting on every sub-page.
|
|
373
|
+
*
|
|
374
|
+
* The unfold point is the first GLOBAL menu entry carrying that section's
|
|
375
|
+
* saved menu, so the tree hangs in the same place from every page of the
|
|
376
|
+
* section. When the global menu lists no such page, the section's own page
|
|
377
|
+
* becomes a top-level entry (`standalone`) for as long as the visitor is
|
|
378
|
+
* inside it, so the menu is never lost — a section off the menu is exactly
|
|
379
|
+
* the case that would otherwise render blank.
|
|
380
|
+
*
|
|
381
|
+
* @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
|
|
382
|
+
* `entries` — the global menu's keys, in menu order.
|
|
383
|
+
* `active` — the key of the page being viewed.
|
|
384
|
+
* `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
|
|
385
|
+
* `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
|
|
386
|
+
* @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
|
|
387
|
+
* `under` is null when nothing unfolds; when `standalone` it names the
|
|
388
|
+
* section's own page, which the host draws as the entry. Each item is
|
|
389
|
+
* returned with a `current` flag rather than leaving each host to
|
|
390
|
+
* recompute it.
|
|
391
|
+
*/
|
|
392
|
+
export function resolveSidebarUnfold(input) {
|
|
393
|
+
const { entries, active, sections } = isPlainObject(input) ? input : {};
|
|
394
|
+
const nothing = { under: null, items: [], standalone: false };
|
|
395
|
+
if (typeof active !== "string" || !active) return nothing;
|
|
396
|
+
const bank = isPlainObject(sections) ? sections : {};
|
|
397
|
+
const order = Array.isArray(entries) ? entries : [];
|
|
398
|
+
const sectionAt = (key) => {
|
|
399
|
+
const candidate = isPlainObject(bank[key]) ? bank[key] : null;
|
|
400
|
+
const items = candidate && Array.isArray(candidate.items) ? candidate.items : [];
|
|
401
|
+
return items.length ? { key, menu: candidate.menu, items } : null;
|
|
402
|
+
};
|
|
403
|
+
const lists = (key) => {
|
|
404
|
+
const candidate = sectionAt(key);
|
|
405
|
+
return (
|
|
406
|
+
Boolean(candidate) &&
|
|
407
|
+
candidate.items.some(
|
|
408
|
+
(item) => isPlainObject(item) && item.key === active,
|
|
409
|
+
)
|
|
410
|
+
);
|
|
411
|
+
};
|
|
412
|
+
// The page's own section wins; otherwise the section whose menu lists it,
|
|
413
|
+
// preferring one a global entry carries so the tree hangs where it always
|
|
414
|
+
// does. Both are the same section from the visitor's side.
|
|
415
|
+
const section =
|
|
416
|
+
sectionAt(active) ||
|
|
417
|
+
sectionAt(order.find(lists)) ||
|
|
418
|
+
sectionAt(Object.keys(bank).find(lists));
|
|
419
|
+
if (!section) return nothing;
|
|
420
|
+
const marked = section.items.map((item) => ({
|
|
421
|
+
...(isPlainObject(item) ? item : {}),
|
|
422
|
+
current:
|
|
423
|
+
isPlainObject(item) && typeof item.key === "string" && item.key === active,
|
|
424
|
+
}));
|
|
425
|
+
const owner = order.find((key) => {
|
|
426
|
+
const candidate = isPlainObject(bank[key]) ? bank[key] : null;
|
|
427
|
+
return Boolean(candidate) && candidate.menu === section.menu;
|
|
428
|
+
});
|
|
429
|
+
if (typeof owner === "string" && owner) {
|
|
430
|
+
return { under: owner, items: marked, standalone: false };
|
|
431
|
+
}
|
|
432
|
+
return { under: section.key, items: marked, standalone: true };
|
|
433
|
+
}
|
|
434
|
+
|
|
360
435
|
// REQ-THEME-LOOK: the divider's width in px, bounded HERE so both hosts inherit
|
|
361
436
|
// one range. The export clamped it on its way into the StyleSheet
|
|
362
437
|
// (`coerceBorderWidth(...) ?? 1`) where the Player passed any finite number
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.151.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",
|