@colixsystems/widget-sdk 0.196.0 → 0.198.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/dist/host.d.ts CHANGED
@@ -254,14 +254,26 @@ export interface SidebarUnfoldItem {
254
254
  [field: string]: unknown;
255
255
  }
256
256
 
257
+ /** One section's local menu and the sidebar entry it hangs beneath. `inside`
258
+ * marks the section the visitor is in — the one that starts unfolded. */
259
+ export interface SidebarUnfoldTree {
260
+ under: string;
261
+ items: Array<SidebarUnfoldItem & { current: boolean }>;
262
+ standalone: boolean;
263
+ inside: boolean;
264
+ }
265
+
257
266
  /** Where a page's `SIDEBAR` local menu unfolds (REQ-NAV-LOCAL, sc-7696).
258
- * `under` is the global menu entry the items hang beneath, or null when
259
- * nothing unfolds. `standalone` marks the case where the global menu does not
260
- * list the page, so the page itself is the entry. */
267
+ * `under` is the global menu entry the visitor's own section hangs beneath,
268
+ * or null when they are in none. `standalone` marks the case where the global
269
+ * menu does not list the page, so the page itself is the entry. `trees` is
270
+ * every section an entry carries, in menu order, so an entry can offer its
271
+ * menu from any page; a `standalone` tree comes last. */
261
272
  export interface SidebarUnfold {
262
273
  under: string | null;
263
274
  items: Array<SidebarUnfoldItem & { current: boolean }>;
264
275
  standalone: boolean;
276
+ trees: SidebarUnfoldTree[];
265
277
  }
266
278
 
267
279
  /** Resolve where the viewed page's local menu unfolds in the sidebar and the
package/dist/index.d.ts CHANGED
@@ -187,6 +187,12 @@ export interface WidgetDatastoreTemplateColumn {
187
187
  relationType?: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_MANY";
188
188
  /** REQ-ACL-RELINHERIT: opt this RELATION column into row-level ACL inheritance. */
189
189
  inheritAcl?: boolean;
190
+ /**
191
+ * With `inheritAcl: true` only: rows inherit read access alone. Members of
192
+ * the parent row read every child and may add children, but change or
193
+ * delete only the rows they created (pair with `grantCreatorPermissions`).
194
+ */
195
+ inheritAclReadOnly?: boolean;
190
196
  /**
191
197
  * ONE_TO_ONE / ONE_TO_MANY only: what deleting the referenced row does to
192
198
  * this table's rows. `CASCADE` deletes them too; `SET_NULL` (the default)
@@ -202,6 +208,26 @@ export interface WidgetDatastoreTemplateColumn {
202
208
  * the backend must resolve); publishing one is rejected.
203
209
  */
204
210
  encrypted?: boolean;
211
+ /**
212
+ * Written when a create omits the column. `"@currentUser"` (USER) stamps
213
+ * the signed-in user and `"@now"` (DATE / DATETIME) the current date or
214
+ * time. Other types take a literal of their own type. FILE, RELATION and
215
+ * USER_GROUP columns take none, because ids belong to the installing
216
+ * workspace.
217
+ */
218
+ defaultValue?: unknown;
219
+ /**
220
+ * With a `"@currentUser"` or `"@now"` default only: the server always
221
+ * stamps it. Any value a client sends for the column is ignored on create
222
+ * and on update, so an author column cannot be forged.
223
+ */
224
+ defaultLocked?: boolean;
225
+ /**
226
+ * Write-once: a create sets the column as usual (the value sent, or the
227
+ * default), and any value sent for it on update is ignored. Not valid on a
228
+ * `MANY_TO_MANY` relation, whose links live outside the row.
229
+ */
230
+ immutable?: boolean;
205
231
  }
206
232
 
