@carlonicora/nextjs-jsonapi 3.8.1 → 3.9.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 (38) hide show
  1. package/dist/{BlockNoteEditor-KXONPDJR.mjs → BlockNoteEditor-RGMU2O24.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-55FMZZOR.js → BlockNoteEditor-WY4LFMXQ.js} +9 -9
  3. package/dist/{BlockNoteEditor-55FMZZOR.js.map → BlockNoteEditor-WY4LFMXQ.js.map} +1 -1
  4. package/dist/billing/index.js +333 -333
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-YOYYVEEY.mjs → chunk-7PNGCZES.mjs} +1720 -1689
  7. package/dist/chunk-7PNGCZES.mjs.map +1 -0
  8. package/dist/{chunk-SJ3EW2FD.js → chunk-DOLEXUO3.js} +180 -149
  9. package/dist/chunk-DOLEXUO3.js.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +33 -2
  13. package/dist/components/index.d.ts +33 -2
  14. package/dist/components/index.js +2 -2
  15. package/dist/components/index.mjs +1 -1
  16. package/dist/contexts/index.d.mts +7 -1
  17. package/dist/contexts/index.d.ts +7 -1
  18. package/dist/contexts/index.js +6 -2
  19. package/dist/contexts/index.js.map +1 -1
  20. package/dist/contexts/index.mjs +5 -1
  21. package/dist/features/help/index.js +31 -31
  22. package/dist/features/help/index.mjs +1 -1
  23. package/dist/features/tokenusage/index.js +75 -75
  24. package/dist/features/tokenusage/index.mjs +1 -1
  25. package/package.json +1 -1
  26. package/src/components/containers/RoundPageContainer.tsx +36 -15
  27. package/src/components/containers/RoundPageContainerTitle.tsx +6 -3
  28. package/src/components/containers/TabsContainer.tsx +10 -0
  29. package/src/components/forms/CommonDeleter.tsx +14 -1
  30. package/src/components/forms/EditorSheet.tsx +64 -46
  31. package/src/contexts/ActionBarContext.tsx +23 -0
  32. package/src/contexts/index.ts +1 -0
  33. package/src/features/user/contexts/CurrentUserContext.tsx +24 -0
  34. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +40 -0
  35. package/src/shadcnui/ui/command.tsx +9 -0
  36. package/dist/chunk-SJ3EW2FD.js.map +0 -1
  37. package/dist/chunk-YOYYVEEY.mjs.map +0 -1
  38. /package/dist/{BlockNoteEditor-KXONPDJR.mjs.map → BlockNoteEditor-RGMU2O24.mjs.map} +0 -0
