@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
@@ -77,8 +77,9 @@ export interface ServerWakeWatcher {
77
77
  */
78
78
  start(method?: string, url?: string, request?: ServerWakeRequest): boolean;
79
79
  /** A counted request settled — either way: an error still proves the server
80
- * answered or gave up. When the last one does, the notice goes away. Never takes
81
- * the counter below zero. */
80
+ * answered or gave up. The stage then follows the oldest request still out (0.51), so
81
+ * the notice goes away when the last one does, or sooner when the rest are recent.
82
+ * Never takes the counter below zero. */
82
83
  end(): void;
83
84
  /** `start` + `end` around a promise (or a function returning one), for a request
84
85
  * that does not go through an adapter. Resolves and rejects as the work does. */
@@ -116,7 +117,19 @@ export function createServerWake({
116
117
  wakingMs = 7000,
117
118
  shouldWatch = watchReadsAnd(),
118
119
  }: ServerWakeOptions = {}): ServerWakeWatcher {
119
- let inFlight = 0;
120
+ // When each counted request went out, oldest first; its length is the count.
121
+ //
122
+ // The stage is the age of the OLDEST outstanding request (0.51). It used to be the age of
123
+ // the first request since the count was last zero, so overlapping reads that were each
124
+ // fast — one going out before the last came back, on a busy page — chained past 2 s and
125
+ // showed "taking longer than usual" with nothing slow at all (keksdose live #454, found
126
+ // there while chasing a read that really was slow).
127
+ //
128
+ // `end()` names no request, so it retires the OLDEST start. When a newer request comes
129
+ // back first, the start left standing for the older one is later than its own, so the
130
+ // age read is never MORE than the oldest request's real age: a notice can come late,
131
+ // never falsely.
132
+ const starts: number[] = [];
120
133
  let stage: ServerWakeStage = "idle";
121
134
  let slowTimer: ReturnType<typeof setTimeout> | null = null;
122
135
  let wakingTimer: ReturnType<typeof setTimeout> | null = null;
@@ -135,25 +148,36 @@ export function createServerWake({
135
148
  wakingTimer = null;
136
149
  };
137
150
 
151
+ // The stage for the oldest outstanding request's age, and timers for what is still to
152
+ // come. With none outstanding: idle.
153
+ const arm = () => {
154
+ clearTimers();
155
+ if (starts.length === 0) {
156
+ setStage("idle");
157
+ return;
158
+ }
159
+ const age = Date.now() - starts[0];
160
+ setStage(age >= wakingMs ? "waking" : age >= slowMs ? "slow" : "idle");
161
+ if (age < slowMs) slowTimer = setTimeout(() => setStage("slow"), slowMs - age);
162
+ if (age < wakingMs) wakingTimer = setTimeout(() => setStage("waking"), wakingMs - age);
163
+ };
164
+
138
165
  const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {
139
166
  if (isOffline()) return false;
140
167
  if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "", request)) return false;
141
- inFlight += 1;
142
- // The clock runs from the FIRST outstanding request: a page firing a second read
143
- // at 1.9 s is still waiting on the same sleeping server, not starting afresh.
144
- if (inFlight === 1) {
145
- clearTimers();
146
- slowTimer = setTimeout(() => setStage("slow"), slowMs);
147
- wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
148
- }
168
+ starts.push(Date.now());
169
+ // A request joining others does not move the clock: a page firing a second read at
170
+ // 1.9 s is still waiting on the same sleeping server, not starting afresh.
171
+ if (starts.length === 1) arm();
149
172
  return true;
150
173
  };
151
174
 
152
175
  const end = () => {
153
- inFlight = Math.max(0, inFlight - 1);
154
- if (inFlight > 0) return;
155
- clearTimers();
156
- setStage("idle");
176
+ if (starts.length === 0) return;
177
+ // The server answered (or gave up on) one of them, so the oldest's clock is over; the
178
+ // next oldest's age decides, which may well put the notice away again.
179
+ starts.shift();
180
+ arm();
157
181
  };
158
182
 
159
183
  return {
@@ -171,7 +195,7 @@ export function createServerWake({
171
195
  return counted ? promise.finally(end) : promise;
172
196
  },
173
197
  reset() {
174
- inFlight = 0;
198
+ starts.length = 0;
175
199
  clearTimers();
176
200
  setStage("idle");
177
201
  },
@@ -182,7 +206,7 @@ export function createServerWake({
182
206
  };
183
207
  },
184
208
  getStage: () => stage,
185
- getInFlight: () => inFlight,
209
+ getInFlight: () => starts.length,
186
210
  };
187
211
  }
188
212
 
@@ -164,17 +164,6 @@ export const DEFAULT_MOBILE_MAX_ITEMS: Readonly<Record<TextSize, number>> = {
164
164
  xlarge: 4,
165
165
  };
166
166
 
167
- /**
168
- * @deprecated 0.49 — use {@link DEFAULT_MOBILE_MAX_ITEMS} and `AppShell`'s
169
- * `mobileMaxItems`: the bar is sized in cells, "More" included; removed in 0.51. This is
170
- * the same default counted as entries before "More" (one fewer).
171
- */
172
- export const DEFAULT_MOBILE_BAR_MAX: Readonly<Record<TextSize, number>> = {
173
- normal: DEFAULT_MOBILE_MAX_ITEMS.normal - 1,
174
- large: DEFAULT_MOBILE_MAX_ITEMS.large - 1,
175
- xlarge: DEFAULT_MOBILE_MAX_ITEMS.xlarge - 1,
176
- };
177
-
178
167
  /**
179
168
  * The phone bar's cells (0.49 KIT-6): every entry when there are at most `maxItems`,
180
169
  * else `maxItems − 1` entries and a "More" cell for the rest. Which entries stay is
@@ -215,21 +204,10 @@ export function splitMobileBar<T>(entries: readonly T[], max: number): { shown:
215
204
  return { shown: entries.slice(0, limit), hidden: entries.slice(limit) };
216
205
  }
217
206
 
218
- /** The bar's cell count at `size`: `mobileMaxItems`, else the deprecated `mobileBarMax`
219
- * read as cells (one more), else the default. */
220
- function mobileCells(
221
- maxItems: AppShellProps["mobileMaxItems"],
222
- barMax: AppShellProps["mobileBarMax"],
223
- size: TextSize,
224
- ): number {
225
- if (maxItems !== undefined) {
226
- return typeof maxItems === "number" ? maxItems : (maxItems[size] ?? DEFAULT_MOBILE_MAX_ITEMS[size]);
227
- }
228
- if (barMax !== undefined) {
229
- const entries = typeof barMax === "number" ? barMax : barMax[size];
230
- if (entries !== undefined) return entries + 1;
231
- }
232
- return DEFAULT_MOBILE_MAX_ITEMS[size];
207
+ /** The bar's cell count at `size`: `mobileMaxItems`, else the default. */
208
+ function mobileCells(maxItems: AppShellProps["mobileMaxItems"], size: TextSize): number {
209
+ if (maxItems === undefined) return DEFAULT_MOBILE_MAX_ITEMS[size];
210
+ return typeof maxItems === "number" ? maxItems : (maxItems[size] ?? DEFAULT_MOBILE_MAX_ITEMS[size]);
233
211
  }
234
212
 
235
213
  /** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a
@@ -263,17 +241,10 @@ function shortLabelNamesLabel(short: string, label: string): boolean {
263
241
  const TEXT_SIZE_WORDS: Record<TextSize, string> = { normal: "Normal", large: "Large", xlarge: "Extra large" };
264
242
 
265
243
  /** A development build's notes on `nav` (0.49 KIT-6): a `shortLabel` that is another word
266
- * than its `label` without `shortLabelIsAlias`, and the deprecated `mobileBarMax`. */
267
- function useNavDevWarnings(nav: AppShellNavItem[], deprecatedBarMax: boolean): void {
244
+ * than its `label` without `shortLabelIsAlias`. */
245
+ function useNavDevWarnings(nav: AppShellNavItem[]): void {
268
246
  useEffect(() => {
269
247
  if (!devWarnings()) return;
270
- if (deprecatedBarMax) {
271
- warnNavOnce(
272
- "mobileBarMax",
273
- "[ui-kit] AppShell mobileBarMax is deprecated: use mobileMaxItems, which counts the bar's cells with " +
274
- '"More" (mobileBarMax={4} is mobileMaxItems={5}). Removed in 0.51.',
275
- );
276
- }
277
248
  for (const item of nav) {
278
249
  if (item.shortLabel === undefined || item.shortLabelIsAlias) continue;
279
250
  if (shortLabelNamesLabel(item.shortLabel, item.label)) continue;
@@ -287,7 +258,7 @@ function useNavDevWarnings(nav: AppShellNavItem[], deprecatedBarMax: boolean): v
287
258
  "Shorten the label instead, or pass shortLabelIsAlias: true if the other word is meant.",
288
259
  );
289
260
  }
290
- }, [nav, deprecatedBarMax]);
261
+ }, [nav]);
291
262
  }
