@eifi1/ui-kit 0.50.0 → 0.51.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.
Files changed (143) hide show
  1. package/README.md +14 -12
  2. package/dist/admin/user-roster.js +31 -12
  3. package/dist/admin/user-roster.js.map +1 -1
  4. package/dist/auth/verify-email.d.ts +4 -0
  5. package/dist/auth/verify-email.js.map +1 -1
  6. package/dist/components/alert-banner.d.ts +9 -0
  7. package/dist/components/alert-banner.js +9 -5
  8. package/dist/components/alert-banner.js.map +1 -1
  9. package/dist/components/autocomplete.js +13 -0
  10. package/dist/components/autocomplete.js.map +1 -1
  11. package/dist/components/button-look.d.ts +4 -19
  12. package/dist/components/button-look.js +3 -30
  13. package/dist/components/button-look.js.map +1 -1
  14. package/dist/components/combobox.js +3 -1
  15. package/dist/components/combobox.js.map +1 -1
  16. package/dist/components/data-table-fit.js.map +1 -1
  17. package/dist/components/data-table-virtual.js +1 -0
  18. package/dist/components/data-table-virtual.js.map +1 -1
  19. package/dist/components/field-base.d.ts +31 -1
  20. package/dist/components/field-base.js +28 -0
  21. package/dist/components/field-base.js.map +1 -1
  22. package/dist/components/floating-panel.d.ts +6 -13
  23. package/dist/components/floating-panel.js +2 -9
  24. package/dist/components/floating-panel.js.map +1 -1
  25. package/dist/components/icon-button.js +3 -5
  26. package/dist/components/icon-button.js.map +1 -1
  27. package/dist/components/inline-entity-combobox.js +12 -0
  28. package/dist/components/inline-entity-combobox.js.map +1 -1
  29. package/dist/components/select.js +3 -2
  30. package/dist/components/select.js.map +1 -1
  31. package/dist/components/stat-tile.d.ts +8 -1
  32. package/dist/components/stat-tile.js +7 -3
  33. package/dist/components/stat-tile.js.map +1 -1
  34. package/dist/eslint/index.js +1 -1
  35. package/dist/i18n/german.js +2 -0
  36. package/dist/i18n/german.js.map +1 -1
  37. package/dist/i18n/kit-labels.d.ts +2 -2
  38. package/dist/i18n/kit-labels.js.map +1 -1
  39. package/dist/i18n/locales/en.js +2 -0
  40. package/dist/i18n/locales/en.js.map +2 -2
  41. package/dist/i18n/locales/es.js +2 -0
  42. package/dist/i18n/locales/es.js.map +1 -1
  43. package/dist/i18n/locales/fr.js +2 -0
  44. package/dist/i18n/locales/fr.js.map +1 -1
  45. package/dist/i18n/locales/hu.js +2 -0
  46. package/dist/i18n/locales/hu.js.map +1 -1
  47. package/dist/i18n/locales/it.js +2 -0
  48. package/dist/i18n/locales/it.js.map +1 -1
  49. package/dist/i18n/locales/zh.js +2 -0
  50. package/dist/i18n/locales/zh.js.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.js +2 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/landing/page-seo.d.ts +19 -11
  55. package/dist/landing/page-seo.js +11 -7
  56. package/dist/landing/page-seo.js.map +1 -1
  57. package/dist/landing/public-page.d.ts +57 -0
  58. package/dist/landing/public-page.js +45 -0
  59. package/dist/landing/public-page.js.map +1 -0
  60. package/dist/lib/quiet-focus.d.ts +4 -0
  61. package/dist/lib/quiet-focus.js +19 -0
  62. package/dist/lib/quiet-focus.js.map +1 -0
  63. package/dist/lib/server-wake.d.ts +3 -2
  64. package/dist/lib/server-wake.js +19 -13
  65. package/dist/lib/server-wake.js.map +1 -1
  66. package/dist/shell/app-shell.d.ts +2 -15
  67. package/dist/shell/app-shell.js +7 -26
  68. package/dist/shell/app-shell.js.map +1 -1
  69. package/dist/shell/dev-menu.d.ts +98 -0
  70. package/dist/shell/dev-menu.js +133 -0
  71. package/dist/shell/dev-menu.js.map +1 -0
  72. package/dist/shell/dev-switchers.d.ts +6 -53
  73. package/dist/shell/dev-switchers.js +5 -100
  74. package/dist/shell/dev-switchers.js.map +1 -1
  75. package/dist/shell/top-bar-actions.d.ts +4 -1
  76. package/dist/shell/top-bar-actions.js.map +1 -1
  77. package/dist/shell/top-bar-parts-labels.d.ts +18 -9
  78. package/dist/shell/top-bar-parts-labels.js +2 -0
  79. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  80. package/dist/shell/topbar-action-menu.d.ts +17 -2
  81. package/dist/shell/topbar-action-menu.js +3 -1
  82. package/dist/shell/topbar-action-menu.js.map +1 -1
  83. package/dist/shell.d.ts +3 -1
  84. package/dist/shell.js +3 -1
  85. package/dist/shell.js.map +1 -1
  86. package/dist/testing/index.d.ts +1 -0
  87. package/dist/testing/index.js +4 -0
  88. package/dist/testing/index.js.map +1 -1
  89. package/dist/testing/tour.d.ts +31 -0
  90. package/dist/testing/tour.js +12 -0
  91. package/dist/testing/tour.js.map +1 -0
  92. package/dist/theme/palette-presets.d.ts +0 -3
  93. package/dist/theme/palette-presets.js +0 -2
  94. package/dist/theme/palette-presets.js.map +1 -1
  95. package/dist/tour/tour-engine.d.ts +12 -0
  96. package/dist/tour/tour-engine.js +31 -0
  97. package/dist/tour/tour-engine.js.map +1 -0
  98. package/dist/tour/tour.d.ts +14 -3
  99. package/dist/tour/tour.js +60 -45
  100. package/dist/tour/tour.js.map +1 -1
  101. package/dist/tour/use-tour-url-sync.d.ts +5 -3
  102. package/dist/tour/use-tour-url-sync.js +21 -1
  103. package/dist/tour/use-tour-url-sync.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/admin/user-roster.tsx +30 -10
  106. package/src/auth/verify-email.tsx +4 -0
  107. package/src/components/alert-banner.tsx +37 -10
  108. package/src/components/autocomplete.tsx +18 -0
  109. package/src/components/button-look.ts +7 -57
  110. package/src/components/combobox.tsx +5 -1
  111. package/src/components/data-table-fit.ts +4 -1
  112. package/src/components/data-table-virtual.ts +14 -1
  113. package/src/components/field-base.tsx +53 -1
  114. package/src/components/floating-panel.tsx +6 -28
  115. package/src/components/icon-button.tsx +6 -8
  116. package/src/components/inline-entity-combobox.tsx +17 -0
  117. package/src/components/select.tsx +3 -6
  118. package/src/components/stat-tile.tsx +27 -9
  119. package/src/i18n/german.ts +2 -0
  120. package/src/i18n/kit-labels.tsx +2 -2
  121. package/src/i18n/locales/es.ts +2 -0
  122. package/src/i18n/locales/fr.ts +2 -0
  123. package/src/i18n/locales/hu.ts +2 -0
  124. package/src/i18n/locales/it.ts +2 -0
  125. package/src/i18n/locales/zh.ts +2 -0
  126. package/src/index.ts +4 -1
  127. package/src/landing/page-seo.ts +28 -16
  128. package/src/landing/public-page.tsx +106 -0
  129. package/src/lib/quiet-focus.ts +32 -0
  130. package/src/lib/server-wake.ts +41 -17
  131. package/src/shell/app-shell.tsx +11 -48
  132. package/src/shell/dev-menu.tsx +259 -0
  133. package/src/shell/dev-switchers.tsx +13 -174
  134. package/src/shell/top-bar-actions.tsx +4 -1
  135. package/src/shell/top-bar-parts-labels.ts +20 -9
  136. package/src/shell/topbar-action-menu.tsx +28 -2
  137. package/src/shell.ts +5 -1
  138. package/src/testing/index.ts +8 -0
  139. package/src/testing/tour.ts +41 -0
  140. package/src/theme/palette-presets.ts +0 -4
  141. package/src/tour/tour-engine.ts +41 -0
  142. package/src/tour/tour.tsx +64 -24
  143. package/src/tour/use-tour-url-sync.ts +33 -4