207
233
  export interface WidgetDatastoreTemplateTable {
@@ -367,7 +393,7 @@ export interface WidgetManifest {
367
393
  * under a per-widget namespace (`widget.<id>.<key>`), so `useI18n().t(key)`
368
394
  * resolves the namespaced key automatically — the author never types the
369
395
  * prefix. Non-destructive (admin edits win; absent languages are not
370
- * created) and persists across uninstalls. Caps: ≤100 keys, key matches
396
+ * created) and persists across uninstalls. Caps: ≤200 keys, key matches
371
397
  * /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/, value ≤1 KB.
372
398
  */
373
399
  translations?: Record<string, { en: string } & Record<string, string>>;
@@ -639,6 +665,35 @@ export interface IdentificationClient {
639
665
  */
640
666
  export interface NotificationsClient {
641
667
  send(body: SendNotificationRequest): Promise<NotificationRow>;
668
+ /**
669
+ * sc-10268 — the signed-in user's per-record mutes; backs `useRecordMute`
670
+ * and `useRecordMutes`.
671
+ * Optional: a host without it leaves the hook reading not muted.
672
+ */
673
+ mutes?: {
674
+ get(ref: { table_id: string; record_id: string }): Promise<RecordMuteWire>;
675
+ set(body: {
676
+ table_id: string;
677
+ record_id: string;
678
+ muted_until: string | null;
679
+ }): Promise<RecordMuteWire>;
680
+ clear(ref: { table_id: string; record_id: string }): Promise<void>;
681
+ /** Backs `useRecordMutes`: one page of the user's active mutes. */
682
+ list(query: {
683
+ table_id: string;
684
+ limit?: number;
685
+ offset?: number;
686
+ }): Promise<{
687
+ data: Array<{ record_id: string; muted_until: string | null }>;
688
+ meta: { total: number; limit: number; offset: number };
689
+ }>;
690
+ };
691
+ }
692
+
693
+ /** The mute wire shape, snake_case verbatim. */
694
+ export interface RecordMuteWire {
695
+ muted: boolean;
696
+ muted_until: string | null;
642
697
  }
643
698
 
644
699
  export interface WidgetContext<TProps = unknown> {
@@ -988,6 +1043,11 @@ export interface DirectoryQuery {
988
1043
  * `useGroups()` row. Pair with a group picker to build "members of group X".
989
1044
  */
990
1045
  group_id?: string;
1046
+ /**
1047
+ * Only these users (1 to 100 ids): resolve the names behind ids you already
1048
+ * hold — message senders, members — without loading the whole roster.
1049
+ */
1050
+ ids?: string[];
991
1051
  limit?: number;
992
1052
  offset?: number;
993
1053
  }
@@ -1145,6 +1205,45 @@ export interface SendNotificationApi {
1145
1205
  */
1146
1206
  export function useSendNotification(): SendNotificationApi;
1147
1207
 
1208
+ export interface RecordMuteApi {
1209
+ muted: boolean;
1210
+ /** ISO time the mute ends; null when muted until unmuted, or not muted. */
1211
+ mutedUntil: string | null;
1212
+ loading: boolean;
1213
+ error: NotificationError | null;
1214
+ /** An ISO time or a Date; null or omitted mutes until `unmute()`. */
1215
+ mute(until?: string | Date | null): Promise<void>;
1216
+ unmute(): Promise<void>;
1217
+ refetch(): Promise<void>;
1218
+ }
1219
+
1220
+ /**
1221
+ * sc-10268 — the signed-in user's mute of one datastore record's
1222
+ * notifications. While muted, `notifications.notifyRecordSubjects` on the
1223
+ * record sends them nothing. A record they cannot read fails with
1224
+ * `RECORD_NOT_FOUND`; a falsy id returns a stable not-muted result.
1225
+ */
1226
+ export function useRecordMute(
1227
+ tableId: string | null | undefined,
1228
+ recordId: string | null | undefined,
1229
+ ): RecordMuteApi;
1230
+
1231
+ export interface RecordMutesApi {
1232
+ /** True while the record's mute is active; false once a timed mute lapses. */
1233
+ isMuted(recordId: string | null | undefined): boolean;
1234
+ loading: boolean;
1235
+ error: NotificationError | null;
1236
+ refetch(): Promise<void>;
1237
+ }
1238
+
1239
+ /**
1240
+ * sc-10268 — which records of one table the signed-in user has muted, in one
1241
+ * read for a whole list. A `useRecordMute` write on the table shows at once.
1242
+ */
1243
+ export function useRecordMutes(
1244
+ tableId: string | null | undefined,
1245
+ ): RecordMutesApi;
1246
+
1148
1247
  export interface WorkspaceCurrency {
1149
1248
  /** The ISO code this workspace charges its app users in. */
1150
1249
  currency: string;
@@ -1704,6 +1803,35 @@ export function useNavigation(): {
1704
1803
  */
1705
1804
  export function useRouteParams(): Record<string, unknown>;
1706
1805
 
1806
+ /**
1807
+ * sc-10268 — puts `count` on the menu entry or tab of the page this widget is
1808
+ * on. The host sums the page's widgets and keeps the last count after the
1809
+ * widget leaves the screen; 0 clears this widget's share.
1810
+ */
1811
+ /**
1812
+ * sc-10268 — `{ uri }` an `<Image>` can show for a picked file before it is
1813
+ * uploaded. Null for no file, or a host that cannot preview one.
1814
+ */
1815
+ export function useFilePreview(file: unknown): { uri: string | null };
1816
+
1817
+ export function usePageBadge(count: number | null | undefined): {
1818
+ supported: boolean;
1819
+ };
1820
+
1821
+ /**
1822
+ * sc-10268 — Android's back button for a widget with pages of its own.
1823
+ * `handler` runs on a back press while the widget's screen is in view; return
1824
+ * true when it went back a step, false to let the app go back. Pass null while
1825
+ * there is nothing to go back from. `supported` is false on the web, which has
1826
+ * no hardware back, so keep a visible back control too.
1827
+ *
1828
+ * @example
1829
+ * useHardwareBack(openThread ? () => { closeThread(); return true; } : null);
1830
+ */
1831
+ export function useHardwareBack(handler: (() => boolean) | null | undefined): {
1832
+ supported: boolean;
1833
+ };
1834
+
1707
1835
  /**
1708
1836
  * REQ-NAV-05 — the PAGE's resolved context. When the page declares typed
1709
1837
  * parameters, the host resolves them ONCE before any widget renders: `params`
@@ -2068,7 +2196,7 @@ export interface CameraOptions {
2068
2196
  quality?: number;
2069
2197
  }
2070
2198
 
2071
- /** A photo taken or picked through `useCamera()`, normalised across hosts. */
2199
+ /** A photo (or, from `pick({ videos: true })`, a video) taken or picked through `useCamera()`, normalised across hosts. */
2072
2200
  export interface CameraAsset {
2073
2201
  /** Displayable source — `<Image source={{ uri }} />` / `<img src>`. */
2074
2202
  uri: string;
@@ -2079,6 +2207,8 @@ export interface CameraAsset {
2079
2207
  height: number | null;
2080
2208
  /** Bytes, when the host reports it. */
2081
2209
  size: number | null;
2210
+ /** A video's length in milliseconds when the host reports it; null for a photo. */
2211
+ durationMs: number | null;
2082
2212
  /**
2083
2213
  * Ready-to-upload part — a Blob on both hosts: a `File` on web,
2084
2214
  * expo-file-system's `File` on native. Expo's fetch rejects any other shape.
@@ -2096,8 +2226,13 @@ export interface CameraResult {
2096
2226
  supported: boolean;
2097
2227
  /** Open the camera. Resolves null if the user dismisses it. */
2098
2228
  capture(): Promise<CameraAsset | null>;
2099
- /** Open the photo library. Resolves null if the user dismisses it. */
2100
- pick(): Promise<CameraAsset | null>;
2229
+ /**
2230
+ * Open the photo library. Resolves null if the user dismisses it.
2231
+ * `{ multiple: true }` resolves an array of up to 10; `{ videos: true }`
2232
+ * offers videos beside photos (play one with `<Video>`).
2233
+ */
2234
+ pick(options: { multiple: true; videos?: boolean }): Promise<CameraAsset[] | null>;
2235
+ pick(options?: { multiple?: false; videos?: boolean }): Promise<CameraAsset | null>;
2101
2236
  /** Clear `asset` and `error`, releasing the held asset. */
2102
2237
  reset(): void;
2103
2238
  }
@@ -2412,6 +2547,10 @@ export class NotificationError extends Error {
2412
2547
  | "FORBIDDEN"
2413
2548
  | "RECIPIENT_NOT_FOUND"
2414
2549
  | "RATE_LIMITED"
2550
+ | "RECORD_NOT_FOUND"
2551
+ | "INVALID_MUTED_UNTIL"
2552
+ | "INVALID_RECORD_REF"
2553
+ | "NOTIFICATIONS_UNAVAILABLE"
2415
2554
  | "INTERNAL"
2416
2555
  | string;
2417
2556
  constructor(
@@ -2904,6 +3043,35 @@ export const Gradient: (props: {
2904
3043
  children?: ReactNode;
2905
3044
  }) => any;
2906
3045
 
3046
+ /**
3047
+ * sc-10268 — plays a video file inline with the platform's own controls, the
3048
+ * first frame showing before playback. Web uses the browser's `<video>`;
3049
+ * native uses expo-video's VideoView. It fills the box `style` gives it.
3050
+ *
3051
+ * @example
3052
+ * <Video
3053
+ * source={file.url}
3054
+ * contentFit="cover"
3055
+ * style={{ width: 240, height: 180, borderRadius: theme.radii.md, overflow: "hidden" }}
3056
+ * onError={() => setPlayable(false)}
3057
+ * />
3058
+ */
3059
+ export const Video: (props: {
3060
+ /** A playable URL; null renders an empty box. */
3061
+ source: string | null;
3062
+ /** Play, seek and fullscreen controls. Defaults to true. */
3063
+ controls?: boolean;
3064
+ /** Starts playing muted, as browsers allow only muted autoplay. */
3065
+ autoPlay?: boolean;
3066
+ /** Letterboxed ("contain", the default) or cropped to fill ("cover"). */
3067
+ contentFit?: "contain" | "cover";
3068
+ style?: any;
3069
+ accessibilityLabel?: string;
3070
+ testID?: string;
3071
+ /** The file could not be played on this host. */
3072
+ onError?: () => void;
3073
+ }) => any;
3074
+
2907
3075
  /**
2908
3076
  * sc-6607 — screen-level overlay. Renders OUTSIDE the widget's layout box on
2909
3077
  * both hosts (web portals it to the document root, native uses the OS modal),
@@ -3054,9 +3222,14 @@ export const Badge: (props: {
3054
3222
  }) => any;
3055
3223
 
3056
3224
  /** Themed picture of a person, falling back to initials, then a glyph. */
3225
+ /** The stable colour `<Avatar seed>` paints for a seed, or null for none. */
3226
+ export function avatarColorFor(seed: string | null | undefined): string | null;
3227
+
3057
3228
  export const Avatar: (props: {
3058
3229
  /** Drives the initials and the accessibility label. */
3059
3230
  name?: string;
3231
+ /** A stable id (a user id): tints the monogram with that person's colour. */
3232
+ seed?: string;
3060
3233
  /** An absolute image URL. */
3061
3234
  url?: string;
3062
3235
  /** A size token, or a number of px (clamped to 16-160). */
package/dist/index.js CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  useFilestoreFolders,
47
47
  useFilestoreCreateFolder,
48
48
  useFilestoreCopy,
49
+ useFilestoreDelete,
49
50
  useFileSignature,
50
51
  useFileSignatures,
51
52
  useFileRoster,
@@ -66,6 +67,8 @@ export {
66
67
  usePayments,
67
68
  useWorkspaceCurrency,
68
69
  useSendNotification,
70
+ useRecordMute,
71
+ useRecordMutes,
69
72
  useTheme,
70
73
  useWidgetStyle,
71
74
  useI18n,
@@ -78,6 +81,9 @@ export {
78
81
  useFill,
79
82
  useNavigation,
80
83
  useRouteParams,
84
+ usePageBadge,
85
+ useHardwareBack,
86
+ useFilePreview,
81
87
  usePageContext,
82
88
  useWidgetRoute,
83
89
  useChildRenderer,
@@ -160,6 +166,7 @@ export {
160
166
  DateTimePicker,
161
167
  FilePicker,
162
168
  Gradient,
169
+ Video,
163
170
  } from "./primitives.js";
164
171
  export { lintSource, bannedIdentifiers } from "./linter.js";
165
172
  // sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
@@ -215,6 +222,7 @@ export { List, ListRow, ListHeader, EmptyState } from "./list.js";
215
222
  // Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
216
223
  export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.js";
217
224
  export { initialsFor } from "./initials.js";
225
+ export { avatarColorFor } from "./avatar-color.js";
218
226
  // The one card surface: the theme's `card` scope, a gradient fill, margin.
219
227
  export { Card } from "./card.js";
220
228
  export {
@@ -46,6 +46,7 @@ export {
46
46
  useFilestoreFolders,
47
47
  useFilestoreCreateFolder,
48
48
  useFilestoreCopy,
49
+ useFilestoreDelete,
49
50
  useFileSignature,
50
51
  useFileSignatures,
51
52
  useFileRoster,
@@ -66,6 +67,8 @@ export {
66
67
  usePayments,
67
68
  useWorkspaceCurrency,
68
69
  useSendNotification,
70
+ useRecordMute,
71
+ useRecordMutes,
69
72
  useTheme,
70
73
  useWidgetStyle,
71
74
  useI18n,
@@ -78,6 +81,9 @@ export {
78
81
  useFill,
79
82
  useNavigation,
80
83
  useRouteParams,
84
+ usePageBadge,
85
+ useHardwareBack,
86
+ useFilePreview,
81
87
  usePageContext,
82
88
  useWidgetRoute,
83
89
  useChildRenderer,
@@ -153,6 +159,7 @@ export {
153
159
  DateTimePicker,
154
160
  FilePicker,
155
161
  Gradient,
162
+ Video,
156
163
  } from "./primitives.native.js";
157
164
  export { lintSource, bannedIdentifiers } from "./linter.js";
158
165
  // sc-3696: the trailing six are the theme-derivation helpers both hosts resolve
@@ -208,6 +215,7 @@ export { List, ListRow, ListHeader, EmptyState } from "./list.native.js";
208
215
  // Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
209
216
  export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.native.js";
210
217
  export { initialsFor } from "./initials.js";
218
+ export { avatarColorFor } from "./avatar-color.js";
211
219
  // The one card surface: the theme's `card` scope, a gradient fill, margin.
212
220
  export { Card } from "./card.native.js";
213
221
  export {
package/dist/initials.js CHANGED
@@ -4,14 +4,15 @@
4
4
  // Returns "" when the name yields no letters, so each caller keeps its own
5
5
  // fallback (a "?", a person glyph).
6
6
 
7
- /** "Ada Lovelace" → "AL", "Ada" → "A", " " → "". */
7
+ /** "Ada Lovelace" → "AL", "Börje" → "BÖ", " " → "". */
8
8
  export function initialsFor(name) {
9
9
  const parts =
10
10
  typeof name === "string" ? name.trim().split(/\s+/).filter(Boolean) : [];
11
11
  if (parts.length === 0) return "";
12
12
  // Array.from, not slice: a multi-byte first character must not be halved.
13
13
  const first = Array.from(parts[0])[0];
14
- if (parts.length === 1) return first.toUpperCase();
14
+ // sc-10268: one word still gets two letters, as the Studio's member lists show.
15
+ if (parts.length === 1) return Array.from(parts[0]).slice(0, 2).join("").toUpperCase();
15
16
  const last = Array.from(parts[parts.length - 1])[0];
16
17
  return (first + last).toUpperCase();
17
18
  }
package/dist/manifest.cjs CHANGED
@@ -46,7 +46,8 @@ const VALID_PLATFORMS = new Set(["web", "native"]);
46
46
  // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
47
47
  // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
48
48
  // would reject.
49
- const TRANSLATIONS_MAX_KEYS = 100;
49
+ // One page of the dictionary's widget namespace (l10n MAX_KEYS_PER_PAGE).
50
+ const TRANSLATIONS_MAX_KEYS = 200;
50
51
  const TRANSLATION_VALUE_MAX_BYTES = 1024;
51
52
  const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
52
53
  const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
package/dist/manifest.js CHANGED
@@ -60,7 +60,8 @@ const ACTION_TIMEOUT_MAX_MS = 5 * 60 * 1000;
60
60
  // backend/src/core/services/l10n.service.js). The locale pattern mirrors that
61
61
  // service's LANG_CODE_RE so a manifest can't declare a locale the dictionary
62
62
  // would reject.
63
- const TRANSLATIONS_MAX_KEYS = 100;
63
+ // One page of the dictionary's widget namespace (l10n MAX_KEYS_PER_PAGE).
64
+ const TRANSLATIONS_MAX_KEYS = 200;
64
65
  const TRANSLATION_VALUE_MAX_BYTES = 1024;
65
66
  const TRANSLATION_KEY_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
66
67
  const TRANSLATION_LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
@@ -636,21 +636,25 @@ function resolveTopBarTokens(theme) {
636
636
  * inside it, so the menu is never lost — a section off the menu is exactly
637
637
  * the case that would otherwise render blank.
638
638
  *
639
+ * sc-10693: `trees` answers EVERY section, not only the visitor's own, so a
640
+ * section's entry can offer its menu from any page. `under` / `items` /
641
+ * `standalone` still name the one the visitor is inside.
642
+ *
639
643
  * @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
640
644
  * `entries` — the global menu's keys, in menu order.
641
645
  * `active` — the key of the page being viewed.
642
646
  * `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
643
647
  * `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
644
- * @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
645
- * `under` is null when nothing unfolds; when `standalone` it names the
646
- * section's own page, which the host draws as the entry. Each item is
647
- * returned with a `current` flag rather than leaving each host to
648
- * recompute it.
648
+ * @returns {{ under: string|null, items: Array<object>, standalone: boolean, trees: Array<object> }}
649
+ * `under` is null when the visitor is in no section; when `standalone` it
650
+ * names the section's own page, which the host draws as the entry. Each
651
+ * item is returned with a `current` flag rather than leaving each host to
652
+ * recompute it. `trees` lists `{ under, items, standalone, inside }` in menu
653
+ * order, a `standalone` one last; `inside` marks the visitor's own.
649
654
  */
650
655
  function resolveSidebarUnfold(input) {
651
656
  const { entries, active, sections } = isPlainObject(input) ? input : {};
652
- const nothing = { under: null, items: [], standalone: false };
653
- if (typeof active !== "string" || !active) return nothing;
657
+ const viewed = typeof active === "string" && active ? active : null;
654
658
  const bank = isPlainObject(sections) ? sections : {};
655
659
  const order = Array.isArray(entries) ? entries : [];
656
660
  const sectionAt = (key) => {
@@ -663,31 +667,60 @@ function resolveSidebarUnfold(input) {
663
667
  return (
664
668
  Boolean(candidate) &&
665
669
  candidate.items.some(
666
- (item) => isPlainObject(item) && item.key === active,
670
+ (item) => isPlainObject(item) && item.key === viewed,
667
671
  )
668
672
  );
669
673
  };
670
674
  // The page's own section wins; otherwise the section whose menu lists it,
671
675
  // preferring one a global entry carries so the tree hangs where it always
672
676
  // does. Both are the same section from the visitor's side.
673
- const section =
674
- sectionAt(active) ||
675
- sectionAt(order.find(lists)) ||
676
- sectionAt(Object.keys(bank).find(lists));
677
- if (!section) return nothing;
678
- const marked = section.items.map((item) => ({
679
- ...(isPlainObject(item) ? item : {}),
680
- current:
681
- isPlainObject(item) && typeof item.key === "string" && item.key === active,
682
- }));
683
- const owner = order.find((key) => {
684
- const candidate = isPlainObject(bank[key]) ? bank[key] : null;
685
- return Boolean(candidate) && candidate.menu === section.menu;
686
- });
687
- if (typeof owner === "string" && owner) {
688
- return { under: owner, items: marked, standalone: false };
677
+ const section = viewed
678
+ ? sectionAt(viewed) ||
679
+ sectionAt(order.find(lists)) ||
680
+ sectionAt(Object.keys(bank).find(lists))
681
+ : null;
682
+ const mark = (items) =>
683
+ items.map((item) => ({
684
+ ...(isPlainObject(item) ? item : {}),
685
+ current:
686
+ viewed !== null &&
687
+ isPlainObject(item) &&
688
+ typeof item.key === "string" &&
689
+ item.key === viewed,
690
+ }));
691
+ // A saved menu hangs under the FIRST global entry carrying it, so two
692
+ // entries sharing one menu never draw it twice.
693
+ const claimed = [];
694
+ const trees = [];
695
+ for (const key of order) {
696
+ if (!isPlainObject(bank[key])) continue;
697
+ const { menu } = bank[key];
698
+ if (claimed.includes(menu)) continue;
699
+ claimed.push(menu);
700
+ // An unusable key still owns its menu: nothing may hang it elsewhere.
701
+ if (typeof key !== "string" || !key) continue;
702
+ const inside = Boolean(section) && menu === section.menu;
703
+ const own = inside ? section : sectionAt(key);
704
+ if (!own) continue;
705
+ trees.push({ under: key, items: mark(own.items), standalone: false, inside });
706
+ }
707
+ if (!section) return { under: null, items: [], standalone: false, trees };
708
+ const owned = trees.find((tree) => tree.inside);
709
+ if (owned) {
710
+ return { under: owned.under, items: owned.items, standalone: false, trees };
689
711
  }
690
- return { under: section.key, items: marked, standalone: true };
712
+ const alone = {
713
+ under: section.key,
714
+ items: mark(section.items),
715
+ standalone: true,
716
+ inside: true,
717
+ };
718
+ return {
719
+ under: alone.under,
720
+ items: alone.items,
721
+ standalone: true,
722
+ trees: [...trees, alone],
723
+ };
691
724
  }
692
725
 
693
726
  /**
@@ -627,21 +627,25 @@ export function resolveTopBarTokens(theme) {
627
627
  * inside it, so the menu is never lost — a section off the menu is exactly
628
628
  * the case that would otherwise render blank.
629
629
  *
630
+ * sc-10693: `trees` answers EVERY section, not only the visitor's own, so a
631
+ * section's entry can offer its menu from any page. `under` / `items` /
632
+ * `standalone` still name the one the visitor is inside.
633
+ *
630
634
  * @param {{ entries?: unknown, active?: unknown, sections?: unknown }} input
631
635
  * `entries` — the global menu's keys, in menu order.
632
636
  * `active` — the key of the page being viewed.
633
637
  * `sections` — `{ [key]: { menu, items } }` for pages whose local nav is
634
638
  * `SIDEBAR`; `menu` identifies the saved menu, `items` carry their own key.
635
- * @returns {{ under: string|null, items: Array<object>, standalone: boolean }}
636
- * `under` is null when nothing unfolds; when `standalone` it names the
637
- * section's own page, which the host draws as the entry. Each item is
638
- * returned with a `current` flag rather than leaving each host to
639
- * recompute it.
639
+ * @returns {{ under: string|null, items: Array<object>, standalone: boolean, trees: Array<object> }}
640
+ * `under` is null when the visitor is in no section; when `standalone` it
641
+ * names the section's own page, which the host draws as the entry. Each
642
+ * item is returned with a `current` flag rather than leaving each host to
643
+ * recompute it. `trees` lists `{ under, items, standalone, inside }` in menu
644
+ * order, a `standalone` one last; `inside` marks the visitor's own.
640
645
  */
641
646
  export function resolveSidebarUnfold(input) {
642
647
  const { entries, active, sections } = isPlainObject(input) ? input : {};
643
- const nothing = { under: null, items: [], standalone: false };
644
- if (typeof active !== "string" || !active) return nothing;
648
+ const viewed = typeof active === "string" && active ? active : null;
645
649
  const bank = isPlainObject(sections) ? sections : {};
646
650
  const order = Array.isArray(entries) ? entries : [];
647
651
  const sectionAt = (key) => {
@@ -654,31 +658,60 @@ export function resolveSidebarUnfold(input) {
654
658
  return (
655
659
  Boolean(candidate) &&
656
660
  candidate.items.some(
657
- (item) => isPlainObject(item) && item.key === active,
661
+ (item) => isPlainObject(item) && item.key === viewed,
658
662
  )
659
663
  );
660
664
  };
661
665
  // The page's own section wins; otherwise the section whose menu lists it,
662
666
  // preferring one a global entry carries so the tree hangs where it always
663
667
  // does. Both are the same section from the visitor's side.
664
- const section =
665
- sectionAt(active) ||
666
- sectionAt(order.find(lists)) ||
667
- sectionAt(Object.keys(bank).find(lists));
668
- if (!section) return nothing;
669
- const marked = section.items.map((item) => ({
670
- ...(isPlainObject(item) ? item : {}),
671
- current:
672
- isPlainObject(item) && typeof item.key === "string" && item.key === active,
673
- }));
674
- const owner = order.find((key) => {
675
- const candidate = isPlainObject(bank[key]) ? bank[key] : null;
676
- return Boolean(candidate) && candidate.menu === section.menu;
677
- });
678
- if (typeof owner === "string" && owner) {
679
- return { under: owner, items: marked, standalone: false };
668
+ const section = viewed
669
+ ? sectionAt(viewed) ||
670
+ sectionAt(order.find(lists)) ||
671
+ sectionAt(Object.keys(bank).find(lists))
672
+ : null;
673
+ const mark = (items) =>
674
+ items.map((item) => ({
675
+ ...(isPlainObject(item) ? item : {}),
676
+ current:
677
+ viewed !== null &&
678
+ isPlainObject(item) &&
679
+ typeof item.key === "string" &&
680
+ item.key === viewed,
681
+ }));
682
+ // A saved menu hangs under the FIRST global entry carrying it, so two
683
+ // entries sharing one menu never draw it twice.
684
+ const claimed = [];
685
+ const trees = [];
686
+ for (const key of order) {
687
+ if (!isPlainObject(bank[key])) continue;
688
+ const { menu } = bank[key];
689
+ if (claimed.includes(menu)) continue;
690
+ claimed.push(menu);
691
+ // An unusable key still owns its menu: nothing may hang it elsewhere.
692
+ if (typeof key !== "string" || !key) continue;
693
+ const inside = Boolean(section) && menu === section.menu;
694
+ const own = inside ? section : sectionAt(key);
695
+ if (!own) continue;
696
+ trees.push({ under: key, items: mark(own.items), standalone: false, inside });
697
+ }
698
+ if (!section) return { under: null, items: [], standalone: false, trees };
699
+ const owned = trees.find((tree) => tree.inside);
700
+ if (owned) {
701
+ return { under: owned.under, items: owned.items, standalone: false, trees };
680
702
  }
681
- return { under: section.key, items: marked, standalone: true };
703
+ const alone = {
704
+ under: section.key,
705
+ items: mark(section.items),
706
+ standalone: true,
707
+ inside: true,
708
+ };
709
+ return {
710
+ under: alone.under,
711
+ items: alone.items,
712
+ standalone: true,
713
+ trees: [...trees, alone],
714
+ };
682
715
  }
683
716
 
684
717
  /**