292
263
 
293
264
  /**
@@ -352,7 +323,7 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
352
323
  *
353
324
  * Unset (0.32.2, kastlan's 0.32.1 report): `"wrap"` at Normal and `"scroll"` at Large
354
325
  * and Extra large, where a wrapped group of eight pages took six or seven rows and
355
- * covered the page — the other half of what `mobileBarMax` does for the bar.
326
+ * covered the page — the other half of what `mobileMaxItems` does for the bar.
356
327
  */
357
328
  mobileSubNavLayout?: "wrap" | "scroll";
358
329
  /**
@@ -368,13 +339,6 @@ export interface AppShellProps extends ComponentPropsWithoutRef<"div"> {
368
339
  * `{ xlarge: 5 }` replaces it at one size only. Never fewer than one entry beside More.
369
340
  */
370
341
  mobileMaxItems?: number | Partial<Record<TextSize, number>>;
371
- /**
372
- * @deprecated 0.49 — use `mobileMaxItems`, which counts the bar's cells, "More"
373
- * included: `mobileBarMax={4}` is `mobileMaxItems={5}`; removed in 0.51. Read as that
374
- * (one more, per size), with a development warning; ignored when `mobileMaxItems` is
375
- * given.
376
- */
377
- mobileBarMax?: number | Partial<Record<TextSize, number>>;
378
342
  /** The More cell's words over the provider's `appShellMore`. */