@@ -0,0 +1,133 @@
1
+ "use client";
2
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { FlaskConical, Undo2 } from "lucide-react";
5
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels.js";
6
+ import { TopBarActionMenu } from "./topbar-action-menu.js";
7
+ import { useTopBarPartsLabels } from "./top-bar-parts-labels.js";
8
+ const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
9
+ function readKey(key) {
10
+ try {
11
+ return sessionStorage.getItem(key);
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ function writeKey(key, value) {
17
+ try {
18
+ if (value === null) sessionStorage.removeItem(key);
19
+ else sessionStorage.setItem(key, value);
20
+ } catch {
21
+ }
22
+ }
23
+ const flask = /* @__PURE__ */ jsx(FlaskConical, { className: "size-5 text-warning" });
24
+ const textOf = (label, value) => typeof label === "string" || typeof label === "number" ? String(label) : value;
25
+ const isRow = (entry) => entry.kind !== "heading" && entry.kind !== "divider";
26
+ function DevMenu({
27
+ enabled,
28
+ roles,
29
+ currentRole: role,
30
+ onRole,
31
+ plans,
32
+ currentPlan: plan,
33
+ onPlan,
34
+ originalRole,
35
+ storageKey = DEV_ORIGINAL_ROLE_KEY,
36
+ billingOff = false,
37
+ roleLabel = (r) => r,
38
+ planLabel = (p) => p,
39
+ labels: labelsProp,
40
+ sections = [],
41
+ active = false
42
+ }) {
43
+ const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
44
+ const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : void 0).dev;
45
+ const [kept, setKept] = useState(() => enabled ? readKey(storageKey) : null);
46
+ const [wasEnabled, setWasEnabled] = useState(enabled);
47
+ if (wasEnabled !== enabled) {
48
+ setWasEnabled(enabled);
49
+ if (enabled) setKept(readKey(storageKey));
50
+ }
51
+ if (!enabled) return null;
52
+ const original = originalRole ?? kept ?? void 0;
53
+ const pickRole = (next) => {
54
+ if (originalRole === void 0) {
55
+ if (next === original) {
56
+ writeKey(storageKey, null);
57
+ setKept(null);
58
+ } else if (kept === null && role !== void 0 && next !== role) {
59
+ writeKey(storageKey, role);
60
+ setKept(role);
61
+ }
62
+ }
63
+ onRole?.(next);
64
+ };
65
+ const roleGroup = roles && roles.length > 0 && role !== void 0 && onRole ? [
66
+ { kind: "heading", key: "head-role", label: words.roleHeading },
67
+ ...roles.map(
68
+ (r) => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) })
69
+ ),
70
+ // Behind a rule, which ends the role group: "Back to" is a way out, not a role.
71
+ ...original !== void 0 && original !== role ? [
72
+ { kind: "divider", key: "sep-back" },
73
+ {
74
+ key: "back",
75
+ icon: /* @__PURE__ */ jsx(Undo2, { className: "size-4" }),
76
+ label: words.backTo(textOf(roleLabel(original), original)),
77
+ onSelect: () => pickRole(original)
78
+ }
79
+ ] : []
80
+ ] : [];
81
+ const planGroup = plans && plans.length > 0 && onPlan ? [
82
+ {
83
+ kind: "heading",
84
+ key: "head-plan",
85
+ // The billing-off note is part of the group's NAME, under the heading. In 0.42's
86
+ // own plan menu it was a trailing `<li>`: not a row, so the arrow keys passed
87
+ // over it and a screen reader moving through the menu never reached it. Named,
88
+ // it is read on the way into the plans. And in one menu a trailing note would
89
+ // sit under the app's sections, not under the plans.
90
+ label: billingOff ? /* @__PURE__ */ jsxs(Fragment, { children: [
91
+ words.planHeading,
92
+ " ",
93
+ /* @__PURE__ */ jsx(
94
+ "span",
95
+ {
96
+ "data-dev-billing-off": "",
97
+ className: "mt-0.5 block font-normal normal-case tracking-normal text-xs text-muted",
98
+ children: words.billingOff
99
+ }
100
+ )
101
+ ] }) : words.planHeading
102
+ },
103
+ ...plans.map(
104
+ (p) => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) })
105
+ )
106
+ ] : [];
107
+ const appGroups = sections.flatMap((section) => [
108
+ { kind: "heading", key: `section:${section.key}`, label: section.title },
109
+ ...section.entries.map((entry) => ({ ...entry, key: `${section.key}:${entry.key}` }))
110
+ ]);
111
+ const entries = [...roleGroup, ...planGroup, ...appGroups];
112
+ if (!entries.some(isRow)) return null;
113
+ const name = [
114
+ words.menu,
115
+ roleGroup.length > 0 && role !== void 0 ? bar.role(role) : null,
116
+ planGroup.length > 0 && plan !== void 0 ? words.plan(plan) : null
117
+ ].filter((part) => part !== null).join(" \xB7 ");
118
+ return /* @__PURE__ */ jsx(
119
+ TopBarActionMenu,
120
+ {
121
+ icon: flask,
122
+ iconBadge: active ? { label: words.overridesOn } : null,
123
+ "aria-label": name,
124
+ entries,
125
+ scrollable: true
126
+ }
127
+ );
128
+ }
129
+ export {
130
+ DEV_ORIGINAL_ROLE_KEY,
131
+ DevMenu
132
+ };
133
+ //# sourceMappingURL=dev-menu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/dev-menu.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { FlaskConical, Undo2 } from \"lucide-react\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TopBarActionMenu, type TopBarMenuEntry } from \"./topbar-action-menu\";\nimport { useTopBarPartsLabels, type DevSwitchersLabels } from \"./top-bar-parts-labels\";\n\n/** Where the role the session started with is kept, unless the app names its own key. */\nexport const DEV_ORIGINAL_ROLE_KEY = \"ui-kit:dev-original-role\";\n\nfunction readKey(key: string): string | null {\n try {\n return sessionStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeKey(key: string, value: string | null): void {\n try {\n if (value === null) sessionStorage.removeItem(key);\n else sessionStorage.setItem(key, value);\n } catch {\n /* storage blocked: \"Back to\" lasts as long as this page */\n }\n}\n\n/**\n * One of the app's own groups in the {@link DevMenu} (docs/top-bar-harmonization.md\n * §2.7.1): keksdose's budget role, its \"today\" override, its resets.\n *\n * The rows are plain {@link TopBarMenuEntry}s, and there is no dev item type beside them\n * (the contract with keksdose, 0.51): a choice of one is radio rows (`checked`), a toggle\n * is a `checkable: \"checkbox\"` row, an action is a plain row. No form fields — a menu is\n * a `role=\"menu\"`, where a text or date input has no place in the arrow keys; a free\n * value is a \"Custom…\" row that opens the app's own dialog.\n */\nexport interface DevMenuSection {\n /** Unique among the sections. The rows' keys need be unique only within theirs: the\n * menu prefixes them with this. */\n key: string;\n /** The group's heading, drawn above its rows and announced as the group's name. */\n title: ReactNode;\n /** The rows, as `TopBarActionMenu` takes them. A `divider` ends the group, as there. */\n entries: readonly TopBarMenuEntry[];\n}\n\nexport interface DevMenuProps<R extends string, P extends string> {\n /**\n * Draw anything at all. The app passes `import.meta.env.DEV` AND its probe of the dev\n * route (§2.7.1): the constant keeps the menu off a production build's screen, the\n * probe off a dev build whose server has the route shut (§6.1) — flasks with nothing\n * behind them. Neither takes code out of the bundle; see `sections`.\n */\n enabled: boolean;\n roles?: readonly R[];\n /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on\n * a JSX element is the ARIA role (as `RoleChip`'s `value`). */\n currentRole?: R;\n /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads\n * `/auth/me` (§2.7, §6.2). */\n onRole?: (role: R) => void;\n plans?: readonly P[];\n /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a\n * 404 while billing is off), the plan group still renders, with no row checked; a\n * `GET /dev/switch` answers it. */\n currentPlan?: P;\n /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */\n onPlan?: (plan: P) => void;\n /**\n * The role to offer \"Back to {role}\" for. Left out, the kit keeps it itself: the role\n * before the first switch of this browser session, in `sessionStorage` under\n * `storageKey` — the server answers with the NEW role after a switch, so only the\n * browser still knows the old one.\n */\n originalRole?: R;\n /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};\n * an app with several accounts on one browser may scope it per user. */\n storageKey?: string;\n /** Billing is off locally: the plan group says plans change nothing, and stays usable\n * (§6.3). */\n billingOff?: boolean;\n /** A role's or a plan's words in the menu. Default: the value itself. */\n roleLabel?: (role: R) => ReactNode;\n planLabel?: (plan: P) => ReactNode;\n /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */\n labels?: LabelOverride<DevSwitchersLabels>;\n /** 0.51: the app's own dev groups, after the kit's role and plan, in this order. Their\n * DEFINITIONS belong in a module only a dev build imports (§2.7.1): `enabled={false}`\n * hides the menu, but the code of a section built inline stays in the bundle. */\n sections?: readonly DevMenuSection[];\n /**\n * 0.51: an override is on that the developer could forget — a \"today\" that is not\n * today, a simulated slow server. The flask wears a dot, and its name says so (\"Dev\n * tools · Role: ADMIN · Plan: free Overrides on\"), since a dot alone tells a screen\n * reader nothing. The app decides what counts; the kit cannot see into its sections.\n */\n active?: boolean;\n}\n\nconst flask = <FlaskConical className=\"size-5 text-warning\" />;\n\n/** A label's text for a sentence, or the raw value when the label is not text. */\nconst textOf = (label: ReactNode, value: string) =>\n typeof label === \"string\" || typeof label === \"number\" ? String(label) : value;\n\n/** A row someone can pick — what makes a menu worth drawing. */\nconst isRow = (entry: TopBarMenuEntry) => entry.kind !== \"heading\" && entry.kind !== \"divider\";\n\n/**\n * The top bar's local-dev menu (docs/top-bar-harmonization.md §2.7, §2.7.1, §6.2, §6.3):\n * ONE amber flask, `RoleSwitcher`'s, over the kit's Account section and the app's own.\n *\n * 0.42's `DevSwitchers` drew a flask per switcher and had no place for anything else,\n * and an app has more dev controls than a role and a plan — keksdose's budget role, a\n * \"today\" override, resets (agreed with keksdose for 0.51). So the kit's part is now the\n * menu, and the app's rows go in it.\n *\n * - **The Account section** comes first: the role group, ending in \"Back to {role}\" once\n * the role differs from the one the session started with (the dev route needs only a\n * signed-in user, so a switch to a lesser role can always switch back); then the plan\n * group, which with `billingOff` notes that plans change nothing. Each renders only\n * with its list and handler.\n * - **The app's `sections`** follow, each a group under its own heading.\n * - **`active`** puts a dot on the flask for an override that is easy to forget.\n *\n * Nothing renders unless `enabled`, and nothing with no row to show. The panel scrolls\n * (`TopBarActionMenu`'s `scrollable`): some 25 rows ran off a 390×844 phone.\n */\nexport function DevMenu<R extends string, P extends string>({\n enabled,\n roles,\n currentRole: role,\n onRole,\n plans,\n currentPlan: plan,\n onPlan,\n originalRole,\n storageKey = DEV_ORIGINAL_ROLE_KEY,\n billingOff = false,\n roleLabel = (r) => r,\n planLabel = (p) => p,\n labels: labelsProp,\n sections = [],\n active = false,\n}: DevMenuProps<R, P>) {\n const bar = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;\n const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));\n // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the\n // dev route — so the kept role is read again then, or \"Back to {role}\" is lost after a\n // reload. Adjusted during render, React's pattern for state that follows a prop.\n const [wasEnabled, setWasEnabled] = useState(enabled);\n if (wasEnabled !== enabled) {\n setWasEnabled(enabled);\n if (enabled) setKept(readKey(storageKey));\n }\n if (!enabled) return null;\n\n const original = (originalRole ?? kept ?? undefined) as R | undefined;\n\n const pickRole = (next: R) => {\n if (originalRole === undefined) {\n if (next === original) {\n // Back where the session began: nothing left to go back to.\n writeKey(storageKey, null);\n setKept(null);\n } else if (kept === null && role !== undefined && next !== role) {\n writeKey(storageKey, role);\n setKept(role);\n }\n }\n onRole?.(next);\n };\n\n const roleGroup: TopBarMenuEntry[] =\n roles && roles.length > 0 && role !== undefined && onRole\n ? [\n { kind: \"heading\", key: \"head-role\", label: words.roleHeading },\n ...roles.map(\n (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),\n ),\n // Behind a rule, which ends the role group: \"Back to\" is a way out, not a role.\n ...(original !== undefined && original !== role\n ? ([\n { kind: \"divider\", key: \"sep-back\" },\n {\n key: \"back\",\n icon: <Undo2 className=\"size-4\" />,\n label: words.backTo(textOf(roleLabel(original), original)),\n onSelect: () => pickRole(original),\n },\n ] satisfies TopBarMenuEntry[])\n : []),\n ]\n : [];\n\n const planGroup: TopBarMenuEntry[] =\n plans && plans.length > 0 && onPlan\n ? [\n {\n kind: \"heading\",\n key: \"head-plan\",\n // The billing-off note is part of the group's NAME, under the heading. In 0.42's\n // own plan menu it was a trailing `<li>`: not a row, so the arrow keys passed\n // over it and a screen reader moving through the menu never reached it. Named,\n // it is read on the way into the plans. And in one menu a trailing note would\n // sit under the app's sections, not under the plans.\n label: billingOff ? (\n <>\n {words.planHeading}{\" \"}\n <span\n data-dev-billing-off=\"\"\n className=\"mt-0.5 block font-normal normal-case tracking-normal text-xs text-muted\"\n >\n {words.billingOff}\n </span>\n </>\n ) : (\n words.planHeading\n ),\n },\n ...plans.map(\n (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),\n ),\n ]\n : [];\n\n // The app's rows keyed under their section: two sections may well both have a \"reset\".\n const appGroups = sections.flatMap((section): TopBarMenuEntry[] => [\n { kind: \"heading\", key: `section:${section.key}`, label: section.title },\n ...section.entries.map((entry): TopBarMenuEntry => ({ ...entry, key: `${section.key}:${entry.key}` })),\n ]);\n\n const entries = [...roleGroup, ...planGroup, ...appGroups];\n if (!entries.some(isRow)) return null;\n\n // The menu, then the role and the plan, as 0.42's two triggers named them: the bar\n // shows the flask only, so its name (and tooltip) is where they are read.\n const name = [\n words.menu,\n roleGroup.length > 0 && role !== undefined ? bar.role(role) : null,\n planGroup.length > 0 && plan !== undefined ? words.plan(plan) : null,\n ]\n .filter((part): part is string => part !== null)\n .join(\" · \");\n\n return (\n <TopBarActionMenu\n icon={flask}\n // The badge's words join the trigger's name, as the tours compass's NEW dot does.\n // `danger`, the badge's default: an amber dot would vanish into the amber flask.\n iconBadge={active ? { label: words.overridesOn } : null}\n aria-label={name}\n entries={entries}\n scrollable\n />\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,53 +1,6 @@
1
- import type { ReactNode } from "react";
2
- import { type LabelOverride } from "../i18n/kit-labels.js";
3
- import { type DevSwitchersLabels } from "./top-bar-parts-labels.js";
4
- /** Where the role the session started with is kept, unless the app names its own key. */
5
- export declare const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
6
- export interface DevSwitchersProps<R extends string, P extends string> {
7
- /** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build
8
- * tree-shakes the switchers with the constant. */
9
- enabled: boolean;
10
- roles?: readonly R[];
11
- /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
12
- * a JSX element is the ARIA role (as `RoleChip`'s `value`). */
13
- currentRole?: R;
14
- /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
15
- * `/auth/me` (§2.7, §6.2). */
16
- onRole?: (role: R) => void;
17
- plans?: readonly P[];
18
- /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
19
- * 404 while billing is off), the plan menu still renders, with no row checked; a
20
- * `GET /dev/switch` answers it. */
21
- currentPlan?: P;
22
- /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
23
- onPlan?: (plan: P) => void;
24
- /**
25
- * The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
26
- * before the first switch of this browser session, in `sessionStorage` under
27
- * `storageKey` — the server answers with the NEW role after a switch, so only the
28
- * browser still knows the old one.
29
- */
30
- originalRole?: R;
31
- /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
32
- * an app with several accounts on one browser may scope it per user. */
33
- storageKey?: string;
34
- /** Billing is off locally: the plan menu says plans change nothing, and stays usable
35
- * (§6.3). */
36
- billingOff?: boolean;
37
- /** A role's or a plan's words in the menu. Default: the value itself. */
38
- roleLabel?: (role: R) => ReactNode;
39
- planLabel?: (plan: P) => ReactNode;
40
- /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
41
- labels?: LabelOverride<DevSwitchersLabels>;
42
- }
43
- /**
44
- * The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the
45
- * role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders
46
- * unless `enabled`; either menu renders only with its list and handler.
47
- *
48
- * The role menu ends in "Back to {role}" once the role differs from the one the session
49
- * started with: the dev route needs only a signed-in user, so a switch to a lesser role
50
- * can always switch back. With `billingOff` the plan menu notes that plans change nothing
51
- * while billing is off.
52
- */
53
- export declare function DevSwitchers<R extends string, P extends string>({ enabled, roles, currentRole: role, onRole, plans, currentPlan: plan, onPlan, originalRole, storageKey, billingOff, roleLabel, planLabel, labels: labelsProp, }: DevSwitchersProps<R, P>): import("react").JSX.Element | null;
1
+ import { type DevMenuProps } from "./dev-menu.js";
2
+ export { DEV_ORIGINAL_ROLE_KEY } from "./dev-menu.js";
3
+ /** @deprecated 0.51 — use {@link DevMenuProps} (the same props); removed in 0.52. */
4
+ export type DevSwitchersProps<R extends string, P extends string> = Omit<DevMenuProps<R, P>, "sections" | "active">;
5
+ /** @deprecated 0.51 — use {@link DevMenu} (one flask; same props); removed in 0.52. */
6
+ export declare function DevSwitchers<R extends string, P extends string>(props: DevSwitchersProps<R, P>): import("react").JSX.Element;
@@ -1,104 +1,9 @@
1
1
  "use client";