@@ -20,6 +20,7 @@ import {
20
20
  import { partitionTabs, Tab } from "@/components/containers";
21
21
  import { HEADER_ROW_MIN_H, RoundPageContainerTitle } from "@/components/containers/RoundPageContainerTitle";
22
22
  import { Header, MobileNavigationBar } from "@/components/navigations";
23
+ import { MicroLabel } from "@/components/typography";
23
24
  import { useHeaderChildren, useHeaderLeftContent, useHeaderLogo, useHeaderMobileChildren } from "@/contexts";
24
25
  import { useUrlRewriter } from "@/hooks";
25
26
  import { cn, useIsMobile } from "@/index";
@@ -113,15 +114,33 @@ type RoundPageContainerProps = {
113
114
  scroll?: "document" | "fixed";
114
115
  };
115
116
 
116
- // Rail trigger class: override the horizontal TabsTrigger defaults for a
117
- // vertical, start-aligned, dark-filled active state. tailwind-merge inside cn()
118
- // resolves the conflicts with the base classes.
117
+ // Rail trigger class: the rail is a LIST, not a column of buttons — the same
118
+ // treatment as the handbook reader's section rail (HandbookPageNavigator /
119
+ // HandbookPageToc in a360ai): entries at `text-xs` on a `border-s-2` rule, and
120
+ // the active one marked by turning that rule and its label `primary` rather
121
+ // than by growing a filled box around it.
122
+ //
123
+ // Every override below is written in the SAME class group AND the same variant
124
+ // modifiers as the base `TabsTrigger` class it has to beat, because that is the
125
+ // only way tailwind-merge inside cn() drops the base one; a differently-scoped
126
+ // utility (`data-active:` against a `dark:data-active:` base) would survive as
127
+ // a second rule and win or lose on stylesheet order.
128
+ //
129
+ // The previous version targeted `data-[state=active]`, which these Base UI tabs
130
+ // never set — the active row was therefore still wearing the base
131
+ // `data-active:bg-background` box, which is the button look this replaces.
119
132
  const railTriggerClass = cn(
120
- "flex w-full items-center justify-start rounded-md px-3 py-1.5 text-start text-sm leading-tight whitespace-normal",
121
- "text-muted-foreground",
122
- "hover:bg-muted hover:text-foreground",
123
- "data-[state=active]:bg-foreground data-[state=active]:text-background",
124
- "data-[state=active]:font-semibold data-[state=active]:shadow-none",
133
+ "flex h-auto w-full items-center justify-start px-3 py-1 text-start text-xs leading-tight whitespace-normal",
134
+ // `border-0` first: the base sets a 1px box on all four sides, and only a
135
+ // later border-width utility of the same group removes it. `border-s-2` then
136
+ // draws the rail itself.
137
+ "rounded-none border-0 border-s-2 border-transparent bg-transparent",
138
+ "text-muted-foreground dark:text-muted-foreground transition-colors",
139
+ "hover:bg-transparent hover:text-primary dark:hover:text-primary",
140
+ "data-active:bg-transparent dark:data-active:bg-transparent",
141
+ "data-active:border-primary dark:data-active:border-primary",
142
+ "data-active:text-primary dark:data-active:text-primary",
143
+ "data-active:font-medium data-active:shadow-none",
125
144
  );
126
145
 
127
146
  /** Stable value for the URL `?section=` and active-tab matching. */
@@ -244,7 +263,9 @@ export function RoundPageContainer({
244
263
  [module, id, rewriteUrl, onSectionChange],
245
264
  );
246
265
 
247
- const activeFillHeight = tabs?.find((t) => tabValue(t) === activeTab)?.fillHeight === true;
266
+ const activeTabDefinition = tabs?.find((t) => tabValue(t) === activeTab);
267
+ const activeFillHeight = activeTabDefinition?.fillHeight === true;
268
+ const activeConstrainWidth = activeTabDefinition?.constrainWidth === true;
248
269
 
249
270
  // Rail partition — only consumed by `layout="rail"` but cheap to compute.
250
271
  const { ungrouped, groups } = useMemo(() => partitionTabs(tabs ?? []), [tabs]);
@@ -394,12 +415,9 @@ export function RoundPageContainer({
394
415
  ))}
395
416
  {groups.map((group) => (
396
417
  <Fragment key={group.label}>
397
- <div
398
- role="presentation"
399
- className="text-muted-foreground px-3 pt-3 pb-1 text-[10px] font-bold tracking-wider uppercase"
400
- >
418
+ <MicroLabel as="span" role="presentation" className="block px-3 pt-3 pb-1">
401
419
  {group.label}
402
- </div>
420
+ </MicroLabel>
403
421
  {group.items.map((tab) => (
404
422
  <TabsTrigger key={tab.label} value={tabValue(tab)} className={railTriggerClass}>
405
423
  {tab.contentLabel ?? tab.label}
@@ -447,10 +465,13 @@ export function RoundPageContainer({
447
465
  className={cn(`min-w-0 grow`, activeFillHeight ? cn(`flex flex-col`, clip) : cn(scrollY, `p-4`))}
448
466
  >
449
467
  {/* Centre and constrain rail content (like the non-rail
450
- layout). Fill-height tabs keep the full width. */}
468
+ layout). Fill-height tabs are full-bleed a canvas, a
469
+ map or a two-pane browser wants the width — unless the
470
+ tab asks for the reading column with `constrainWidth`. */}
451
471
  <div
452
472
  className={cn(
453
473
  activeFillHeight ? `flex min-h-0 w-full flex-1 flex-col` : `mx-auto w-full max-w-6xl`,
474
+ activeFillHeight && activeConstrainWidth && `mx-auto max-w-6xl`,
454
475
  )}
455
476
  >
456
477
  {header}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@/components";
4
- import { useSharedContext } from "@/contexts";
4
+ import { ActionBarProvider, useSharedContext } from "@/contexts";
5
5
  import { ModuleWithPermissions } from "@/permissions";
6
6
  import { cn, useIsMobile } from "@/utils";
7
7
  import { InfoIcon } from "lucide-react";
@@ -82,8 +82,11 @@ export function RoundPageContainerTitle({
82
82
  )}
83
83
  </div>
84
84
  {title.actionBar && (
85
- <div data-testid="round-page-action-bar" className="flex w-full items-center border-t px-4 py-2">
86
- {title.actionBar}
85
+ <div data-testid="round-page-action-bar" className="flex w-full items-center gap-x-2 border-t px-4 py-2">
86
+ {/* The bar is the page's command row, so the commands inside it label
87
+ themselves ("Edit", "Delete") instead of rendering the bare glyph
88
+ they use in a table row. See ActionBarContext. */}
89
+ <ActionBarProvider value={true}>{title.actionBar}</ActionBarProvider>
87
90
  </div>
88
91
  )}
89
92
  </div>
@@ -37,6 +37,16 @@ export type Tab = {
37
37
  * because it doesn't own the surrounding scroll container.
38
38
  */
39
39
  fillHeight?: boolean;
40
+ /**
41
+ * Keep the tab's content in the centred reading column (`max-w-6xl`) under
42
+ * `layout="rail"` even when `fillHeight` is set.
43
+ *
44
+ * A rail's `fillHeight` tab is full-bleed by default, which is what a
45
+ * two-pane browser, a canvas or a map wants. Prose does not: an editor
46
+ * stretched across a wide screen is unreadable. Non-fill-height tabs are
47
+ * already constrained, so this flag only matters alongside `fillHeight`.
48
+ */
49
+ constrainWidth?: boolean;
40
50
  };
41
51
 
42
52
  type TabsContainerProps = {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { LoaderCircleIcon, Trash2Icon } from "lucide-react";
4
4
  import { isValidElement, ReactNode, useState } from "react";
5
+ import { useIsInActionBar } from "../../contexts/ActionBarContext";
5
6
  import { useI18nRouter, useI18nTranslations } from "../../i18n";
6
7
  import {
7
8
  AlertDialog,
@@ -28,6 +29,12 @@ type CommonDeleterProps = {
28
29
  testId?: string;
29
30
  /** Custom trigger element replacing the default trash button. */
30
31
  trigger?: ReactNode;
32
+ /**
33
+ * Force the default trigger to show / hide its text label. Left unset it
34
+ * follows `useIsInActionBar()`: labelled in a page action bar, glyph-only in
35
+ * a table row or a sheet header, where there is no room for the word.
36
+ */
37
+ showLabel?: boolean;
31
38
  onSuccess?: () => void | Promise<void>;
32
39
  /** Fired after a successful delete, in addition to redirect/onSuccess. */
33
40
  propagateChanges?: () => void;
@@ -43,10 +50,15 @@ export function CommonDeleter({
43
50
  forceShow,
44
51
  testId,
45
52
  trigger,
53
+ showLabel,
46
54
  onSuccess,
47
55
  propagateChanges,
48
56
  }: CommonDeleterProps) {
49
57
  const t = useI18nTranslations();
58
+ // Called unconditionally: `showLabel ?? useIsInActionBar()` would skip the
59
+ // hook whenever the prop is set, breaking the rules of hooks.
60
+ const inActionBar = useIsInActionBar();
61
+ const isLabelled = showLabel ?? inActionBar;
50
62
  const router = useI18nRouter();
51
63
  const [open, setOpen] = useState<boolean>(forceShow || false);
52
64
  const [isDeleting, setIsDeleting] = useState<boolean>(false);
@@ -80,11 +92,12 @@ export function CommonDeleter({
80
92
  render={<div />}
81
93
  nativeButton={false}
82
94
  size="sm"
83
- variant={"ghost"}
95
+ variant={isLabelled ? "outline" : "ghost"}
84
96
  className="text-muted-foreground hover:text-destructive"
85
97
  data-testid={testId}
86
98
  >
87
99
  <Trash2Icon />
100
+ {isLabelled && t(`ui.buttons.delete`)}
88
101
  </Button>
89
102
  )}
90
103
  </AlertDialogTrigger>
@@ -4,6 +4,7 @@ import { useTranslations } from "next-intl";
4
4
  import { isValidElement, ReactElement, ReactNode, useCallback, useEffect, useRef } from "react";
5
5
  import { FieldValues, UseFormReturn } from "react-hook-form";
6
6
  import { PencilIcon } from "lucide-react";
7
+ import { ActionBarProvider, useIsInActionBar } from "../../contexts/ActionBarContext";
7
8
  import { ModuleWithPermissions } from "../../permissions/types";
8
9
  import { usePageUrlGenerator } from "../../hooks/usePageUrlGenerator";
9
10
  import {
@@ -109,6 +110,13 @@ export type EditorSheetProps<T extends FieldValues> = {
109
110
  centerButtons?: ReactNode;
110
111
 
111
112
  trigger?: ReactNode;
113
+ /**
114
+ * Force the default edit trigger to show / hide its text label. Left unset it
115
+ * follows `useIsInActionBar()`: labelled in a page action bar, glyph-only in
116
+ * a table row or an inline cell, where there is no room for the word. The
117
+ * create trigger is always labelled.
118
+ */
119
+ showLabel?: boolean;
112
120
  forceShow?: boolean;
113
121
  onClose?: () => void;
114
122
  dialogOpen?: boolean;
@@ -154,6 +162,7 @@ export function EditorSheet<T extends FieldValues>({
154
162
  hideSubmit,
155
163
  centerButtons,
156
164
  trigger,
165
+ showLabel,
157
166
  forceShow,
158
167
  onClose,
159
168
  dialogOpen,
@@ -164,6 +173,8 @@ export function EditorSheet<T extends FieldValues>({
164
173
  }: EditorSheetProps<T>) {
165
174
  const t = useTranslations();
166
175
  const generateUrl = usePageUrlGenerator();
176
+ const inActionBar = useIsInActionBar();
177
+ const isLabelled = showLabel ?? inActionBar;
167
178
 
168
179
  const defaultIsFormDirty = useCallback(() => {
169
180
  return Object.keys(form.formState.dirtyFields).length > 0;
@@ -288,10 +299,11 @@ export function EditorSheet<T extends FieldValues>({
288
299
  render={<div />}
289
300
  nativeButton={false}
290
301
  size="sm"
291
- variant="ghost"
302
+ variant={isLabelled ? "outline" : "ghost"}
292
303
  className="text-muted-foreground"
293
304
  >
294
305
  <PencilIcon />
306
+ {isLabelled && t("ui.buttons.edit")}
295
307
  </Button>
296
308
  ) : (
297
309
  <Button render={<div />} nativeButton={false} size="sm" variant="outline">
@@ -302,53 +314,59 @@ export function EditorSheet<T extends FieldValues>({
302
314
  />
303
315
  ))}
304
316
  <SheetContent side="end" className={sizeClasses[size]}>
305
- <SheetHeader className="border-b px-6 py-4">
306
- {actions ? (
307
- // pe-10 clears the SheetContent close button, which sits at the
308
- // inline end of the sheet (absolute top-4 end-4).
309
- <div className="flex items-start justify-between gap-x-4 pe-10">
310
- <div className="flex min-w-0 flex-col gap-y-1.5">
317
+ {/* The sheet's own content is NOT the action bar, even when the
318
+ trigger that opened it lives there — context flows through the
319
+ portal, so reset it or a CommonDeleter passed as `actions` would
320
+ inherit the bar's labelled trigger. */}
321
+ <ActionBarProvider value={false}>
322
+ <SheetHeader className="border-b px-6 py-4">
323
+ {actions ? (
324
+ // pe-10 clears the SheetContent close button, which sits at the
325
+ // inline end of the sheet (absolute top-4 end-4).
326
+ <div className="flex items-start justify-between gap-x-4 pe-10">
327
+ <div className="flex min-w-0 flex-col gap-y-1.5">
328
+ <SheetTitle>{headerTitle}</SheetTitle>
329
+ <SheetDescription>{headerDescription}</SheetDescription>
330
+ </div>
331
+ <div className="flex shrink-0 items-center gap-x-2">{actions}</div>
332
+ </div>
333
+ ) : (
334
+ <>
311
335
  <SheetTitle>{headerTitle}</SheetTitle>
312
336
  <SheetDescription>{headerDescription}</SheetDescription>
313
- </div>
314
- <div className="flex shrink-0 items-center gap-x-2">{actions}</div>
315
- </div>
316
- ) : (
317
- <>
318
- <SheetTitle>{headerTitle}</SheetTitle>
319
- <SheetDescription>{headerDescription}</SheetDescription>
320
- </>
321
- )}
322
- </SheetHeader>
323
- <Form {...form}>
324
- <form
325
- onSubmit={(e) => {
326
- // The Sheet content is portaled, but React synthetic events still
327
- // bubble up the React tree — so without stopPropagation an inner
328
- // EditorSheet's submit also triggers the outer form's submit
329
- // (e.g. a create dialog opened from within another editor).
330
- e.stopPropagation();
331
- return form.handleSubmit(wrappedOnSubmit)(e);
332
- }}
333
- className="flex min-h-0 flex-1 flex-col"
334
- >
335
- <div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
336
- <SheetFooter className="shrink-0 border-t px-6 py-4">
337
- {renderFooter ? (
338
- renderFooter({ form, isEdit, setOpen: handleOpenChange, closeWithoutConfirm: setOpen })
339
- ) : (
340
- <CommonEditorButtons
341
- form={form}
342
- setOpen={handleOpenChange}
343
- isEdit={isEdit}
344
- disabled={disabled}
345
- hideSubmit={hideSubmit}
346
- centerButtons={centerButtons}
347
- />
348
- )}
349
- </SheetFooter>
350
- </form>
351
- </Form>
337
+ </>
338
+ )}
339
+ </SheetHeader>
340
+ <Form {...form}>
341
+ <form
342
+ onSubmit={(e) => {
343
+ // The Sheet content is portaled, but React synthetic events still
344
+ // bubble up the React tree — so without stopPropagation an inner
345
+ // EditorSheet's submit also triggers the outer form's submit
346
+ // (e.g. a create dialog opened from within another editor).
347
+ e.stopPropagation();
348
+ return form.handleSubmit(wrappedOnSubmit)(e);
349
+ }}
350
+ className="flex min-h-0 flex-1 flex-col"
351
+ >
352
+ <div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
353
+ <SheetFooter className="shrink-0 border-t px-6 py-4">
354
+ {renderFooter ? (
355
+ renderFooter({ form, isEdit, setOpen: handleOpenChange, closeWithoutConfirm: setOpen })
356
+ ) : (
357
+ <CommonEditorButtons
358
+ form={form}
359
+ setOpen={handleOpenChange}
360
+ isEdit={isEdit}
361
+ disabled={disabled}
362
+ hideSubmit={hideSubmit}
363
+ centerButtons={centerButtons}
364
+ />
365
+ )}
366
+ </SheetFooter>
367
+ </form>
368
+ </Form>
369
+ </ActionBarProvider>
352
370
  </SheetContent>
353
371
  </Sheet>
354
372
  <CommonEditorDiscardDialog {...discardDialogProps} />
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { createContext, ReactNode, useContext } from "react";
4
+
5
+ /**
6
+ * True while rendering inside the page action bar (`title.actionBar`, drawn by
7
+ * RoundPageContainerTitle).
8
+ *
9
+ * The action bar is a full-width row of page-level commands, so its buttons
10
+ * carry a visible label — "Edit", "Delete" — instead of the bare glyph the same
11
+ * components use in a table row or a sheet header, where horizontal space is
12
+ * scarce. Commands read the flag through `useIsInActionBar()` rather than
13
+ * taking a prop, so an app only has to populate `title.actionBar`: everything
14
+ * it puts there labels itself, and nothing has to be threaded through the
15
+ * app's own wrapper components.
16
+ */
17
+ const ActionBarContext = createContext<boolean>(false);
18
+
19
+ export const ActionBarProvider = ({ value, children }: { value: boolean; children: ReactNode }) => (
20
+ <ActionBarContext.Provider value={value}>{children}</ActionBarContext.Provider>
21
+ );
22
+
23
+ export const useIsInActionBar = (): boolean => useContext(ActionBarContext);
@@ -7,6 +7,7 @@ export * from "../features/user/contexts";
7
7
  export * from "../features/how-to/contexts/HowToContext";
8
8
  export * from "../features/rbac/contexts/RbacContext";
9
9
  export * from "../features/assistant/contexts/AssistantContext";
10
+ export * from "./ActionBarContext";
10
11
  export * from "./CommonContext";
11
12
  export * from "./DirectionContext";
12
13
  export * from "./HeaderChildrenContext";
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { getCookie } from "cookies-next";
3
4
  import { useAtom } from "jotai";
4
5
  import { atomWithStorage } from "jotai/utils";
5
6
  import { usePathname } from "next/navigation";
@@ -196,6 +197,29 @@ export const CurrentUserProvider = ({ children }: { children: React.ReactNode })
196
197
  const refreshUserRef = useRef(refreshUser);
197
198
  refreshUserRef.current = refreshUser;
198
199
 
200
+ // Reconcile the local cache with the session on mount.
201
+ //
202
+ // `userAtom` is a localStorage CACHE of the user; the session itself is the
203
+ // token cookie. The two can disagree — an iOS home-screen web app shares
204
+ // Safari's cookies but starts with an empty localStorage — and nothing used
205
+ // to reconcile them: the user was written only by the login flow or by
206
+ // RefreshUser, which fires only when the proxy refreshes a token. With a
207
+ // cookie and no cached user the server rendered a logged-in page while every
208
+ // client surface (avatar, sidebar, campaign role) saw nobody.
209
+ //
210
+ // localStorage is read directly rather than through `dehydratedUser`:
211
+ // atomWithStorage reports its initial value on the first render and only
212
+ // then hydrates from storage, so the atom alone would trigger a needless
213
+ // fetch on every mount. skipCookieUpdate because the cookies are what we are
214
+ // reading FROM; rewriting them through the Server Action would reload the page.
215
+ useEffect(() => {
216
+ if (typeof window === "undefined") return;
217
+ if (window.localStorage.getItem(CURRENT_USER_STORAGE_KEY)) return;
218
+ if (!getCookie("token")) return;
219
+ void refreshUserRef.current({ skipCookieUpdate: true });
220
+ // Mount-only: this is a one-shot reconciliation, not a subscription.
221
+ }, []);
222
+
199
223
  // Track refresh in progress to prevent duplicate API calls
200
224
  const isRefreshingRef = useRef(false);
201
225
 
@@ -118,6 +118,46 @@ describe("CurrentUserProvider", () => {
118
118
  expect(localStorage.getItem("user")).toBe(JSON.stringify(storedUser));
119
119
  });
120
120
 
121
+ it("loads the user from the session when a token cookie exists but localStorage is empty", async () => {
122
+ // An iOS home-screen web app shares Safari's cookies but starts with an
123
+ // empty localStorage: the server renders a logged-in page while the client
124
+ // has no user. The provider must reconcile from the cookie session.
125
+ vi.mocked(getCookie).mockImplementation((name: any) => (name === "token" ? "jwt" : undefined));
126
+ const dehydrated = { id: "user-from-session", roles: [], company: null, modules: [] };
127
+ vi.mocked(UserService.findFullUser).mockResolvedValue({ ...dehydrated, dehydrate: () => dehydrated } as any);
128
+ const updateToken = vi.fn().mockResolvedValue(undefined);
129
+ vi.mocked(getTokenHandler).mockReturnValue({ updateToken, removeToken: vi.fn() } as any);
130
+
131
+ const { getByTestId } = render(
132
+ <CurrentUserProvider>
133
+ <Consumer />
134
+ </CurrentUserProvider>,
135
+ );
136
+
137
+ await new Promise((resolve) => setTimeout(resolve, 100));
138
+
139
+ expect(UserService.findFullUser).toHaveBeenCalledTimes(1);
140
+ expect(getByTestId("current-user-id").textContent).toBe("user-from-session");
141
+ // The cookies are what we read FROM; rewriting them would reload the page.
142
+ expect(updateToken).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("does not hit the API when localStorage already holds the user", async () => {
146
+ vi.mocked(getCookie).mockImplementation((name: any) => (name === "token" ? "jwt" : undefined));
147
+ localStorage.setItem("user", JSON.stringify({ id: "cached", roles: [], company: null, modules: [] }));
148
+
149
+ const { getByTestId } = render(
150
+ <CurrentUserProvider>
151
+ <Consumer />
152
+ </CurrentUserProvider>,
153
+ );
154
+
155
+ await new Promise((resolve) => setTimeout(resolve, 100));
156
+
157
+ expect(UserService.findFullUser).not.toHaveBeenCalled();
158
+ expect(getByTestId("current-user-id").textContent).toBe("cached");
159
+ });
160
+
121
161
  it("awaits updateToken before writing the user atom in refreshUser", async () => {
122
162
  const oldUser = { id: "old", roles: [], company: null, modules: [] };
123
163
  localStorage.setItem("user", JSON.stringify(oldUser));
@@ -8,12 +8,21 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
8
8
  import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
9
9
  import { SearchIcon, CheckIcon } from "lucide-react";
10
10
 
11
+ /**
12
+ * Inside a dialog the Command must NOT paint `bg-popover`: `DialogContent` is
13
+ * `bg-background`, and the two tokens are only identical in the light theme
14
+ * (both white). In dark they differ — `--background` oklch(0.148) vs
15
+ * `--popover` oklch(0.218) — so the command block renders as a lighter panel
16
+ * seamed against the dialog's own header. Inheriting the dialog surface keeps a
17
+ * command dialog one colour in both themes.
18
+ */
11
19
  function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
12
20
  return (
13
21
  <CommandPrimitive
14
22
  data-slot="command"
15
23
  className={cn(
16
24
  "bg-popover text-popover-foreground rounded-xl p-1 flex size-full flex-col overflow-hidden",
25
+ "[[data-slot=dialog-content]_&]:bg-transparent [[data-slot=dialog-content]_&]:text-foreground",
17
26
  className,
18
27
  )}
19
28
  {...props}