379
343
  moreLabels?: Partial<AppShellMoreLabels>;
380
344
  /**
@@ -498,7 +462,6 @@ export function AppShell({
498
462
  mobileSubNav = true,
499
463
  mobileSubNavLayout: mobileSubNavLayoutProp,
500
464
  mobileMaxItems,
501
- mobileBarMax,
502
465
  moreLabels,
503
466
  toggleGroupLabel,
504
467
  embedded: embeddedProp,
@@ -512,13 +475,13 @@ export function AppShell({
512
475
  const mobileSubNavLayout = mobileSubNavLayoutProp ?? (large ? "scroll" : "wrap");
513
476
  // The bottom bar's cells: every entry the phone does not reach another way, up to the
514
477
  // text size's limit, More included; the rest go under More (§10.7, KIT-6).
515
- const maxItems = mobileCells(mobileMaxItems, mobileBarMax, textSize);
478
+ const maxItems = mobileCells(mobileMaxItems, textSize);
516
479
  const { shown: barNav, hidden: moreNav } = pickMobileBar(
517
480
  nav.filter((item) => !item.mobileHidden),
518
481
  maxItems,
519
482
  (item) => item.mobilePriority,
520
483
  );
521
- useNavDevWarnings(nav, mobileBarMax !== undefined && mobileMaxItems === undefined);
484
+ useNavDevWarnings(nav);
522
485
  // An embedded shell sharing the default key would overwrite the outer app's sidebar
523
486
  // preference with its own — so it persists only when given a key of its own.
524
487
  const collapseStorageKey =
@@ -1079,7 +1042,7 @@ function TourAnchor({ to, dataTour }: { to: string; dataTour?: string }) {
1079
1042
 
1080
1043
  /**
1081
1044
  * The bar's "More" cell (0.32, docs/text-size-harmonization.md §4, §10.7): the entries
1082
- * past `mobileBarMax`, in a sheet — each entry as a link, and under a group its pages,
1045
+ * past `mobileMaxItems`, in a sheet — each entry as a link, and under a group its pages,
1083
1046
  * since a group the bar no longer shows has no sub-row to reach them by either. Marked
1084
1047
  * active while the current page is one of them, as the cell it replaced would be.
1085
1048
  *
@@ -0,0 +1,259 @@
1
+ import { useState } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { FlaskConical, Undo2 } from "lucide-react";
4
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
5
+ import { TopBarActionMenu, type TopBarMenuEntry } from "./topbar-action-menu";
6
+ import { useTopBarPartsLabels, type DevSwitchersLabels } from "./top-bar-parts-labels";
7
+
8
+ /** Where the role the session started with is kept, unless the app names its own key. */
9
+ export const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
10
+
11
+ function readKey(key: string): string | null {
12
+ try {
13
+ return sessionStorage.getItem(key);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function writeKey(key: string, value: string | null): void {
20
+ try {
21
+ if (value === null) sessionStorage.removeItem(key);
22
+ else sessionStorage.setItem(key, value);
23
+ } catch {
24
+ /* storage blocked: "Back to" lasts as long as this page */
25
+ }
26
+ }
27
+
28
+ /**
29
+ * One of the app's own groups in the {@link DevMenu} (docs/top-bar-harmonization.md
30
+ * §2.7.1): keksdose's budget role, its "today" override, its resets.
31
+ *
32
+ * The rows are plain {@link TopBarMenuEntry}s, and there is no dev item type beside them
33
+ * (the contract with keksdose, 0.51): a choice of one is radio rows (`checked`), a toggle
34
+ * is a `checkable: "checkbox"` row, an action is a plain row. No form fields — a menu is
35
+ * a `role="menu"`, where a text or date input has no place in the arrow keys; a free
36
+ * value is a "Custom…" row that opens the app's own dialog.
37
+ */
38
+ export interface DevMenuSection {
39
+ /** Unique among the sections. The rows' keys need be unique only within theirs: the
40
+ * menu prefixes them with this. */
41
+ key: string;
42
+ /** The group's heading, drawn above its rows and announced as the group's name. */
43
+ title: ReactNode;
44
+ /** The rows, as `TopBarActionMenu` takes them. A `divider` ends the group, as there. */
45
+ entries: readonly TopBarMenuEntry[];
46
+ }
47
+
48
+ export interface DevMenuProps<R extends string, P extends string> {
49
+ /**
50
+ * Draw anything at all. The app passes `import.meta.env.DEV` AND its probe of the dev
51
+ * route (§2.7.1): the constant keeps the menu off a production build's screen, the
52
+ * probe off a dev build whose server has the route shut (§6.1) — flasks with nothing
53
+ * behind them. Neither takes code out of the bundle; see `sections`.
54
+ */
55
+ enabled: boolean;
56
+ roles?: readonly R[];
57
+ /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
58
+ * a JSX element is the ARIA role (as `RoleChip`'s `value`). */
59
+ currentRole?: R;
60
+ /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
61
+ * `/auth/me` (§2.7, §6.2). */
62
+ onRole?: (role: R) => void;
63
+ plans?: readonly P[];
64
+ /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
65
+ * 404 while billing is off), the plan group still renders, with no row checked; a
66
+ * `GET /dev/switch` answers it. */
67
+ currentPlan?: P;
68
+ /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
69
+ onPlan?: (plan: P) => void;
70
+ /**
71
+ * The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
72
+ * before the first switch of this browser session, in `sessionStorage` under
73
+ * `storageKey` — the server answers with the NEW role after a switch, so only the
74
+ * browser still knows the old one.
75
+ */
76
+ originalRole?: R;
77
+ /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
78
+ * an app with several accounts on one browser may scope it per user. */
79
+ storageKey?: string;
80
+ /** Billing is off locally: the plan group says plans change nothing, and stays usable
81
+ * (§6.3). */
82
+ billingOff?: boolean;
83
+ /** A role's or a plan's words in the menu. Default: the value itself. */
84
+ roleLabel?: (role: R) => ReactNode;
85
+ planLabel?: (plan: P) => ReactNode;
86
+ /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
87
+ labels?: LabelOverride<DevSwitchersLabels>;
88
+ /** 0.51: the app's own dev groups, after the kit's role and plan, in this order. Their
89
+ * DEFINITIONS belong in a module only a dev build imports (§2.7.1): `enabled={false}`
90
+ * hides the menu, but the code of a section built inline stays in the bundle. */
91
+ sections?: readonly DevMenuSection[];
92
+ /**
93
+ * 0.51: an override is on that the developer could forget — a "today" that is not
94
+ * today, a simulated slow server. The flask wears a dot, and its name says so ("Dev
95
+ * tools · Role: ADMIN · Plan: free Overrides on"), since a dot alone tells a screen
96
+ * reader nothing. The app decides what counts; the kit cannot see into its sections.
97
+ */
98
+ active?: boolean;
99
+ }
100
+
101
+ const flask = <FlaskConical className="size-5 text-warning" />;
102
+
103
+ /** A label's text for a sentence, or the raw value when the label is not text. */
104
+ const textOf = (label: ReactNode, value: string) =>
105
+ typeof label === "string" || typeof label === "number" ? String(label) : value;
106
+
107
+ /** A row someone can pick — what makes a menu worth drawing. */
108
+ const isRow = (entry: TopBarMenuEntry) => entry.kind !== "heading" && entry.kind !== "divider";
109
+
110
+ /**
111
+ * The top bar's local-dev menu (docs/top-bar-harmonization.md §2.7, §2.7.1, §6.2, §6.3):
112
+ * ONE amber flask, `RoleSwitcher`'s, over the kit's Account section and the app's own.
113
+ *
114
+ * 0.42's `DevSwitchers` drew a flask per switcher and had no place for anything else,
115
+ * and an app has more dev controls than a role and a plan — keksdose's budget role, a
116
+ * "today" override, resets (agreed with keksdose for 0.51). So the kit's part is now the
117
+ * menu, and the app's rows go in it.
118
+ *
119
+ * - **The Account section** comes first: the role group, ending in "Back to {role}" once
120
+ * the role differs from the one the session started with (the dev route needs only a
121
+ * signed-in user, so a switch to a lesser role can always switch back); then the plan
122
+ * group, which with `billingOff` notes that plans change nothing. Each renders only
123
+ * with its list and handler.
124
+ * - **The app's `sections`** follow, each a group under its own heading.
125
+ * - **`active`** puts a dot on the flask for an override that is easy to forget.
126
+ *
127
+ * Nothing renders unless `enabled`, and nothing with no row to show. The panel scrolls
128
+ * (`TopBarActionMenu`'s `scrollable`): some 25 rows ran off a 390×844 phone.
129
+ */
130
+ export function DevMenu<R extends string, P extends string>({
131
+ enabled,
132
+ roles,
133
+ currentRole: role,
134
+ onRole,
135
+ plans,
136
+ currentPlan: plan,
137
+ onPlan,
138
+ originalRole,
139
+ storageKey = DEV_ORIGINAL_ROLE_KEY,
140
+ billingOff = false,
141
+ roleLabel = (r) => r,
142
+ planLabel = (p) => p,
143
+ labels: labelsProp,
144
+ sections = [],
145
+ active = false,
146
+ }: DevMenuProps<R, P>) {
147
+ const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
148
+ const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;
149
+ const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));
150
+ // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the
151
+ // dev route — so the kept role is read again then, or "Back to {role}" is lost after a
152
+ // reload. Adjusted during render, React's pattern for state that follows a prop.
153
+ const [wasEnabled, setWasEnabled] = useState(enabled);
154
+ if (wasEnabled !== enabled) {
155
+ setWasEnabled(enabled);
156
+ if (enabled) setKept(readKey(storageKey));
157
+ }
158
+ if (!enabled) return null;
159
+
160
+ const original = (originalRole ?? kept ?? undefined) as R | undefined;
161
+
162
+ const pickRole = (next: R) => {
163
+ if (originalRole === undefined) {
164
+ if (next === original) {
165
+ // Back where the session began: nothing left to go back to.
166
+ writeKey(storageKey, null);
167
+ setKept(null);
168
+ } else if (kept === null && role !== undefined && next !== role) {
169
+ writeKey(storageKey, role);
170
+ setKept(role);
171
+ }
172
+ }
173
+ onRole?.(next);
174
+ };
175
+
176
+ const roleGroup: TopBarMenuEntry[] =
177
+ roles && roles.length > 0 && role !== undefined && onRole
178
+ ? [
179
+ { kind: "heading", key: "head-role", label: words.roleHeading },
180
+ ...roles.map(
181
+ (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),
182
+ ),
183
+ // Behind a rule, which ends the role group: "Back to" is a way out, not a role.
184
+ ...(original !== undefined && original !== role
185
+ ? ([
186
+ { kind: "divider", key: "sep-back" },
187
+ {
188
+ key: "back",
189
+ icon: <Undo2 className="size-4" />,
190
+ label: words.backTo(textOf(roleLabel(original), original)),
191
+ onSelect: () => pickRole(original),
192
+ },
193
+ ] satisfies TopBarMenuEntry[])
194
+ : []),
195
+ ]
196
+ : [];
197
+
198
+ const planGroup: TopBarMenuEntry[] =
199
+ plans && plans.length > 0 && onPlan
200
+ ? [
201
+ {
202
+ kind: "heading",
203
+ key: "head-plan",
204
+ // The billing-off note is part of the group's NAME, under the heading. In 0.42's
205
+ // own plan menu it was a trailing `<li>`: not a row, so the arrow keys passed
206
+ // over it and a screen reader moving through the menu never reached it. Named,
207
+ // it is read on the way into the plans. And in one menu a trailing note would
208
+ // sit under the app's sections, not under the plans.
209
+ label: billingOff ? (
210
+ <>
211
+ {words.planHeading}{" "}
212
+ <span
213
+ data-dev-billing-off=""
214
+ className="mt-0.5 block font-normal normal-case tracking-normal text-xs text-muted"
215
+ >
216
+ {words.billingOff}
217
+ </span>
218
+ </>
219
+ ) : (
220
+ words.planHeading
221
+ ),
222
+ },
223
+ ...plans.map(
224
+ (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),
225
+ ),
226
+ ]
227
+ : [];
228
+
229
+ // The app's rows keyed under their section: two sections may well both have a "reset".
230
+ const appGroups = sections.flatMap((section): TopBarMenuEntry[] => [
231
+ { kind: "heading", key: `section:${section.key}`, label: section.title },
232
+ ...section.entries.map((entry): TopBarMenuEntry => ({ ...entry, key: `${section.key}:${entry.key}` })),
233
+ ]);
234
+
235
+ const entries = [...roleGroup, ...planGroup, ...appGroups];
236
+ if (!entries.some(isRow)) return null;
237
+
238
+ // The menu, then the role and the plan, as 0.42's two triggers named them: the bar
239
+ // shows the flask only, so its name (and tooltip) is where they are read.
240
+ const name = [
241
+ words.menu,
242
+ roleGroup.length > 0 && role !== undefined ? bar.role(role) : null,
243
+ planGroup.length > 0 && plan !== undefined ? words.plan(plan) : null,
244
+ ]
245
+ .filter((part): part is string => part !== null)
246
+ .join(" · ");
247
+
248
+ return (
249
+ <TopBarActionMenu
250
+ icon={flask}
251
+ // The badge's words join the trigger's name, as the tours compass's NEW dot does.
252
+ // `danger`, the badge's default: an amber dot would vanish into the amber flask.
253
+ iconBadge={active ? { label: words.overridesOn } : null}
254
+ aria-label={name}
255
+ entries={entries}
256
+ scrollable
257
+ />
258
+ );
259
+ }
@@ -1,179 +1,18 @@
1
- import { useState } from "react";
2
- import type { ReactNode } from "react";
3
- import { FlaskConical, Undo2 } from "lucide-react";
4
- import { DEFAULT_TOP_BAR_LABELS, useKitLabels, type LabelOverride } from "../i18n/kit-labels";
5
- import { TopBarActionMenu, type TopBarMenuEntry } from "./topbar-action-menu";
6
- import { useTopBarPartsLabels, type DevSwitchersLabels } from "./top-bar-parts-labels";
1
+ import { DevMenu, type DevMenuProps } from "./dev-menu";
7
2
 