2
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { useState } from "react";
4
- import { FlaskConical, Undo2 } from "lucide-react";
5
- import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels.js";
6
- import { TopBarActionMenu } from "./topbar-action-menu.js";
7
- import { useTopBarPartsLabels } from "./top-bar-parts-labels.js";
8
- const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
9
- function readKey(key) {
10
- try {
11
- return sessionStorage.getItem(key);
12
- } catch {
13
- return null;
14
- }
15
- }
16
- function writeKey(key, value) {
17
- try {
18
- if (value === null) sessionStorage.removeItem(key);
19
- else sessionStorage.setItem(key, value);
20
- } catch {
21
- }
22
- }
23
- const flask = /* @__PURE__ */ jsx(FlaskConical, { className: "size-5 text-warning" });
24
- const textOf = (label, value) => typeof label === "string" || typeof label === "number" ? String(label) : value;
25
- function DevSwitchers({
26
- enabled,
27
- roles,
28
- currentRole: role,
29
- onRole,
30
- plans,
31
- currentPlan: plan,
32
- onPlan,
33
- originalRole,
34
- storageKey = DEV_ORIGINAL_ROLE_KEY,
35
- billingOff = false,
36
- roleLabel = (r) => r,
37
- planLabel = (p) => p,
38
- labels: labelsProp
39
- }) {
40
- const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
41
- const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : void 0).dev;
42
- const [kept, setKept] = useState(() => enabled ? readKey(storageKey) : null);
43
- const [wasEnabled, setWasEnabled] = useState(enabled);
44
- if (wasEnabled !== enabled) {
45
- setWasEnabled(enabled);
46
- if (enabled) setKept(readKey(storageKey));
47
- }
48
- if (!enabled) return null;
49
- const original = originalRole ?? kept ?? void 0;
50
- const pickRole = (next) => {
51
- if (originalRole === void 0) {
52
- if (next === original) {
53
- writeKey(storageKey, null);
54
- setKept(null);
55
- } else if (kept === null && role !== void 0 && next !== role) {
56
- writeKey(storageKey, role);
57
- setKept(role);
58
- }
59
- }
60
- onRole?.(next);
61
- };
62
- const roleMenu = roles && roles.length > 0 && role !== void 0 && onRole ? /* @__PURE__ */ jsx(
63
- TopBarActionMenu,
64
- {
65
- icon: flask,
66
- "aria-label": `${bar.switchRole} \xB7 ${bar.role(role)}`,
67
- entries: [
68
- { kind: "heading", key: "head-role", label: words.roleHeading },
69
- ...roles.map(
70
- (r) => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) })
71
- ),
72
- ...original !== void 0 && original !== role ? [
73
- { kind: "divider", key: "sep-back" },
74
- {
75
- key: "back",
76
- icon: /* @__PURE__ */ jsx(Undo2, { className: "size-4" }),
77
- label: words.backTo(textOf(roleLabel(original), original)),
78
- onSelect: () => pickRole(original)
79
- }
80
- ] : []
81
- ]
82
- }
83
- ) : null;
84
- const planMenu = plans && plans.length > 0 && onPlan ? /* @__PURE__ */ jsx(
85
- TopBarActionMenu,
86
- {
87
- icon: flask,
88
- "aria-label": plan !== void 0 ? `${words.switchPlan} \xB7 ${words.plan(plan)}` : words.switchPlan,
89
- entries: [
90
- { kind: "heading", key: "head-plan", label: words.planHeading },
91
- ...plans.map(
92
- (p) => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) })
93
- )
94
- ],
95
- children: billingOff ? () => /* @__PURE__ */ jsx("li", { "data-dev-billing-off": "", className: "mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted", children: words.billingOff }) : void 0
96
- }
97
- ) : null;
98
- return /* @__PURE__ */ jsxs(Fragment, { children: [
99
- roleMenu,
100
- planMenu
101
- ] });
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { DevMenu } from "./dev-menu.js";
4
+ import { DEV_ORIGINAL_ROLE_KEY } from "./dev-menu.js";
5
+ function DevSwitchers(props) {
6
+ return /* @__PURE__ */ jsx(DevMenu, { ...props });
102
7
  }
