@casualoffice/sheets 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casualoffice/sheets",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Casual Sheets SDK — signing pipeline, iframe postMessage protocol, and CasualSheets React wrapper around Univer.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Unit tests for the MenuBar feature-gating contract — specifically the
19
+ * embed-mode gates that let a host hide the editor's branding surfaces:
20
+ *
21
+ * features={{ help: false }} → the whole Help menu is dropped.
22
+ * features={{ branding: false }} → the "View on GitHub" + "About" links are
23
+ * dropped (from both Help and File), so an
24
+ * embedded sheet shows no editor branding,
25
+ * while Keyboard shortcuts stays in Help.
26
+ *
27
+ * Exercises the pure `computeVisibleMenus` resolver (no DOM needed). The
28
+ * `MENUS` array itself lives in `MenuBar.tsx`, which statically imports
29
+ * `@univerjs/core` values the vendored ESM can't expose as named exports to
30
+ * node's loader — so it can't be imported here. Instead this pins the resolver
31
+ * against a fixture that mirrors the real Help/File branding subtree (kept in
32
+ * sync with MenuBar.tsx: Help menu `feature: 'help'`; About + View-on-GitHub +
33
+ * their separator `feature: 'branding'`; File > About `feature: 'branding'`).
34
+ *
35
+ * Run with: `pnpm test:unit`
36
+ */
37
+ import { strict as assert } from 'node:assert';
38
+ import { test } from 'node:test';
39
+
40
+ import { computeVisibleMenus, type MenuDef } from './menu-model';
41
+
42
+ // A faithful copy of the branding-relevant subtree of `MENUS` in MenuBar.tsx.
43
+ const MENUS_FIXTURE: MenuDef[] = [
44
+ {
45
+ id: 'file',
46
+ label: 'File',
47
+ feature: 'file',
48
+ items: [
49
+ { kind: 'item', id: 'properties', label: 'Properties…', dialog: 'properties' },
50
+ {
51
+ kind: 'item',
52
+ id: 'about',
53
+ label: 'About casual sheets',
54
+ dialog: 'about',
55
+ feature: 'branding',
56
+ },
57
+ ],
58
+ },
59
+ {
60
+ id: 'help',
61
+ label: 'Help',
62
+ feature: 'help',
63
+ items: [
64
+ {
65
+ kind: 'item',
66
+ id: 'keyboard-shortcuts',
67
+ label: 'Keyboard shortcuts',
68
+ dialog: 'keyboard-shortcuts',
69
+ },
70
+ { kind: 'separator', id: 'sep-help', feature: 'branding' },
71
+ {
72
+ kind: 'item',
73
+ id: 'about',
74
+ label: 'About casual sheets',
75
+ dialog: 'about',
76
+ feature: 'branding',
77
+ },
78
+ {
79
+ kind: 'item',
80
+ id: 'github',
81
+ label: 'View on GitHub',
82
+ run: () => {},
83
+ feature: 'branding',
84
+ },
85
+ ],
86
+ },
87
+ ];
88
+
89
+ // All SDK-built-in dialogs openable — matches standalone chrome (the SDK ships
90
+ // the About / Keyboard-shortcuts modals), so branding items are not
91
+ // latent-dropped and the feature gate is the only thing that can hide them.
92
+ const canOpenAll = () => true;
93
+
94
+ function findMenu(menus: MenuDef[], id: string) {
95
+ return menus.find((m) => m.id === id);
96
+ }
97
+
98
+ function itemIds(items: { id: string }[]): string[] {
99
+ return items.map((i) => i.id);
100
+ }
101
+
102
+ test('default (features unset): Help menu, About and View-on-GitHub all render', () => {
103
+ const menus = computeVisibleMenus(MENUS_FIXTURE, {}, canOpenAll);
104
+ const help = findMenu(menus, 'help');
105
+ assert.ok(help, 'Help menu should render by default');
106
+ const ids = itemIds(help.items);
107
+ assert.ok(ids.includes('github'), 'View on GitHub should render by default');
108
+ assert.ok(ids.includes('about'), 'Help > About should render by default');
109
+ assert.ok(ids.includes('keyboard-shortcuts'), 'Keyboard shortcuts should render');
110
+
111
+ const file = findMenu(menus, 'file');
112
+ assert.ok(file, 'File menu should render by default');
113
+ assert.ok(itemIds(file.items).includes('about'), 'File > About should render by default');
114
+ });
115
+
116
+ test('features={{ help: false }}: the whole Help menu is dropped', () => {
117
+ const menus = computeVisibleMenus(MENUS_FIXTURE, { help: false }, canOpenAll);
118
+ assert.equal(findMenu(menus, 'help'), undefined, 'Help menu must not render when help:false');
119
+ // File menu is unaffected by the help gate.
120
+ assert.ok(findMenu(menus, 'file'), 'File menu still renders when only help is disabled');
121
+ });
122
+
123
+ test('features={{ branding: false }}: GitHub + About vanish, Help stays for shortcuts', () => {
124
+ const menus = computeVisibleMenus(MENUS_FIXTURE, { branding: false }, canOpenAll);
125
+
126
+ const help = findMenu(menus, 'help');
127
+ assert.ok(help, 'Help menu still renders (keyboard shortcuts is not branding)');
128
+ const helpIds = itemIds(help.items);
129
+ assert.ok(!helpIds.includes('github'), 'View on GitHub must be hidden when branding:false');
130
+ assert.ok(!helpIds.includes('about'), 'Help > About must be hidden when branding:false');
131
+ assert.ok(helpIds.includes('keyboard-shortcuts'), 'Keyboard shortcuts stays');
132
+ // Trailing separator before the (now removed) branding items must be collapsed.
133
+ assert.ok(!helpIds.includes('sep-help'), 'dangling separator collapses once branding items go');
134
+
135
+ const file = findMenu(menus, 'file');
136
+ assert.ok(file, 'File menu still renders');
137
+ assert.ok(
138
+ !itemIds(file.items).includes('about'),
139
+ 'File > About must be hidden when branding:false',
140
+ );
141
+ });
142
+
143
+ test('features={{ help: false, branding: false }}: no editor-branded surface at all', () => {
144
+ const menus = computeVisibleMenus(MENUS_FIXTURE, { help: false, branding: false }, canOpenAll);
145
+ assert.equal(findMenu(menus, 'help'), undefined, 'Help menu gone');
146
+ const file = findMenu(menus, 'file');
147
+ assert.ok(file && !itemIds(file.items).includes('about'), 'File > About gone');
148
+ // No visible menu anywhere links to GitHub.
149
+ for (const m of menus) {
150
+ assert.ok(!itemIds(m.items).includes('github'), `menu ${m.id} has no GitHub link`);
151
+ }
152
+ });
@@ -54,52 +54,24 @@ import type { CasualSheetsAPI } from '../sheets/api';
54
54
  import { ensurePluginByName } from '../univer';