8
- /** Where the role the session started with is kept, unless the app names its own key. */
9
- export const DEV_ORIGINAL_ROLE_KEY = "ui-kit:dev-original-role";
3
+ // 0.42's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3), since 0.51
4
+ // a `DevMenu` with no app sections (§2.7.1). The props are the same and so is every row,
5
+ // "Back to {role}" and its `sessionStorage` included — but it draws ONE flask where it
6
+ // drew two: the role and the plan are groups of one menu, as in DevMenu.
10
7
 
11
- function readKey(key: string): string | null {
12
- try {
13
- return sessionStorage.getItem(key);
14
- } catch {
15
- return null;
16
- }
17
- }
18
-
19
- function writeKey(key: string, value: string | null): void {
20
- try {
21
- if (value === null) sessionStorage.removeItem(key);
22
- else sessionStorage.setItem(key, value);
23
- } catch {
24
- /* storage blocked: "Back to" lasts as long as this page */
25
- }
26
- }
27
-
28
- export interface DevSwitchersProps<R extends string, P extends string> {
29
- /** Draw anything at all: the app passes `import.meta.env.DEV`, so a production build
30
- * tree-shakes the switchers with the constant. */
31
- enabled: boolean;
32
- roles?: readonly R[];
33
- /** The role the server enforces now. Named `currentRole`, not `role`, because `role` on
34
- * a JSX element is the ARIA role (as `RoleChip`'s `value`). */
35
- currentRole?: R;
36
- /** Apply a role — the app calls server-kit's `POST /dev/switch {role}` and re-reads
37
- * `/auth/me` (§2.7, §6.2). */
38
- onRole?: (role: R) => void;
39
- plans?: readonly P[];
40
- /** The plan the server enforces now. Left out (not known yet — kastlan's overview is a
41
- * 404 while billing is off), the plan menu still renders, with no row checked; a
42
- * `GET /dev/switch` answers it. */
43
- currentPlan?: P;
44
- /** Apply a plan — `POST /dev/switch {plan}`, then the overview (§6.3). */
45
- onPlan?: (plan: P) => void;
46
- /**
47
- * The role to offer "Back to {role}" for. Left out, the kit keeps it itself: the role
48
- * before the first switch of this browser session, in `sessionStorage` under
49
- * `storageKey` — the server answers with the NEW role after a switch, so only the
50
- * browser still knows the old one.
51
- */
52
- originalRole?: R;
53
- /** The `sessionStorage` key for the kept role. Default {@link DEV_ORIGINAL_ROLE_KEY};
54
- * an app with several accounts on one browser may scope it per user. */
55
- storageKey?: string;
56
- /** Billing is off locally: the plan menu says plans change nothing, and stays usable
57
- * (§6.3). */
58
- billingOff?: boolean;
59
- /** A role's or a plan's words in the menu. Default: the value itself. */
60
- roleLabel?: (role: R) => ReactNode;
61
- planLabel?: (plan: P) => ReactNode;
62
- /** Prop > `<UiKitProvider labels={{ topBarParts: { dev } }}>` > English. */
63
- labels?: LabelOverride<DevSwitchersLabels>;
64
- }
65
-
66
- const flask = <FlaskConical className="size-5 text-warning" />;
67
-
68
- /** A label's text for a sentence, or the raw value when the label is not text. */
69
- const textOf = (label: ReactNode, value: string) =>
70
- typeof label === "string" || typeof label === "number" ? String(label) : value;
71
-
72
- /**
73
- * The top bar's local-dev switchers (docs/top-bar-harmonization.md §2.7, §6.2, §6.3): the
74
- * role, then the plan, each a menu behind `RoleSwitcher`'s amber flask. Nothing renders
75
- * unless `enabled`; either menu renders only with its list and handler.
76
- *
77
- * The role menu ends in "Back to {role}" once the role differs from the one the session
78
- * started with: the dev route needs only a signed-in user, so a switch to a lesser role
79
- * can always switch back. With `billingOff` the plan menu notes that plans change nothing
80
- * while billing is off.
81
- */
82
- export function DevSwitchers<R extends string, P extends string>({
83
- enabled,
84
- roles,
85
- currentRole: role,
86
- onRole,
87
- plans,
88
- currentPlan: plan,
89
- onPlan,
90
- originalRole,
91
- storageKey = DEV_ORIGINAL_ROLE_KEY,
92
- billingOff = false,
93
- roleLabel = (r) => r,
94
- planLabel = (p) => p,
95
- labels: labelsProp,
96
- }: DevSwitchersProps<R, P>) {
97
- const bar = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS);
98
- const words = useTopBarPartsLabels(labelsProp ? { dev: labelsProp } : undefined).dev;
99
- const [kept, setKept] = useState<string | null>(() => (enabled ? readKey(storageKey) : null));
100
- // 0.43 (keksdose): `enabled` may start false and turn true — an app's GET probe of the
101
- // dev route — so the kept role is read again then, or "Back to {role}" is lost after a
102
- // reload. Adjusted during render, React's pattern for state that follows a prop.
103
- const [wasEnabled, setWasEnabled] = useState(enabled);
104
- if (wasEnabled !== enabled) {
105
- setWasEnabled(enabled);
106
- if (enabled) setKept(readKey(storageKey));
107
- }
108
- if (!enabled) return null;
109
-
110
- const original = (originalRole ?? kept ?? undefined) as R | undefined;
111
-
112
- const pickRole = (next: R) => {
113
- if (originalRole === undefined) {
114
- if (next === original) {
115
- // Back where the session began: nothing left to go back to.
116
- writeKey(storageKey, null);
117
- setKept(null);
118
- } else if (kept === null && role !== undefined && next !== role) {
119
- writeKey(storageKey, role);
120
- setKept(role);
121
- }
122
- }
123
- onRole?.(next);
124
- };
125
-
126
- const roleMenu =
127
- roles && roles.length > 0 && role !== undefined && onRole ? (
128
- <TopBarActionMenu
129
- icon={flask}
130
- aria-label={`${bar.switchRole} · ${bar.role(role)}`}
131
- entries={[
132
- { kind: "heading", key: "head-role", label: words.roleHeading },
133
- ...roles.map(
134
- (r): TopBarMenuEntry => ({ key: `role-${r}`, label: roleLabel(r), checked: r === role, onSelect: () => pickRole(r) }),
135
- ),
136
- ...(original !== undefined && original !== role
137
- ? ([
138
- { kind: "divider", key: "sep-back" },
139
- {
140
- key: "back",
141
- icon: <Undo2 className="size-4" />,
142
- label: words.backTo(textOf(roleLabel(original), original)),
143
- onSelect: () => pickRole(original),
144
- },
145
- ] satisfies TopBarMenuEntry[])
146
- : []),
147
- ]}
148
- />
149
- ) : null;
8
+ // Where it has always been imported from, until the wrapper goes in 0.52; the key and the
9
+ // "Back to" logic it serves are DevMenu's now.
10
+ export { DEV_ORIGINAL_ROLE_KEY } from "./dev-menu";
150
11
 