103
8
  export {
104
9
  DEV_ORIGINAL_ROLE_KEY,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/dev-switchers.tsx"],"sourcesContent":["import { useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { FlaskConical, Undo2 } from \"lucide-react\";\nimport { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { TopBarActionMenu, type TopBarMenuEntry } from \"./topbar-action-menu\";\nimport { useTopBarPartsLabels, type DevSwitchersLabels } from \"./top-bar-parts-labels\";\n\n/** Where the role the session started with is kept, unless the app names its own key. */\nexport const DEV_ORIGINAL_ROLE_KEY = \"ui-kit:dev-original-role\";\n\nfunction readKey(key: string): string | null {\n try {\n return sessionStorage.getItem(key);\n } catch {\n return null;\n }\n}\n\nfunction writeKey(key: string, value: string | null): void {\n try {\n if (value === null) sessionStorage.removeItem(key);\n else sessionStorage.setItem(key, value);\n } catch {\n /* storage blocked: \"Back to\" lasts as long as this page */\n }\n}\n\nexport interface DevSwitchersProps<R extends string, P extends string> {\n /** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build\n * tree-shakes the switchers with the constant. */\n enabled: boolean;\n roles?: readonly R[];\n /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on\n * a JSX element is the ARIA role (as `RoleChip`'s `value`). */\n currentRole?: R;\n /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads\n * `/auth/me` (§2.7, §6.2). */\n onRole?: (role: R) => void;\n plans?: readonly P[];\n /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a\n * 404 while billing is off), the plan menu still renders, with no row checked; a\n * `GET /dev/switch` answers it. */\n currentPlan?: P;\n /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */\n onPlan?: (plan: P) => void;\n /**\n * The role to offer \"Back to {role}\" for. Left out, the kit keeps it itself: the role\n * before the first switch of this browser session, in `sessionStorage` under\n * `storageKey` — the server answers with the NEW role after a switch, so only the\n * browser still knows the old one.\n */\n originalRole?: R;\n /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};\n * an app with several accounts on one browser may scope it per user. */\n storageKey?: string;\n /** Billing is off locally: the plan menu says plans change nothing, and stays usable\n * (§6.3). */\n billingOff?: boolean;\n /** A role's or a plan's words in the menu. Default: the value itself. */\n roleLabel?: (role: R) => ReactNode;\n planLabel?: (plan: P) => ReactNode;\n /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */\n labels?: LabelOverride<DevSwitchersLabels>;\n}\n\nconst flask = <FlaskConical className=\"size-5 text-warning\" />;\n\n/** A label's text for a sentence, or the raw value when the label is not text. */\nconst textOf = (label: ReactNode, value: string) =>\n typeof label === \"string\" || typeof label === \"number\" ? String(label) : value;\n\n/**\n * The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the\n * role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders\n * unless `enabled`; either menu renders only with its list and handler.\n *\n * The role menu ends in \"Back to {role}\" once the role differs from the one the session\n * started with: the dev route needs only a signed-in user, so a switch to a lesser role\n * can always switch back. With `billingOff` the plan menu notes that plans change nothing\n * while billing is off.\n */\nexport function DevSwitchers<R extends string, P extends string>({\n enabled,\n roles,\n currentRole: role,\n onRole,\n plans,\n currentPlan: plan,\n onPlan,\n originalRole,\n storageKey = DEV_ORIGINAL_ROLE_KEY,\n billingOff = false,\n roleLabel = (r) => r,\n planLabel = (p) => p,\n labels: labelsProp,\n}: DevSwitchersProps<R, P>) {\n const bar = useKitLabels(\"topBar\", DEFAULT_TOP_BAR_LABELS);\n const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;\n const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));\n // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the\n // dev route — so the kept role is read again then, or \"Back to {role}\" is lost after a\n // reload. Adjusted during render, React's pattern for state that follows a prop.\n const [wasEnabled, setWasEnabled] = useState(enabled);\n if (wasEnabled !== enabled) {\n setWasEnabled(enabled);\n if (enabled) setKept(readKey(storageKey));\n }\n if (!enabled) return null;\n\n const original = (originalRole ?? kept ?? undefined) as R | undefined;\n\n const pickRole = (next: R) => {\n if (originalRole === undefined) {\n if (next === original) {\n // Back where the session began: nothing left to go back to.\n writeKey(storageKey, null);\n setKept(null);\n } else if (kept === null && role !== undefined && next !== role) {\n writeKey(storageKey, role);\n setKept(role);\n }\n }\n onRole?.(next);\n };\n\n const roleMenu =\n roles && roles.length > 0 && role !== undefined && onRole ? (\n <TopBarActionMenu\n icon={flask}\n aria-label={`${bar.switchRole} · ${bar.role(role)}`}\n entries={[\n { kind: \"heading\", key: \"head-role\", label: words.roleHeading },\n ...roles.map(\n (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),\n ),\n ...(original !== undefined && original !== role\n ? ([\n { kind: \"divider\", key: \"sep-back\" },\n {\n key: \"back\",\n icon: <Undo2 className=\"size-4\" />,\n label: words.backTo(textOf(roleLabel(original), original)),\n onSelect: () => pickRole(original),\n },\n ] satisfies TopBarMenuEntry[])\n : []),\n ]}\n />\n ) : null;\n\n const planMenu =\n plans && plans.length > 0 && onPlan ? (\n <TopBarActionMenu\n icon={flask}\n aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}\n entries={[\n { kind: \"heading\", key: \"head-plan\", label: words.planHeading },\n ...plans.map(\n (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),\n ),\n ]}\n >\n {billingOff\n ? () => (\n <li data-dev-billing-off=\"\" className=\"mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted\">\n {words.billingOff}\n </li>\n )\n : undefined}\n </TopBarActionMenu>\n ) : null;\n\n return (\n <>\n {roleMenu}\n {planMenu}\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/shell/dev-switchers.tsx"],"sourcesContent":["import { DevMenu, type DevMenuProps } from \"./dev-menu\";\n\n// 0.42's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3), since 0.51\n// a `DevMenu` with no app sections (§2.7.1). The props are the same and so is every row,\n// \"Back to {role}\" and its `sessionStorage` included — but it draws ONE flask where it\n// drew two: the role and the plan are groups of one menu, as in DevMenu.\n\n// Where it has always been imported from, until the wrapper goes in 0.52; the key and the\n// \"Back to\" logic it serves are DevMenu's now.\nexport { DEV_ORIGINAL_ROLE_KEY } from \"./dev-menu\";\n\n/** @deprecated 0.51 — use {@link DevMenuProps} (the same props); removed in 0.52. */\nexport type DevSwitchersProps<R extends string, P extends string> = Omit<DevMenuProps<R, P>, \"sections\" | \"active\">;\n\n/** @deprecated 0.51 — use {@link DevMenu} (one flask; same props); removed in 0.52. */\nexport function DevSwitchers<R extends string, P extends string>(props: DevSwitchersProps<R, P>) {\n return <DevMenu {...props} />;\n}\n"],"mappings":";AAgBS;AAhBT,SAAS,eAAkC;AAS3C,SAAS,6BAA6B;AAM/B,SAAS,aAAiD,OAAgC;AAC/F,SAAO,oBAAC,WAAS,GAAG,OAAO;AAC7B;","names":[]}