55
55
  import { Icon } from './Icon';
56
56
  import { ensureChromeFonts } from './fonts';
57
- import { useDialogs, type DialogKind } from './dialog-context';
58
- import type { ChromeExtensions, MenuExtension } from './extensions';
59
-
60
- type MenuId = 'file' | 'edit' | 'view' | 'insert' | 'format' | 'data' | 'help';
57
+ import { useDialogs } from './dialog-context';
58
+ import type { ChromeExtensions } from './extensions';
59
+ import {
60
+ computeVisibleMenus,
61
+ type MenuDef,
62
+ type MenuId,
63
+ type MenuItemDef,
64
+ type RunFn,
65
+ } from './menu-model';
66
+
67
+ export type { MenuDialogKind } from './menu-model';
61
68
 
62
69
  /**
63
- * Dialog kinds the host can choose to render via `onDialogRequest`. These are
64
- * the actions the SDK chrome can't fulfil on its own (no built-in modal). The
65
- * string is passed straight to the host hook; the `context` (when present)
66
- * carries the pre-resolved A1 selection so the host doesn't have to re-read it.
70
+ * The menu data model + pure gating engine live in `./menu-model` (univer-free,
71
+ * so unit-testable). `RunFn` / `MenuItemDef` / `MenuDef` / `MenuId` /
72
+ * `MenuDialogKind` are re-imported above; the `run` handlers that populate
73
+ * `MENUS` are defined inline below against the `@univerjs/core` facade.
67
74
  */