151
- const planMenu =
152
- plans && plans.length > 0 && onPlan ? (
153
- <TopBarActionMenu
154
- icon={flask}
155
- aria-label={plan !== undefined ? `${words.switchPlan} · ${words.plan(plan)}` : words.switchPlan}
156
- entries={[
157
- { kind: "heading", key: "head-plan", label: words.planHeading },
158
- ...plans.map(
159
- (p): TopBarMenuEntry => ({ key: `plan-${p}`, label: planLabel(p), checked: p === plan, onSelect: () => onPlan(p) }),
160
- ),
161
- ]}
162
- >
163
- {billingOff
164
- ? () => (
165
- <li data-dev-billing-off="" className="mt-1 max-w-64 border-t border-subtle px-3 py-2 text-xs text-muted">
166
- {words.billingOff}
167
- </li>
168
- )
169
- : undefined}
170
- </TopBarActionMenu>
171
- ) : null;
12
+ /** @deprecated 0.51 — use {@link DevMenuProps} (the same props); removed in 0.52. */
13
+ export type DevSwitchersProps<R extends string, P extends string> = Omit<DevMenuProps<R, P>, "sections" | "active">;
172
14
 
173
- return (
174
- <>
175
- {roleMenu}
176
- {planMenu}
177
- </>
178
- );
15
+ /** @deprecated 0.51 — use {@link DevMenu} (one flask; same props); removed in 0.52. */
16
+ export function DevSwitchers<R extends string, P extends string>(props: DevSwitchersProps<R, P>) {
17
+ return <DevMenu {...props} />;
179
18
  }