@@ -18,7 +18,10 @@ export interface TopBarActionsProps {
18
18
  assistant?: ReactNode;
19
19
  /** `SyncStatusIndicator`. */
20
20
  sync?: ReactNode;
21
- /** `DevSwitchers` (role, then plan). */
21
+ /** `DevMenu` (0.51, docs/top-bar-harmonization.md §2.7.1): the one flask — the role,
22
+ * the plan, then the app's own dev sections. Fill it only behind
23
+ * `import.meta.env.DEV`, so a production build drops the branch. (0.42's
24
+ * `DevSwitchers`, deprecated, is a DevMenu with no sections.) */
22
25
  dev?: ReactNode;
23
26
  /** kastlan's platform button. */
24
27
  platform?: ReactNode;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/top-bar-actions.tsx"],"sourcesContent":["import { Fragment } from \"react\";\nimport type { ReactNode } from \"react\";\n\n/**\n * The top bar's items by name (docs/top-bar-harmonization.md §2.1, §6.6). Each is the\n * app's own node — a kit part or its own — and any may be left out.\n */\nexport interface TopBarActionsProps {\n /** kastlan's `CompanySwitcher`: the context, first. */\n companySwitcher?: ReactNode;\n /** `GlobalSearch` (⌘K). */\n search?: ReactNode;\n /** `ThemeToggle`. */\n theme?: ReactNode;\n /** `FeedbackMenu`. */\n feedback?: ReactNode;\n /** `TourMenu`. */\n tours?: ReactNode;\n /** kastlan's assistant, beside the tours while it is on. */\n assistant?: ReactNode;\n /** `SyncStatusIndicator`. */\n sync?: ReactNode;\n /** `DevSwitchers` (role, then plan). */\n dev?: ReactNode;\n /** kastlan's platform button. */\n platform?: ReactNode;\n /** 0.46 (docs/notifications-harmonization.md §10.1): `NotificationBell`, right before the\n * account menu. Optional per app: an app shows the bell or an unread dot on the avatar,\n * never both (keksdose keeps its dot and leaves this empty). */\n notifications?: ReactNode;\n /** The account menu. When it is set the signed-out slots are not drawn. */\n account?: ReactNode;\n /** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold\n * the language. Drawn only without `account`. */\n language?: ReactNode;\n /** Signed out (§6.6): the \"Sign in\" button, where the account menu would be. Drawn only\n * without `account`. */\n signIn?: ReactNode;\n}\n\n/** §2.1's order, then the signed-out pair in the account menu's place. */\nexport const TOP_BAR_ACTION_ORDER = [\n \"companySwitcher\",\n \"search\",\n \"theme\",\n \"feedback\",\n \"tours\",\n \"assistant\",\n \"sync\",\n \"dev\",\n \"platform\",\n \"notifications\",\n \"account\",\n \"language\",\n \"signIn\",\n] as const satisfies readonly (keyof TopBarActionsProps)[];\n\nconst present = (node: ReactNode) => node !== undefined && node !== null && node !== false && node !== \"\";\n\n/**\n * The top bar's actions in the one order every app shares (§2.1): company switcher ·\n * Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·\n * notifications (the bell, 0.46) · account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).\n * The app fills the slots it has; an empty slot is skipped, so it leaves no gap.\n *\n * It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row\n * spaces the items.\n *\n * ```tsx\n * <TopBar brand={…} actions={<TopBarActions search={…} theme={…} tours={…} account={…} />} />\n * ```\n */\nexport function TopBarActions(props: TopBarActionsProps) {\n const signedOut = !present(props.account);\n return (\n <>\n {TOP_BAR_ACTION_ORDER.map((slot) => {\n if ((slot === \"language\" || slot === \"signIn\") && !signedOut) return null;\n const node = props[slot];\n return present(node) ? <Fragment key={slot}>{node}</Fragment> : null;\n })}\n </>\n );\n}\n"],"mappings":";AA2EI,mBAI2B,WAJ3B;AA3EJ,SAAS,YAAAA,iBAAgB;AAyClB,MAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,UAAU,CAAC,SAAoB,SAAS,UAAa,SAAS,QAAQ,SAAS,SAAS,SAAS;AAehG,SAAS,cAAc,OAA2B;AACvD,QAAM,YAAY,CAAC,QAAQ,MAAM,OAAO;AACxC,SACE,gCACG,+BAAqB,IAAI,CAAC,SAAS;AAClC,SAAK,SAAS,cAAc,SAAS,aAAa,CAAC,UAAW,QAAO;AACrE,UAAM,OAAO,MAAM,IAAI;AACvB,WAAO,QAAQ,IAAI,IAAI,oBAACA,WAAA,EAAqB,kBAAP,IAAY,IAAc;AAAA,EAClE,CAAC,GACH;AAEJ;","names":["Fragment"]}