68
- export type MenuDialogKind = DialogKind;
69
-
70
- type RunFn = (api: CasualSheetsAPI) => void;
71
-
72
- type MenuItemDef =
73
- | {
74
- kind: 'item';
75
- id: string;
76
- label: string;
77
- icon?: string;
78
- shortcut?: string;
79
- /** Dispatch a command / facade call directly. */
80
- run?: RunFn;
81
- /** Route through the host's `onDialogRequest`. Omitted if no host hook. */
82
- dialog?: MenuDialogKind;
83
- /** Feature gate — item hidden when `features[feature] === false`. */
84
- feature?: string;
85
- }
86
- | { kind: 'separator'; id: string; feature?: string }
87
- | {
88
- kind: 'submenu';
89
- id: string;
90
- label: string;
91
- icon?: string;
92
- items: MenuItemDef[];
93
- feature?: string;
94
- };
95
-
96
- interface MenuDef {
97
- id: MenuId;
98
- label: string;
99
- /** Feature gate for the whole menu. */
100
- feature?: string;
101
- items: MenuItemDef[];
102
- }
103
75
 
104
76
  /**
105
77
  * Pretty-print a `Ctrl+Shift+X` shortcut for the current platform. The SDK
@@ -559,6 +531,7 @@ const MENUS: MenuDef[] = [
559
531
  label: 'About casual sheets',
560
532
  icon: 'help_outline',
561
533
  dialog: 'about',
534
+ feature: 'branding',
562
535
  },
563
536
  ],
564
537
  },
@@ -1106,6 +1079,12 @@ const MENUS: MenuDef[] = [
1106
1079
  {
1107
1080
  id: 'help',
1108
1081
  label: 'Help',
1082
+ // Whole-menu gate: an embedded host passes `features={{ help: false }}` to
1083
+ // drop the Help menu entirely (no editor-branded surface). Individual
1084
+ // branding links below also carry `feature: 'branding'` so a host that
1085
+ // keeps Help (for keyboard shortcuts) can still hide the About / GitHub
1086
+ // links with `features={{ branding: false }}`.
1087
+ feature: 'help',
1109
1088
  items: [
1110
1089
  {
1111
1090
  kind: 'item',
@@ -1115,14 +1094,22 @@ const MENUS: MenuDef[] = [
1115
1094
  shortcut: 'Ctrl+/',
1116
1095
  dialog: 'keyboard-shortcuts',
1117
1096
  },
1118
- { kind: 'separator', id: 'sep-help' },
1119
- { kind: 'item', id: 'about', label: 'About casual sheets', icon: 'info', dialog: 'about' },
1097
+ { kind: 'separator', id: 'sep-help', feature: 'branding' },
1098
+ {
1099
+ kind: 'item',
1100
+ id: 'about',
1101
+ label: 'About casual sheets',
1102
+ icon: 'info',
1103
+ dialog: 'about',
1104
+ feature: 'branding',
1105
+ },
1120
1106
  {
1121
1107
  kind: 'item',
1122
1108
  id: 'github',
1123
1109
  label: 'View on GitHub',
1124
1110
  icon: 'open_in_new',
1125
1111
  run: () => openExternal('https://github.com/CasualOffice/sheets'),
1112
+ feature: 'branding',
1126
1113
  },
1127
1114
  ],
1128
1115
  },
@@ -1219,93 +1206,6 @@ const SUBMENU_PANEL_STYLE: CSSProperties = {
1219
1206
  zIndex: 1001,
1220
1207
  };
1221
1208
 
1222
- /* ───────────────────────────── filtering ──────────────────────────────── */
1223
-
1224
- /** True when the feature gate (if any) is enabled (default: enabled). */
1225
- function featureOn(feature: string | undefined, features: Record<string, boolean>): boolean {
1226
- if (!feature) return true;
1227
- return features[feature] !== false;
1228
- }
1229
-
1230
- /**
1231
- * Keep an item if its feature is on AND — for a dialog item — the chrome can
1232
- * open it (built-in dialog, host override, or `onDialogRequest`). Dialog items
1233
- * with no way to open are dropped (the SDK never fakes a dialog). Submenus are
1234
- * filtered recursively and dropped when empty.
1235
- */
1236
- function keepItem(
1237
- item: MenuItemDef,
1238
- features: Record<string, boolean>,
1239
- canOpen: (kind: DialogKind) => boolean,
1240
- ): MenuItemDef | null {
1241
- if (!featureOn(item.feature, features)) return null;
1242
- if (item.kind === 'separator') return item;
1243
- if (item.kind === 'submenu') {
1244
- const items = filterItems(item.items, features, canOpen);
1245
- if (items.length === 0) return null;
1246
- return { ...item, items };
1247
- }
1248
- if (item.dialog && !canOpen(item.dialog)) return null;
1249
- return item;
1250
- }
1251
-
1252
- /** Filter a list and collapse leading/trailing/double separators. */
1253
- function filterItems(
1254
- items: MenuItemDef[],
1255
- features: Record<string, boolean>,
1256
- canOpen: (kind: DialogKind) => boolean,
1257
- ): MenuItemDef[] {
1258
- const kept = items
1259
- .map((i) => keepItem(i, features, canOpen))
1260
- .filter((i): i is MenuItemDef => i !== null);
1261
- // Collapse separators: drop leading, trailing, and runs.
1262
- const out: MenuItemDef[] = [];
1263
- for (const item of kept) {
1264
- if (item.kind === 'separator') {
1265
- if (out.length === 0) continue;
1266
- if (out[out.length - 1].kind === 'separator') continue;
1267
- }
1268
- out.push(item);
1269
- }
1270
- while (out.length > 0 && out[out.length - 1].kind === 'separator') out.pop();
1271
- return out;
1272
- }
1273
-
1274
- /* ─────────────────────────── host extensions ──────────────────────────── */
1275
-
1276
- /**
1277
- * Append host menu extensions to their target top-level menu. Each extension
1278
- * becomes a normal `item` (with a leading separator before the first host item
1279
- * in that menu so it's visually grouped). Host items dispatch via `onClick` or
1280
- * route a `dialog` kind through the dialog host, exactly like built-ins.
1281
- */
1282
- function withMenuExtensions(menus: MenuDef[], ext?: MenuExtension[]): MenuDef[] {
1283
- if (!ext || ext.length === 0) return menus;
1284
- const byMenu = new Map<MenuId, MenuExtension[]>();
1285
- for (const e of ext) {
1286
- const list = byMenu.get(e.menu) ?? [];
1287
- list.push(e);
1288
- byMenu.set(e.menu, list);
1289
- }
1290
- return menus.map((menu) => {
1291
- const extras = byMenu.get(menu.id);
1292
- if (!extras || extras.length === 0) return menu;
1293
- const items: MenuItemDef[] = [...menu.items, { kind: 'separator', id: `ext-sep-${menu.id}` }];
1294
- for (const e of extras) {
1295
- items.push({
1296
- kind: 'item',
1297
- id: `ext-${e.id}`,
1298
- label: e.label,
1299
- icon: e.icon,
1300
- shortcut: e.shortcut,
1301
- dialog: e.dialog,
1302
- run: e.onClick ? (api) => e.onClick?.(api) : undefined,
1303
- });
1304
- }
1305
- return { ...menu, items };
1306
- });
1307
- }
1308
-
1309
1209
  /* ───────────────────────────── component ──────────────────────────────── */