1
+ {"version":3,"sources":["../../src/shell/top-bar-actions.tsx"],"sourcesContent":["import { Fragment } from \"react\";\nimport type { ReactNode } from \"react\";\n\n/**\n * The top bar's items by name (docs/top-bar-harmonization.md §2.1, §6.6). Each is the\n * app's own node — a kit part or its own — and any may be left out.\n */\nexport interface TopBarActionsProps {\n /** kastlan's `CompanySwitcher`: the context, first. */\n companySwitcher?: ReactNode;\n /** `GlobalSearch` (⌘K). */\n search?: ReactNode;\n /** `ThemeToggle`. */\n theme?: ReactNode;\n /** `FeedbackMenu`. */\n feedback?: ReactNode;\n /** `TourMenu`. */\n tours?: ReactNode;\n /** kastlan's assistant, beside the tours while it is on. */\n assistant?: ReactNode;\n /** `SyncStatusIndicator`. */\n sync?: ReactNode;\n /** `DevMenu` (0.51, docs/top-bar-harmonization.md §2.7.1): the one flask — the role,\n * the plan, then the app's own dev sections. Fill it only behind\n * `import.meta.env.DEV`, so a production build drops the branch. (0.42's\n * `DevSwitchers`, deprecated, is a DevMenu with no sections.) */\n dev?: ReactNode;\n /** kastlan's platform button. */\n platform?: ReactNode;\n /** 0.46 (docs/notifications-harmonization.md §10.1): `NotificationBell`, right before the\n * account menu. Optional per app: an app shows the bell or an unread dot on the avatar,\n * never both (keksdose keeps its dot and leaves this empty). */\n notifications?: ReactNode;\n /** The account menu. When it is set the signed-out slots are not drawn. */\n account?: ReactNode;\n /** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold\n * the language. Drawn only without `account`. */\n language?: ReactNode;\n /** Signed out (§6.6): the \"Sign in\" button, where the account menu would be. Drawn only\n * without `account`. */\n signIn?: ReactNode;\n}\n\n/** §2.1's order, then the signed-out pair in the account menu's place. */\nexport const TOP_BAR_ACTION_ORDER = [\n \"companySwitcher\",\n \"search\",\n \"theme\",\n \"feedback\",\n \"tours\",\n \"assistant\",\n \"sync\",\n \"dev\",\n \"platform\",\n \"notifications\",\n \"account\",\n \"language\",\n \"signIn\",\n] as const satisfies readonly (keyof TopBarActionsProps)[];\n\nconst present = (node: ReactNode) => node !== undefined && node !== null && node !== false && node !== \"\";\n\n/**\n * The top bar's actions in the one order every app shares (§2.1): company switcher ·\n * Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·\n * notifications (the bell, 0.46) · account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).\n * The app fills the slots it has; an empty slot is skipped, so it leaves no gap.\n *\n * It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row\n * spaces the items.\n *\n * ```tsx\n * <TopBar brand={…} actions={<TopBarActions search={…} theme={…} tours={…} account={…} />} />\n * ```\n */\nexport function TopBarActions(props: TopBarActionsProps) {\n const signedOut = !present(props.account);\n return (\n <>\n {TOP_BAR_ACTION_ORDER.map((slot) => {\n if ((slot === \"language\" || slot === \"signIn\") && !signedOut) return null;\n const node = props[slot];\n return present(node) ? <Fragment key={slot}>{node}</Fragment> : null;\n })}\n </>\n );\n}\n"],"mappings":";AA8EI,mBAI2B,WAJ3B;AA9EJ,SAAS,YAAAA,iBAAgB;AA4ClB,MAAM,uBAAuB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,UAAU,CAAC,SAAoB,SAAS,UAAa,SAAS,QAAQ,SAAS,SAAS,SAAS;AAehG,SAAS,cAAc,OAA2B;AACvD,QAAM,YAAY,CAAC,QAAQ,MAAM,OAAO;AACxC,SACE,gCACG,+BAAqB,IAAI,CAAC,SAAS;AAClC,SAAK,SAAS,cAAc,SAAS,aAAa,CAAC,UAAW,QAAO;AACrE,UAAM,OAAO,MAAM,IAAI;AACvB,WAAO,QAAQ,IAAI,IAAI,oBAACA,WAAA,EAAqB,kBAAP,IAAY,IAAc;AAAA,EAClE,CAAC,GACH;AAEJ;","names":["Fragment"]}
@@ -1,10 +1,11 @@
1
1
  import { type LabelOverride } from "../i18n/kit-labels.js";
2
2
  /**
3
3
  * The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the
4
- * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module
4
+ * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevMenu` (0.42's
5
+ * `DevSwitchers`, whose words these still are, §2.7.1). A module
5
6
  * of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language
6
- * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role
7
- * switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.
7
+ * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the role in the
8
+ * dev flask's name is `topBar.role`, as `RoleSwitcher`'s tooltip.
8
9
  */
9
10
  /** `TourMenu`'s words (§2.4). */