1310
1210
 
1311
1211
  export interface MenuBarProps {
@@ -1381,15 +1281,10 @@ export function MenuBar({ api, features = {}, extensions }: MenuBarProps) {
1381
1281
  item.run?.(api);
1382
1282
  };
1383
1283
 
1384
- // Compute the visible menus once per render (feature + dialog-open gating),
1385
- // then append host menu extensions to their target menu.
1386
- const baseMenus = withMenuExtensions(MENUS, extensions?.menu);
1387
- const visibleMenus = baseMenus
1388
- .map((menu) => ({
1389
- ...menu,
1390
- items: filterItems(menu.items, features, dialogs.canOpen),
1391
- }))
1392
- .filter((menu) => featureOn(menu.feature, features) && menu.items.length > 0);
1284
+ // Compute the visible menus once per render (feature + dialog-open gating +
1285
+ // host menu extensions). Extracted to `computeVisibleMenus` so the gating
1286
+ // contract is unit-testable without a DOM.
1287
+ const visibleMenus = computeVisibleMenus(MENUS, features, dialogs.canOpen, extensions?.menu);
1393
1288
 
1394
1289
  const renderItems = (items: MenuItemDef[]): ReactNode =>
1395
1290
  items.map((item) => {
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * MenuBar data model + pure gating engine.
19
+ *
20
+ * Split out of `MenuBar.tsx` so the feature-gating contract is unit-testable
21
+ * under `node --test`: `MenuBar.tsx` statically imports `@univerjs/core` values
22
+ * (which the vendored typeless-package ESM can't expose as named exports to
23
+ * node), so it can't be imported in a DOM-less test. This module has only
24
+ * type-only imports (all erased at build), so `computeVisibleMenus` — the code
25
+ * that decides which menus/items a host actually sees — can be exercised
26
+ * directly.
27
+ *
28
+ * Feature gates: pass `features` to hide a control or whole menu group when its
29
+ * feature is disabled. Defaults to all-enabled. A control whose feature is
30
+ * `false` does not render. An entire top-level menu whose own `feature` is
31
+ * `false`, or that ends up with no runnable items, is dropped.
32
+ */
33
+
34
+ import type { DialogKind } from './dialog-context';
35
+ import type { CasualSheetsAPI } from '../sheets/api';
36
+ import type { MenuExtension } from './extensions';
37
+
38
+ export type MenuId = 'file' | 'edit' | 'view' | 'insert' | 'format' | 'data' | 'help';
39
+
40
+ /**
41
+ * Dialog kinds the host can choose to render via `onDialogRequest`. These are
42
+ * the actions the SDK chrome can't fulfil on its own (no built-in modal). The
43
+ * string is passed straight to the host hook; the `context` (when present)
44
+ * carries the pre-resolved A1 selection so the host doesn't have to re-read it.
45
+ */
46
+ export type MenuDialogKind = DialogKind;
47
+
48
+ export type RunFn = (api: CasualSheetsAPI) => void;
49
+
50
+ export type MenuItemDef =
51
+ | {
52
+ kind: 'item';
53
+ id: string;
54
+ label: string;
55
+ icon?: string;
56
+ shortcut?: string;
57
+ /** Dispatch a command / facade call directly. */
58
+ run?: RunFn;
59
+ /** Route through the host's `onDialogRequest`. Omitted if no host hook. */
60
+ dialog?: MenuDialogKind;
61
+ /** Feature gate — item hidden when `features[feature] === false`. */
62
+ feature?: string;
63
+ }
64
+ | { kind: 'separator'; id: string; feature?: string }
65
+ | {
66
+ kind: 'submenu';
67
+ id: string;
68
+ label: string;
69
+ icon?: string;
70
+ items: MenuItemDef[];
71
+ feature?: string;
72
+ };
73
+
74
+ export interface MenuDef {
75
+ id: MenuId;
76
+ label: string;
77
+ /** Feature gate for the whole menu. */
78
+ feature?: string;
79
+ items: MenuItemDef[];
80
+ }
81
+
82
+ /* ───────────────────────────── filtering ──────────────────────────────── */
83
+
84
+ /** True when the feature gate (if any) is enabled (default: enabled). */
85
+ export function featureOn(feature: string | undefined, features: Record<string, boolean>): boolean {
86
+ if (!feature) return true;
87
+ return features[feature] !== false;
88
+ }
89
+
90
+ /**
91
+ * Keep an item if its feature is on AND — for a dialog item — the chrome can
92
+ * open it (built-in dialog, host override, or `onDialogRequest`). Dialog items
93
+ * with no way to open are dropped (the SDK never fakes a dialog). Submenus are
94
+ * filtered recursively and dropped when empty.
95
+ */
96
+ export function keepItem(
97
+ item: MenuItemDef,
98
+ features: Record<string, boolean>,
99
+ canOpen: (kind: DialogKind) => boolean,
100
+ ): MenuItemDef | null {
101
+ if (!featureOn(item.feature, features)) return null;
102
+ if (item.kind === 'separator') return item;
103
+ if (item.kind === 'submenu') {
104
+ const items = filterItems(item.items, features, canOpen);
105
+ if (items.length === 0) return null;
106
+ return { ...item, items };
107
+ }
108
+ if (item.dialog && !canOpen(item.dialog)) return null;
109
+ return item;
110
+ }
111
+
112
+ /** Filter a list and collapse leading/trailing/double separators. */
113
+ export function filterItems(
114
+ items: MenuItemDef[],
115
+ features: Record<string, boolean>,
116
+ canOpen: (kind: DialogKind) => boolean,
117
+ ): MenuItemDef[] {
118
+ const kept = items
119
+ .map((i) => keepItem(i, features, canOpen))
120
+ .filter((i): i is MenuItemDef => i !== null);
121
+ // Collapse separators: drop leading, trailing, and runs.
122
+ const out: MenuItemDef[] = [];
123
+ for (const item of kept) {
124
+ if (item.kind === 'separator') {
125
+ if (out.length === 0) continue;
126
+ if (out[out.length - 1].kind === 'separator') continue;
127
+ }
128
+ out.push(item);
129
+ }
130
+ while (out.length > 0 && out[out.length - 1].kind === 'separator') out.pop();
131
+ return out;
132
+ }
133
+
134
+ /* ─────────────────────────── host extensions ──────────────────────────── */
135
+
136
+ /**
137
+ * Append host menu extensions to their target top-level menu. Each extension
138
+ * becomes a normal `item` (with a leading separator before the first host item
139
+ * in that menu so it's visually grouped). Host items dispatch via `onClick` or
140
+ * route a `dialog` kind through the dialog host, exactly like built-ins.
141
+ */
142
+ export function withMenuExtensions(menus: MenuDef[], ext?: MenuExtension[]): MenuDef[] {
143
+ if (!ext || ext.length === 0) return menus;
144
+ const byMenu = new Map<MenuId, MenuExtension[]>();
145
+ for (const e of ext) {
146
+ const list = byMenu.get(e.menu) ?? [];
147
+ list.push(e);
148
+ byMenu.set(e.menu, list);
149
+ }
150
+ return menus.map((menu) => {
151
+ const extras = byMenu.get(menu.id);
152
+ if (!extras || extras.length === 0) return menu;
153
+ const items: MenuItemDef[] = [...menu.items, { kind: 'separator', id: `ext-sep-${menu.id}` }];
154
+ for (const e of extras) {
155
+ items.push({
156
+ kind: 'item',
157
+ id: `ext-${e.id}`,
158
+ label: e.label,
159
+ icon: e.icon,
160
+ shortcut: e.shortcut,
161
+ dialog: e.dialog,
162
+ run: e.onClick ? (api) => e.onClick?.(api) : undefined,
163
+ });
164
+ }
165
+ return { ...menu, items };
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Resolve the menus a host actually sees: append menu extensions, filter every
171
+ * item by its `feature` gate + dialog-openability, then drop any top-level menu
172
+ * whose own `feature` is off or that ends up empty.
173
+ *
174
+ * Exported (not inlined in the component) so the feature-gating contract — e.g.
175
+ * `features={{ help: false }}` drops the Help menu and
176
+ * `features={{ branding: false }}` drops the "View on GitHub" / "About" links —
177
+ * is unit-testable without a DOM.
178
+ */
179
+ export function computeVisibleMenus(
180
+ menus: MenuDef[],
181
+ features: Record<string, boolean>,
182
+ canOpen: (kind: DialogKind) => boolean,
183
+ ext?: MenuExtension[],
184
+ ): MenuDef[] {
185
+ return withMenuExtensions(menus, ext)
186
+ .map((menu) => ({
187
+ ...menu,
188
+ items: filterItems(menu.items, features, canOpen),
189
+ }))
190
+ .filter((menu) => featureOn(menu.feature, features) && menu.items.length > 0);
191
+ }