10
11
  export interface TourMenuLabels {
@@ -65,19 +66,27 @@ export interface SyncStatusLabels {
65
66
  * words beside the cloud. */
66
67
  saving: string;
67
68
  }
68
- /** `DevSwitchers`' words (§2.7, §6.2, §6.3). */
69
+ /**
70
+ * `DevMenu`'s words (§2.7, §2.7.1, §6.2, §6.3). Still named for 0.42's `DevSwitchers`: the
71
+ * namespace is the same `topBarParts.dev`, and an app's label tree keeps compiling.
72
+ */
69
73
  export interface DevSwitchersLabels {
70
- /** The heading of the role menu. */
74
+ /** 0.51: the flask's name, and the menu's — the role and the plan follow it. */
75
+ menu: string;
76
+ /** 0.51: what the dot on the flask means while `active`, joined to its name — an
77
+ * override the developer could forget is on (a "today", a slow server). */
78
+ overridesOn: string;
79
+ /** The heading of the role group. */
71
80
  roleHeading: string;
72
- /** The plan switcher's name. */
81
+ /** @deprecated 0.51 — DevMenu's one flask is named by `menu`; removed in 0.52. */
73
82
  switchPlan: string;
74
- /** The plan switcher's tooltip, given the active plan. */
83
+ /** The plan in the flask's name, given the active plan. */
75
84
  plan: (value: string) => string;
76
- /** The heading of the plan menu. */
85
+ /** The heading of the plan group. */
77
86
  planHeading: string;
78
87
  /** The row back to the role the session started with. */
79
88
  backTo: (role: string) => string;
80
- /** The plan menu's note while billing is off locally. */
89
+ /** The plan group's note while billing is off locally. */
81
90
  billingOff: string;
82
91
  }
83
92
  export interface TopBarPartsLabels {
@@ -33,6 +33,8 @@ const DEFAULT_TOP_BAR_PARTS_LABELS = {
33
33
  saving: "Saving\u2026"
34
34
  },
35
35
  dev: {
36
+ menu: "Dev tools",
37
+ overridesOn: "Overrides on",
36
38
  roleHeading: "Dev \xB7 role",
37
39
  switchPlan: "Switch plan",
38
40
  plan: (value) => `Plan: ${value}`,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/top-bar-parts-labels.ts"],"sourcesContent":["import { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n\n/**\n * The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the\n * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevSwitchers`. A module\n * of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language\n * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the dev role\n * switcher's name and tooltip are `topBar.switchRole` / `topBar.role`, as `RoleSwitcher`'s.\n */\n\n/** `TourMenu`'s words (§2.4). */\nexport interface TourMenuLabels {\n /** The compass's name, and the menu's. */\n menu: string;\n /** The NEW dot on the compass, for a reader: it joins the trigger's name. */\n newDot: string;\n /** The heading over the tours that start on the open page. */\n thisPage: string;\n /** The heading over the untaken new tours. */\n newTours: string;\n /** The last row, the way to the app's tours page (only with `href`). */\n allTours: string;\n /** The chip on a new tour's row. */\n badgeNew: string;\n /** What the ✓ on a taken tour's row says to a reader. */\n completed: string;\n /** The chip on a tour reset by an update (0.43, docs/tours-harmonization.md §8). */\n badgeUpdated: string;\n /** The dot on the compass while only updated tours light it, for a reader. */\n updatedDot: string;\n /** The heading over the untaken new tours when updated ones are among them. */\n newAndUpdated: string;\n}\n\n/** `SyncStatusIndicator`'s words (§2.6). The \"waiting for a plan\" state is\n * `billing.waitingChanges`, the kit's words for it since billing §12.5. */\nexport interface SyncStatusLabels {\n /** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */\n blocked: (count: number) => string;\n offline: string;\n /** The server refused the last round. */\n refused: string;\n syncing: string;\n pending: (count: number) => string;\n /** Settled, with the time of the last good round (already formatted). */\n syncedAt: (time: string) => string;\n /** Settled, before the first round. */\n idle: string;\n /** Joined to the state when a tap syncs: what the tap does. */\n syncHint: string;\n /** Joined to the state while offline, when a tap can do nothing. */\n offlineHint: string;\n /** The state and what a tap does, as one tooltip: \"Offline — nothing to sync…\". */\n withHint: (state: string, hint: string) => string;\n /** The state with the app's `scope` words before it: \"Handovers on this device: …\". */\n scoped: (scope: string, state: string) => string;\n /** The short words beside the cloud (`showLabel`): offline. */\n offlineShort: string;\n /** The short words while syncing. */\n syncingShort: string;\n /** The short words while changes wait (pending, held for a plan, or blocked). */\n waitingShort: (count: number) => string;\n /** The short words after a refused round. */\n refusedShort: string;\n /** Offline in an online-only app (`storage=\"none\"`): nothing is kept on the device. */\n offlineUnsaved: string;\n /** A round under way in an online-only app — it saves, it doesn't sync. Also its short\n * words beside the cloud. */\n saving: string;\n}\n\n/** `DevSwitchers`' words (§2.7, §6.2, §6.3). */\nexport interface DevSwitchersLabels {\n /** The heading of the role menu. */\n roleHeading: string;\n /** The plan switcher's name. */\n switchPlan: string;\n /** The plan switcher's tooltip, given the active plan. */\n plan: (value: string) => string;\n /** The heading of the plan menu. */\n planHeading: string;\n /** The row back to the role the session started with. */\n backTo: (role: string) => string;\n /** The plan menu's note while billing is off locally. */\n billingOff: string;\n}\n\nexport interface TopBarPartsLabels {\n tours: TourMenuLabels;\n sync: SyncStatusLabels;\n dev: DevSwitchersLabels;\n}\n\nexport const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {\n tours: {\n menu: \"Guided tours\",\n newDot: \"New tours\",\n thisPage: \"On this page\",\n newTours: \"New tours\",\n allTours: \"All tours…\",\n badgeNew: \"New\",\n completed: \"Completed\",\n badgeUpdated: \"Updated\",\n updatedDot: \"Updated tours\",\n newAndUpdated: \"New and updated\",\n },\n sync: {\n blocked: (count) =>\n count === 1\n ? \"1 change cannot be sent from this device\"\n : `${count} changes cannot be sent from this device`,\n offline: \"Offline — changes are saved on this device and will sync when you reconnect\",\n refused: \"The server refused the last sync\",\n syncing: \"Syncing…\",\n pending: (count) => (count === 1 ? \"1 change waiting to sync\" : `${count} changes waiting to sync`),\n syncedAt: (time) => `Synced at ${time}`,\n idle: \"Up to date\",\n syncHint: \"Tap to sync now\",\n offlineHint: \"nothing to sync while offline\",\n withHint: (state, hint) => `${state} — ${hint}`,\n scoped: (scope, state) => `${scope}: ${state}`,\n offlineShort: \"Offline\",\n syncingShort: \"Syncing…\",\n waitingShort: (count) => `${count} waiting`,\n refusedShort: \"Not synced\",\n offlineUnsaved: \"Offline — changes can't be saved until you reconnect\",\n saving: \"Saving…\",\n },\n dev: {\n roleHeading: \"Dev · role\",\n switchPlan: \"Switch plan\",\n plan: (value) => `Plan: ${value}`,\n planHeading: \"Dev · plan\",\n backTo: (role) => `Back to ${role}`,\n billingOff: \"Billing is off locally: plans change nothing\",\n },\n};\n\n/** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */\nexport function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {\n return useKitLabels(\"topBarParts\", DEFAULT_TOP_BAR_PARTS_LABELS, labels);\n}\n"],"mappings":";AAAA,SAAS,oBAAwC;AA6F1C,MAAM,+BAAkD;AAAA,EAC7D,OAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,WAAW;AAAA,IACX,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,eAAe;AAAA,EACjB;AAAA,EACA,MAAM;AAAA,IACJ,SAAS,CAAC,UACR,UAAU,IACN,6CACA,GAAG,KAAK;AAAA,IACd,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS,CAAC,UAAW,UAAU,IAAI,6BAA6B,GAAG,KAAK;AAAA,IACxE,UAAU,CAAC,SAAS,aAAa,IAAI;AAAA,IACrC,MAAM;AAAA,IACN,UAAU;AAAA,IACV,aAAa;AAAA,IACb,UAAU,CAAC,OAAO,SAAS,GAAG,KAAK,WAAM,IAAI;AAAA,IAC7C,QAAQ,CAAC,OAAO,UAAU,GAAG,KAAK,KAAK,KAAK;AAAA,IAC5C,cAAc;AAAA,IACd,cAAc;AAAA,IACd,cAAc,CAAC,UAAU,GAAG,KAAK;AAAA,IACjC,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,QAAQ;AAAA,EACV;AAAA,EACA,KAAK;AAAA,IACH,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM,CAAC,UAAU,SAAS,KAAK;AAAA,IAC/B,aAAa;AAAA,IACb,QAAQ,CAAC,SAAS,WAAW,IAAI;AAAA,IACjC,YAAY;AAAA,EACd;AACF;AAGO,SAAS,qBAAqB,QAA8D;AACjG,SAAO,aAAa,eAAe,8BAA8B,MAAM;AACzE;","names":[]}
1
+ {"version":3,"sources":["../../src/shell/top-bar-parts-labels.ts"],"sourcesContent":["import { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\n\n/**\n * The `topBarParts` namespace (0.42, docs/top-bar-harmonization.md §3.1): the words of the\n * top bar's shared parts — `TourMenu`, `SyncStatusIndicator` and `DevMenu` (0.42's\n * `DevSwitchers`, whose words these still are, §2.7.1). A module\n * of words with no component code, like every `*-labels.ts` (0.34, KS U8). The language\n * sub-list (`AccountMenuLanguage`) names itself with `topBar.language`, and the role in the\n * dev flask's name is `topBar.role`, as `RoleSwitcher`'s tooltip.\n */\n\n/** `TourMenu`'s words (§2.4). */\nexport interface TourMenuLabels {\n /** The compass's name, and the menu's. */\n menu: string;\n /** The NEW dot on the compass, for a reader: it joins the trigger's name. */\n newDot: string;\n /** The heading over the tours that start on the open page. */\n thisPage: string;\n /** The heading over the untaken new tours. */\n newTours: string;\n /** The last row, the way to the app's tours page (only with `href`). */\n allTours: string;\n /** The chip on a new tour's row. */\n badgeNew: string;\n /** What the ✓ on a taken tour's row says to a reader. */\n completed: string;\n /** The chip on a tour reset by an update (0.43, docs/tours-harmonization.md §8). */\n badgeUpdated: string;\n /** The dot on the compass while only updated tours light it, for a reader. */\n updatedDot: string;\n /** The heading over the untaken new tours when updated ones are among them. */\n newAndUpdated: string;\n}\n\n/** `SyncStatusIndicator`'s words (§2.6). The \"waiting for a plan\" state is\n * `billing.waitingChanges`, the kit's words for it since billing §12.5. */\nexport interface SyncStatusLabels {\n /** Writes this device cannot send (keksdose: no key for the budget). Ranked first. */\n blocked: (count: number) => string;\n offline: string;\n /** The server refused the last round. */\n refused: string;\n syncing: string;\n pending: (count: number) => string;\n /** Settled, with the time of the last good round (already formatted). */\n syncedAt: (time: string) => string;\n /** Settled, before the first round. */\n idle: string;\n /** Joined to the state when a tap syncs: what the tap does. */\n syncHint: string;\n /** Joined to the state while offline, when a tap can do nothing. */\n offlineHint: string;\n /** The state and what a tap does, as one tooltip: \"Offline — nothing to sync…\". */\n withHint: (state: string, hint: string) => string;\n /** The state with the app's `scope` words before it: \"Handovers on this device: …\". */\n scoped: (scope: string, state: string) => string;\n /** The short words beside the cloud (`showLabel`): offline. */\n offlineShort: string;\n /** The short words while syncing. */\n syncingShort: string;\n /** The short words while changes wait (pending, held for a plan, or blocked). */\n waitingShort: (count: number) => string;\n /** The short words after a refused round. */\n refusedShort: string;\n /** Offline in an online-only app (`storage=\"none\"`): nothing is kept on the device. */\n offlineUnsaved: string;\n /** A round under way in an online-only app — it saves, it doesn't sync. Also its short\n * words beside the cloud. */\n saving: string;\n}\n\n/**\n * `DevMenu`'s words (§2.7, §2.7.1, §6.2, §6.3). Still named for 0.42's `DevSwitchers`: the\n * namespace is the same `topBarParts.dev`, and an app's label tree keeps compiling.\n */\nexport interface DevSwitchersLabels {\n /** 0.51: the flask's name, and the menu's — the role and the plan follow it. */\n menu: string;\n /** 0.51: what the dot on the flask means while `active`, joined to its name — an\n * override the developer could forget is on (a \"today\", a slow server). */\n overridesOn: string;\n /** The heading of the role group. */\n roleHeading: string;\n /** @deprecated 0.51 — DevMenu's one flask is named by `menu`; removed in 0.52. */\n switchPlan: string;\n /** The plan in the flask's name, given the active plan. */\n plan: (value: string) => string;\n /** The heading of the plan group. */\n planHeading: string;\n /** The row back to the role the session started with. */\n backTo: (role: string) => string;\n /** The plan group's note while billing is off locally. */\n billingOff: string;\n}\n\nexport interface TopBarPartsLabels {\n tours: TourMenuLabels;\n sync: SyncStatusLabels;\n dev: DevSwitchersLabels;\n}\n\nexport const DEFAULT_TOP_BAR_PARTS_LABELS: TopBarPartsLabels = {\n tours: {\n menu: \"Guided tours\",\n newDot: \"New tours\",\n thisPage: \"On this page\",\n newTours: \"New tours\",\n allTours: \"All tours…\",\n badgeNew: \"New\",\n completed: \"Completed\",\n badgeUpdated: \"Updated\",\n updatedDot: \"Updated tours\",\n newAndUpdated: \"New and updated\",\n },\n sync: {\n blocked: (count) =>\n count === 1\n ? \"1 change cannot be sent from this device\"\n : `${count} changes cannot be sent from this device`,\n offline: \"Offline — changes are saved on this device and will sync when you reconnect\",\n refused: \"The server refused the last sync\",\n syncing: \"Syncing…\",\n pending: (count) => (count === 1 ? \"1 change waiting to sync\" : `${count} changes waiting to sync`),\n syncedAt: (time) => `Synced at ${time}`,\n idle: \"Up to date\",\n syncHint: \"Tap to sync now\",\n offlineHint: \"nothing to sync while offline\",\n withHint: (state, hint) => `${state} — ${hint}`,\n scoped: (scope, state) => `${scope}: ${state}`,\n offlineShort: \"Offline\",\n syncingShort: \"Syncing…\",\n waitingShort: (count) => `${count} waiting`,\n refusedShort: \"Not synced\",\n offlineUnsaved: \"Offline — changes can't be saved until you reconnect\",\n saving: \"Saving…\",\n },\n dev: {\n menu: \"Dev tools\",\n overridesOn: \"Overrides on\",\n roleHeading: \"Dev · role\",\n switchPlan: \"Switch plan\",\n plan: (value) => `Plan: ${value}`,\n planHeading: \"Dev · plan\",\n backTo: (role) => `Back to ${role}`,\n billingOff: \"Billing is off locally: plans change nothing\",\n },\n};\n\n/** The `topBarParts` namespace, resolved — English, then the provider, then `labels`. */\nexport function useTopBarPartsLabels(labels?: LabelOverride<TopBarPartsLabels>): TopBarPartsLabels {\n return useKitLabels(\"topBarParts\", DEFAULT_TOP_BAR_PARTS_LABELS, labels);\n}\n"],"mappings":";AAAA,SAAS,oBAAwC;AAsG1C,MAAM,+BAAkD;AAAA,EAC7D,OAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,UAAU;AAAA,IACV,WAAW;AAAA,IACX,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,eAAe;AAAA,EACjB;AAAA,EACA,MAAM;AAAA,IACJ,SAAS,CAAC,UACR,UAAU,IACN,6CACA,GAAG,KAAK;AAAA,IACd,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS;AAAA,IACT,SAAS,CAAC,UAAW,UAAU,IAAI,6BAA6B,GAAG,KAAK;AAAA,IACxE,UAAU,CAAC,SAAS,aAAa,IAAI;AAAA,IACrC,MAAM;AAAA,IACN,UAAU;AAAA,IACV,aAAa;AAAA,IACb,UAAU,CAAC,OAAO,SAAS,GAAG,KAAK,WAAM,IAAI;AAAA,IAC7C,QAAQ,CAAC,OAAO,UAAU,GAAG,KAAK,KAAK,KAAK;AAAA,IAC5C,cAAc;AAAA,IACd,cAAc;AAAA,IACd,cAAc,CAAC,UAAU,GAAG,KAAK;AAAA,IACjC,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,QAAQ;AAAA,EACV;AAAA,EACA,KAAK;AAAA,IACH,MAAM;AAAA,IACN,aAAa;AAAA,IACb,aAAa;AAAA,IACb,YAAY;AAAA,IACZ,MAAM,CAAC,UAAU,SAAS,KAAK;AAAA,IAC/B,aAAa;AAAA,IACb,QAAQ,CAAC,SAAS,WAAW,IAAI;AAAA,IACjC,YAAY;AAAA,EACd;AACF;AAGO,SAAS,qBAAqB,QAA8D;AACjG,SAAO,aAAa,eAAe,8BAA8B,MAAM;AACzE;","names":[]}
@@ -103,9 +103,10 @@ export interface TopBarMenuHeader {
103
103
  * A long menu is grouped by topic with `{ kind: "heading" }` entries (0.23) — see
104
104
  * {@link TopBarMenuEntry}. There is no separate phone layout: the panel is the same
105
105
  * hover panel at 390px, kept inside the viewport by `HoverMenu`, and a heading wraps
106
- * rather than widening it.
106
+ * rather than widening it. A menu that can grow taller than a phone's glass passes
107
+ * `scrollable` (0.51), since that clamp is sideways only.
107
108
  */
108
- export declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName, href, "aria-label": ariaLabel, heading, header, entries, leading, children, footer, footerLabel, align, panelClassName, triggerAttributes, }: {
109
+ export declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClassName, href, "aria-label": ariaLabel, heading, header, entries, leading, children, footer, footerLabel, align, panelClassName, scrollable, triggerAttributes, }: {
109
110
  /** The trigger's face for the usual icon button. Ignored when `trigger` is given. */
110
111
  icon?: ReactNode;
111
112
  /**
@@ -175,6 +176,20 @@ export declare function TopBarActionMenu({ icon, iconBadge, trigger, triggerClas
175
176
  * direction (see `HoverMenu`, whose type this is). Default `end`. */
176
177
  align?: HoverMenuProps["align"];
177
178
  panelClassName?: string;
179
+ /**
180
+ * Cap the panel at the glass under the top bar and scroll the rows inside it (0.51,
181
+ * `DevMenu`). `HoverMenu` keeps a panel inside the viewport sideways only, so a tall
182
+ * one ran off the bottom of the screen: DevMenu's ~25 rows on a 390×844 phone, with
183
+ * the last ones out of reach — the page cannot scroll them up, the panel hangs from
184
+ * the sticky bar.
185
+ *
186
+ * Off by default: a menu that fits keeps its natural height and has no scroll
187
+ * container (`TourMenu` keeps its own `max-h-[70vh]`). The keyboard needs nothing
188
+ * extra: `HoverMenu`'s roving focus is a plain `focus()`, which the browser follows by
189
+ * scrolling the panel to the row, so ↓, ↑, Home and End reach a row below the cap. The
190
+ * header and the footer are inside the list and scroll with the rows.
191
+ */
192
+ scrollable?: boolean;
178
193
  /** `data-*` attributes for the trigger itself — a tour's anchor (`data-tour`), a test
179
194
  * hook. The parts built on this menu (`TourMenu`) pass theirs through here. */
180
195
  triggerAttributes?: {
@@ -28,6 +28,7 @@ function toBlocks(entries) {
28
28
  }
29
29
  const SECTION_CONTROLS = "a[href]:not([role]),button:not([role])";
30
30
  const TABBABLE = "a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]";
31
+ const SCROLLABLE_PANEL = "max-h-[calc(100dvh-4rem)] overflow-y-auto overscroll-contain";
31
32
  function MenuSection({ className, children }) {
32
33
  const ref = useRef(null);
33
34
  useLayoutEffect(() => {
@@ -66,6 +67,7 @@ function TopBarActionMenu({
66
67
  footerLabel,
67
68
  align,
68
69
  panelClassName,
70
+ scrollable = false,
69
71
  triggerAttributes
70
72
  }) {
71
73
  const uid = useId();
@@ -138,7 +140,7 @@ function TopBarActionMenu({
138
140
  ) }, entry.key);
139
141
  return (
140
142
  // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a Tab bubbling up from a row or footer link, see keepTabInsidePanel
141
- /* @__PURE__ */ jsxs("ul", { className: cn("py-1", panelClassName), onKeyDown: keepTabInsidePanel, children: [
143
+ /* @__PURE__ */ jsxs("ul", { className: cn("py-1", scrollable && SCROLLABLE_PANEL, panelClassName), onKeyDown: keepTabInsidePanel, children: [
142
144
  header && /* @__PURE__ */ jsxs(MenuSection, { className: "mb-1 border-b border-subtle px-3 py-2", children: [
143
145
  /* @__PURE__ */ jsx("div", { className: "truncate text-sm font-medium text-primary", children: header.title }),
144
146
  header.subtitle != null && /* @__PURE__ */ jsx("div", { className: "truncate text-xs text-muted", children: header.subtitle }),