@anchrd/intel-ui 0.22.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.22.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.15.0",
36
+ "@anchrd/intel-contract": "^0.17.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -0,0 +1,282 @@
1
+ import type { ResourceGrant } from "@anchrd/intel-contract/share";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useId, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { AppLogo } from "@/branding/branding.tsx";
6
+ import {
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipProvider,
10
+ TooltipTrigger,
11
+ } from "@/components/ui/tooltip.tsx";
12
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
+ import { useIntelRouterContext } from "@/router/router-context.ts";
14
+ import { useUserName } from "@/user-name/user-name.ts";
15
+
16
+ type Principal = ResourceGrant["principal"];
17
+
18
+ type GrantGroup = {
19
+ key: string;
20
+ principal: Principal;
21
+ grants: ResourceGrant[];
22
+ owner: boolean;
23
+ };
24
+
25
+ function principalKey(principal: Principal): string {
26
+ if (principal.type === "organization") return "organization";
27
+ if (principal.type === "email") return `email:${principal.email.toLowerCase()}`;
28
+ return `user:${principal.id}`;
29
+ }
30
+
31
+ function groupGrants(grants: ResourceGrant[], ownerIds: string[] = []): GrantGroup[] {
32
+ const grouped = new Map<string, GrantGroup>();
33
+ for (const ownerId of ownerIds) {
34
+ grouped.set(`user:${ownerId}`, {
35
+ key: `user:${ownerId}`,
36
+ principal: { type: "user", id: ownerId },
37
+ grants: [],
38
+ owner: true,
39
+ });
40
+ }
41
+ for (const grant of grants) {
42
+ const key = principalKey(grant.principal);
43
+ const group = grouped.get(key);
44
+ if (group) group.grants.push(grant);
45
+ else grouped.set(key, { key, principal: grant.principal, grants: [grant], owner: false });
46
+ }
47
+ return [...grouped.values()];
48
+ }
49
+
50
+ function initials(value: string): string {
51
+ const parts = value
52
+ .replace(/@.*$/, "")
53
+ .split(/[\s._+-]+/)
54
+ .filter(Boolean);
55
+ if (parts.length === 0) return "?";
56
+ if (parts.length === 1) return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
57
+ return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
58
+ }
59
+
60
+ function usePrincipalLabel(principal: Principal): string {
61
+ const i18n = useI18n();
62
+ const userName = useUserName(principal.type === "user" ? principal.id : null);
63
+ if (principal.type === "organization") return i18n.t("node.organization");
64
+ if (principal.type === "email") return principal.email;
65
+ return userName ?? i18n.t("node.someUser");
66
+ }
67
+
68
+ const circleTones = [
69
+ "bg-muted text-muted-foreground",
70
+ "bg-accent text-accent-foreground",
71
+ "bg-secondary text-secondary-foreground",
72
+ ] as const;
73
+
74
+ function AccessCircle({
75
+ group,
76
+ position,
77
+ count,
78
+ expanded,
79
+ controls,
80
+ activate,
81
+ }: {
82
+ group: GrantGroup;
83
+ position: number;
84
+ count: number;
85
+ expanded: boolean;
86
+ controls: string;
87
+ activate(element: HTMLButtonElement): void;
88
+ }) {
89
+ const i18n = useI18n();
90
+ const label = usePrincipalLabel(group.principal);
91
+ const circle = (
92
+ <button
93
+ type="button"
94
+ aria-label={position === 0 ? `${i18n.t("node.accessDetails", { count })}: ${label}` : label}
95
+ aria-expanded={expanded}
96
+ aria-controls={controls}
97
+ data-organization-access={group.principal.type === "organization" ? "" : undefined}
98
+ onClick={(event) => activate(event.currentTarget)}
99
+ className={`relative flex size-7 items-center justify-center rounded-full text-xs font-medium ring-2 ring-background outline-none transition hover:z-10 hover:scale-105 focus-visible:z-10 focus-visible:ring-ring ${circleTones[position % circleTones.length]}`}
100
+ >
101
+ {group.principal.type === "organization" ? (
102
+ <AppLogo className="size-4 rounded-none bg-transparent text-current [&_svg]:size-3" />
103
+ ) : (
104
+ initials(label)
105
+ )}
106
+ </button>
107
+ );
108
+ return (
109
+ <TooltipProvider delayDuration={300}>
110
+ <Tooltip>
111
+ <TooltipTrigger asChild>{circle}</TooltipTrigger>
112
+ <TooltipContent>
113
+ {group.principal.type === "organization" ? i18n.t("node.organizationAccess") : label}
114
+ </TooltipContent>
115
+ </Tooltip>
116
+ </TooltipProvider>
117
+ );
118
+ }
119
+
120
+ function AccessDetail({
121
+ group,
122
+ onRevoke,
123
+ }: {
124
+ group: GrantGroup;
125
+ onRevoke?(grantId: string): void;
126
+ }) {
127
+ const i18n = useI18n();
128
+ const label = usePrincipalLabel(group.principal);
129
+ return (
130
+ <li className="flex items-start justify-between gap-3 text-sm">
131
+ <span className="min-w-0">
132
+ <span className="block truncate font-medium">{label}</span>
133
+ <span className="text-xs text-muted-foreground">
134
+ {[
135
+ ...(group.owner ? [i18n.t("node.owner")] : []),
136
+ ...group.grants.map((grant) => i18n.t(`node.verb.${grant.verb}`)),
137
+ ].join(", ")}
138
+ </span>
139
+ </span>
140
+ {onRevoke ? (
141
+ <span className="flex flex-wrap justify-end gap-1">
142
+ {group.grants.map((grant) => (
143
+ <button
144
+ key={grant.id}
145
+ type="button"
146
+ onClick={() => onRevoke(grant.id)}
147
+ aria-label={i18n.t("node.revokeVerbFor", {
148
+ verb: i18n.t(`node.verb.${grant.verb}`),
149
+ recipient: label,
150
+ })}
151
+ className="rounded px-2 py-1 text-xs text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
152
+ >
153
+ {i18n.t("node.revokeVerb", { verb: i18n.t(`node.verb.${grant.verb}`) })}
154
+ </button>
155
+ ))}
156
+ </span>
157
+ ) : null}
158
+ </li>
159
+ );
160
+ }
161
+
162
+ export function AccessSummary({
163
+ grants,
164
+ ownerId,
165
+ ownerIds,
166
+ onRevoke,
167
+ }: {
168
+ grants: ResourceGrant[];
169
+ ownerId?: string;
170
+ ownerIds?: string[];
171
+ onRevoke?(grantId: string): void;
172
+ }) {
173
+ const i18n = useI18n();
174
+ const groups = groupGrants(grants, ownerIds ?? (ownerId ? [ownerId] : []));
175
+ const [open, setOpen] = useState(false);
176
+ const [position, setPosition] = useState({ top: 8, left: 8 });
177
+ const detailsId = useId();
178
+ const organizationDescriptionId = useId();
179
+ if (groups.length === 0) return null;
180
+ const visible = groups.slice(0, 3);
181
+ const overflow = groups.length - visible.length;
182
+ const toggle = (element: HTMLButtonElement) => {
183
+ setOpen((current) => {
184
+ if (!current) {
185
+ const rect = element.getBoundingClientRect();
186
+ const panelHeight = Math.min(320, window.innerHeight - 16);
187
+ const below = window.innerHeight - rect.bottom;
188
+ setPosition({
189
+ top: below >= panelHeight ? rect.bottom + 4 : Math.max(8, rect.top - panelHeight - 4),
190
+ left: Math.max(8, Math.min(rect.left, window.innerWidth - 328)),
191
+ });
192
+ }
193
+ return !current;
194
+ });
195
+ };
196
+
197
+ return (
198
+ <div className="relative flex w-fit items-center -space-x-2" data-access-summary="">
199
+ {visible.map((group, position) => (
200
+ <AccessCircle
201
+ key={group.key}
202
+ group={group}
203
+ position={position}
204
+ count={groups.length}
205
+ expanded={open}
206
+ controls={detailsId}
207
+ activate={toggle}
208
+ />
209
+ ))}
210
+ {overflow > 0 ? (
211
+ <button
212
+ type="button"
213
+ aria-label={i18n.t("node.accessDetails", { count: groups.length })}
214
+ aria-expanded={open}
215
+ aria-controls={detailsId}
216
+ onClick={(event) => toggle(event.currentTarget)}
217
+ className="relative flex size-7 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground ring-2 ring-background outline-none transition hover:z-10 hover:scale-105 focus-visible:z-10 focus-visible:ring-ring"
218
+ >
219
+ +{overflow}
220
+ </button>
221
+ ) : null}
222
+ {groups.some((group) => group.principal.type === "organization") ? (
223
+ <span id={organizationDescriptionId} className="sr-only">
224
+ {i18n.t("node.organizationAccess")}
225
+ </span>
226
+ ) : null}
227
+ {open
228
+ ? createPortal(
229
+ <div
230
+ id={detailsId}
231
+ data-access-details=""
232
+ className="fixed z-50 max-h-[calc(100dvh-1rem)] w-80 overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md"
233
+ style={position}
234
+ >
235
+ <ul className="space-y-3">
236
+ {groups.map((group) => (
237
+ <AccessDetail key={group.key} group={group} {...(onRevoke ? { onRevoke } : {})} />
238
+ ))}
239
+ </ul>
240
+ </div>,
241
+ document.querySelector('[role="dialog"]') ?? document.body,
242
+ )
243
+ : null}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * The compact access view for a resource the reader can manage.
250
+ *
251
+ * `node_grant_list` is itself authorized: a reader who may see the node but may not inspect its
252
+ * grants gets no names here. Treating that refusal as an empty summary is deliberate; rendering an
253
+ * error or retry would disclose that there are grants to ask about (#484).
254
+ */
255
+ export function ResourceAccessSummary({
256
+ resourceId,
257
+ ownerId,
258
+ }: {
259
+ resourceId: string;
260
+ ownerId: string;
261
+ }) {
262
+ const { data } = useIntelRouterContext();
263
+ const grants = useQuery({
264
+ queryKey: ["effective-node-grants", resourceId],
265
+ queryFn: () => data.listEffectiveAccess(resourceId),
266
+ retry: false,
267
+ // Time cannot change this answer locally. Share mutations invalidate the whole effective prefix
268
+ // because changing one folder also changes every descendant summary already on screen.
269
+ staleTime: Number.POSITIVE_INFINITY,
270
+ });
271
+ if (!grants.data) return null;
272
+ return (
273
+ <AccessSummary
274
+ grants={grants.data.items}
275
+ ownerIds={
276
+ grants.data.ownerIds.includes(ownerId)
277
+ ? grants.data.ownerIds
278
+ : [ownerId, ...grants.data.ownerIds]
279
+ }
280
+ />
281
+ );
282
+ }
@@ -487,7 +487,16 @@ export function AppTree() {
487
487
  onClick={() => toggle(level, !isOpen)}
488
488
  // The icons inside a coloured row need a step of their own, or their hover would be
489
489
  // the row's colour on the row's colour and read as nothing at all.
490
- className="shrink-0 rounded-md p-1 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring"
490
+ //
491
+ // ⚠️ A square of exactly `TREE_GUTTER` with the icon CENTRED in it (#453). It used to
492
+ // be `p-1` around a 14px icon — 22px wide, so the arrow's centre sat at 11px while the
493
+ // line of the level below started at 14px: the line ran PAST the arrow instead of
494
+ // under it. On the fourth level that line is the only thing left saying whose content
495
+ // one is reading, and missing its arrow it reads as a separation instead of a
496
+ // belonging. Centred, the hit area also gains the air on the left it never had, and it
497
+ // gets bigger rather than smaller — an arrow that sits well but is harder to hit is no
498
+ // improvement, on touch least of all.
499
+ className={`${TREE_GUTTER} flex items-center justify-center rounded-md text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring`}
491
500
  >
492
501
  <ChevronRight
493
502
  aria-hidden="true"
@@ -495,7 +504,9 @@ export function AppTree() {
495
504
  />
496
505
  </button>
497
506
  ) : (
498
- <span aria-hidden="true" className="size-5 shrink-0" />
507
+ // ⚠️ The SAME width as the button, not one step less: a row without an arrow used to sit
508
+ // 2px left of one with an arrow, and nothing said why.
509
+ <span aria-hidden="true" className={TREE_GUTTER} />
499
510
  )}
500
511
  {/* The row's own control is what is grabbed and what is dropped on: a real button, so the
501
512
  gesture rides on something that is already focusable and named rather than on a bare
@@ -555,7 +566,7 @@ export function AppTree() {
555
566
  under — indentation says whose contents these are to anyone who can see it, and this is
556
567
  the same sentence for anyone who cannot. */}
557
568
  {isOpen ? (
558
- <div className="ml-3.5 border-l border-sidebar-border pl-1.5">
569
+ <div className={TREE_LINE}>
559
570
  {renderLevel(
560
571
  level,
561
572
  new Set([...ancestors, entry.id]),
@@ -616,11 +627,14 @@ export function AppTree() {
616
627
  which is exactly when somebody needs to be told where to start. */}
617
628
  {root?.isPending ? null : (
618
629
  <div className="mt-1 flex items-center gap-0.5">
619
- {/* The gutter the disclosure arrows occupy, so the plus lines up with the row icons
620
- above it rather than half a step to their left. */}
621
- <span aria-hidden="true" className="size-5 shrink-0" />
630
+ {/* ⚠️ No gutter here any more (#453). It was meant to line the plus up with the row ICONS
631
+ above it and the price was that the row stood half a step right of everything
632
+ belonging to the top level, so it read as a child of the list while it means the list
633
+ itself. It begins on the same left edge as the arrows of the top level, which is the
634
+ edge that says "this level". */}
622
635
  <AddMenu
623
636
  label={i18n.t("tree.addRoot")}
637
+ description={i18n.t("tree.addRoot.long")}
624
638
  variant="row"
625
639
  onSelect={(kind) => {
626
640
  if (kind === "upload") startUpload(null);
@@ -657,6 +671,20 @@ export function AppTree() {
657
671
  );
658
672
  }
659
673
 
674
+ // ── The geometry of the tree: two measurements that belong together (#453) ───────────────────────
675
+ //
676
+ // The disclosure arrow sits in a 24px SQUARE, and the vertical line of an open level starts at 12px
677
+ // — the centre column of that square. The two used to be arrived at independently (`p-1` around a
678
+ // 14px icon = 22px, line at `ml-3.5` = 14px), which is why the line ran BESIDE its arrow rather than
679
+ // underneath it.
680
+ //
681
+ // ⚠️ The result is no small matter: on the fourth level the line is the only thing left saying whose
682
+ // content you are reading. If it misses its arrow, it reads as a separation instead of a belonging.
683
+ //
684
+ // That is why the two stand HERE side by side: whoever changes one sees the other.
685
+ const TREE_GUTTER = "size-6 shrink-0";
686
+ const TREE_LINE = "ml-3 border-l border-sidebar-border pl-2";
687
+
660
688
  // ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
661
689
  // having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
662
690
  //
@@ -666,8 +694,19 @@ export function AppTree() {
666
694
  // ⚠️ The two variants do not offer the same things, and the type says so rather than a comment: only
667
695
  // the root can emit an import (#346), because only the root has no three-dot menu to hold one. A
668
696
  // single wide signature would leave the folder row with a branch nothing can reach.
697
+ //
698
+ // ⚠️ In the `row` form the visible text and the accessible name are NOT the same string (#453). The
699
+ // sentence "Add at the top level" was cut to "Add at the top le…" in a 240px sidebar —
700
+ // a truncated sentence says less than a short word. The word is what one reads; the sentence stays
701
+ // as the name, because this row is the only way to start a tree and the only thing on screen when
702
+ // the tree is empty. Shorter must not mean that nobody is told where to begin.
669
703
  type AddMenuProps =
670
- | { label: string; variant: "row"; onSelect(kind: NewKind | "upload" | ImportKind): void }
704
+ | {
705
+ label: string;
706
+ description: string;
707
+ variant: "row";
708
+ onSelect(kind: NewKind | "upload" | ImportKind): void;
709
+ }
671
710
  | { label: string; variant?: "icon"; onSelect(kind: NewKind | "upload"): void };
672
711
 
673
712
  function AddMenu(props: AddMenuProps) {
@@ -687,7 +726,7 @@ function AddMenu(props: AddMenuProps) {
687
726
  return (
688
727
  <DropdownMenu>
689
728
  <DropdownMenuTrigger
690
- {...(variant === "row" ? {} : { "aria-label": label })}
729
+ aria-label={props.variant === "row" ? props.description : label}
691
730
  className={
692
731
  variant === "row"
693
732
  ? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
package/src/app/app.tsx CHANGED
@@ -1,32 +1,19 @@
1
- import { Link, Outlet, useRouterState } from "@tanstack/react-router";
1
+ import { Outlet } from "@tanstack/react-router";
2
2
  import { useRef, useState } from "react";
3
3
  import { AppSidebar } from "@/app/app-sidebar/app-sidebar.tsx";
4
4
  import { HeaderSearch } from "@/app/header-search/header-search.tsx";
5
5
  import { createSidebarPreferences } from "@/app/sidebar-preferences/sidebar-preferences.ts";
6
- import {
7
- Breadcrumb,
8
- BreadcrumbItem,
9
- BreadcrumbLink,
10
- BreadcrumbList,
11
- BreadcrumbPage,
12
- BreadcrumbSeparator,
13
- } from "@/components/ui/breadcrumb";
14
- import { Separator } from "@/components/ui/separator";
15
6
  import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
16
7
  import { useI18n } from "@/i18n/i18n-context.tsx";
17
8
 
18
- const sections = [
19
- { path: "/nodes", labelKey: "nav.nodes" },
20
- { path: "/flows", labelKey: "nav.flows" },
21
- { path: "/tools", labelKey: "nav.tools" },
22
- ] as const;
23
-
9
+ // ⚠️ The breadcrumb is gone (#337). It was two levels deep and the first of them was constant —
10
+ // `Intel > Knowledge`, `Intel > Flows`, `Intel > Tools`. The product name stands in the sidebar
11
+ // anyway, the area is marked there as the active row, and what was left was a path that never grew
12
+ // past two links and carried no navigation that did not already exist. The `Separator` beside the
13
+ // sidebar trigger went with it: it separated the trigger from the breadcrumb, and there is nothing
14
+ // left on that side to separate it from.
24
15
  export function App() {
25
16
  const i18n = useI18n();
26
- const pathname = useRouterState({ select: (state) => state.location.pathname });
27
- const current = sections.find(
28
- (section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
29
- );
30
17
 
31
18
  // Read once: the stored preference is the starting point, after which the shell's own state is
32
19
  // the truth. Re-reading on every render would fight the drag.
@@ -73,24 +60,6 @@ export function App() {
73
60
  <SidebarInset className="min-w-0 overflow-hidden md:border">
74
61
  <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
75
62
  <SidebarTrigger className="-ml-1" aria-label={i18n.t("shell.toggleSidebar")} />
76
- <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
77
- <Breadcrumb>
78
- <BreadcrumbList>
79
- <BreadcrumbItem>
80
- <BreadcrumbLink asChild>
81
- <Link to="/nodes">{i18n.t("app.name")}</Link>
82
- </BreadcrumbLink>
83
- </BreadcrumbItem>
84
- {current ? (
85
- <>
86
- <BreadcrumbSeparator />
87
- <BreadcrumbItem>
88
- <BreadcrumbPage>{i18n.t(current.labelKey)}</BreadcrumbPage>
89
- </BreadcrumbItem>
90
- </>
91
- ) : null}
92
- </BreadcrumbList>
93
- </Breadcrumb>
94
63
  {/* The bar for area-owned actions. A screen renders into the inner box through
95
64
  `ActionSlot`; search is the shell's own, because it has to open from every screen
96
65
  alike and belongs to none of them. It stays last, against the right edge, and a
@@ -16,17 +16,24 @@ import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
16
16
  import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
17
17
  import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
18
18
  import { useTheme } from "@/theme/theme-context.tsx";
19
+ import { DeviceZone, deviceZone, ZoneOptions } from "@/time/time.ts";
20
+ import { useTimeZone } from "@/time/time-context.tsx";
19
21
 
20
22
  // The settings that belong to the person rather than to the installation: language and appearance.
21
23
  // Both are client state — they live in localStorage, not in the Intel HTTP surface — so they do not
22
24
  // belong on a screen with a save button. A choice applies at once; a dialog that previews and only
23
25
  // commits on "Save" would carry two states too many for two dropdowns.
24
26
  //
25
- // ⚠️ There was a third, the reader's timezone, and it left with the Agents (#388). It was a
26
- // suggestion for a schedule and the lens its calendar was read through (#228); with both gone
27
- // nothing read the value, and a preference that changes nothing on screen is worse than none —
28
- // it invites the reader to set it and then contradicts them. Reading times in a chosen zone is a
29
- // feature, and it would come back as one.
27
+ // ⚠️ The third one, the reader's timezone, is BACK and the sentence that sent it away is the
28
+ // reason it could come back (#467). It left with the Agents (#388) because it was a suggestion for
29
+ // a schedule and the lens its calendar was read through (#228); with both gone nothing read the
30
+ // value, "and a preference that changes nothing on screen is worse than none it invites the
31
+ // reader to set it and then contradicts them. Reading times in a chosen zone is a feature, and it
32
+ // would come back as one."
33
+ //
34
+ // It came back as one, and in that order: FIRST every time on screen reads `useDateTime()`, THEN
35
+ // this dropdown exists. Whoever adds a surface that shows a time reads that hook — a second
36
+ // `toLocaleString` would be the old state again, only harder to find.
30
37
  //
31
38
  // The dialog is opened from outside (the user footer holds the state), because its trigger is a
32
39
  // DropdownMenuItem: Radix unmounts the menu content when an item is chosen and would take a dialog
@@ -40,6 +47,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
40
47
  const i18n = useI18n();
41
48
  const language = useLanguage();
42
49
  const theme = useTheme();
50
+ const zone = useTimeZone();
43
51
 
44
52
  return (
45
53
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -95,6 +103,31 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
95
103
  </Select>
96
104
  </div>
97
105
  ) : null}
106
+ {zone ? (
107
+ <div className="grid gap-2">
108
+ <label className="text-sm font-medium" htmlFor="settings-timezone">
109
+ {i18n.t("settings.timezone")}
110
+ </label>
111
+ <Select value={zone.current} onValueChange={zone.select}>
112
+ <SelectTrigger id="settings-timezone" className="w-full">
113
+ <SelectValue />
114
+ </SelectTrigger>
115
+ <SelectContent>
116
+ {/* The default is the device's zone and is NAMED as such — not an empty field
117
+ that would look as if nothing were set. */}
118
+ <SelectItem value={DeviceZone}>
119
+ {i18n.t("settings.timezone.device", { zone: deviceZone() })}
120
+ </SelectItem>
121
+ {ZoneOptions.map((option) => (
122
+ <SelectItem key={option} value={option}>
123
+ {option}
124
+ </SelectItem>
125
+ ))}
126
+ </SelectContent>
127
+ </Select>
128
+ <p className="text-xs text-muted-foreground">{i18n.t("settings.timezone.hint")}</p>
129
+ </div>
130
+ ) : null}
98
131
  </div>
99
132
  </DialogContent>
100
133
  </Dialog>
@@ -14,6 +14,7 @@ import {
14
14
  DropdownMenu,
15
15
  DropdownMenuContent,
16
16
  DropdownMenuItem,
17
+ DropdownMenuLabel,
17
18
  DropdownMenuSeparator,
18
19
  DropdownMenuTrigger,
19
20
  } from "@/components/ui/dropdown-menu";
@@ -89,6 +90,25 @@ export function UserFooter() {
89
90
  align="start"
90
91
  className="w-(--radix-dropdown-menu-trigger-width) min-w-56"
91
92
  >
93
+ {/* #452: the head — who is signed in, above the things they can do. It used to stand
94
+ ONLY in the trigger BELOW the menu, so an open menu pointed at the answer instead of
95
+ giving it. Not a menu item: it is a reading, not an action, so it takes no focus and
96
+ nothing happens when it is pressed. */}
97
+ <DropdownMenuLabel className="font-normal">
98
+ <div className="grid text-left text-sm leading-tight">
99
+ <span className="truncate font-medium">{primary}</span>
100
+ {secondary ? (
101
+ <span className="truncate text-xs text-muted-foreground">{secondary}</span>
102
+ ) : null}
103
+ </div>
104
+ </DropdownMenuLabel>
105
+ {/* ⚠️ The ONE separator, and the only one left (#452). There used to be three, each with
106
+ a good reason of its own: the settings change nothing anybody else sees, the reindex
107
+ belongs to the installation rather than the person, signing out is destructive.
108
+ Together they cut five rows into five groups — and a grouping that separates every
109
+ single row groups nothing. Above it stands who you are, below it what you can do;
110
+ that is the whole distinction the line carries, and it is the same one Gate draws. */}
111
+ <DropdownMenuSeparator />
92
112
  <DropdownMenuItem onSelect={() => void navigate({ to: "/tools" })}>
93
113
  <Wrench aria-hidden="true" />
94
114
  {i18n.t("nav.tools")}
@@ -99,9 +119,7 @@ export function UserFooter() {
99
119
  <ArchiveIcon aria-hidden="true" />
100
120
  {i18n.t("archive.title")}
101
121
  </DropdownMenuItem>
102
- <DropdownMenuSeparator />
103
- {/* Language, appearance and timezone belong to the person, like everything else behind
104
- this menu — and unlike the two rows above, they change nothing anybody else sees. */}
122
+ {/* Language and appearance belong to the person, like everything else behind this menu. */}
105
123
  <DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
106
124
  <Settings aria-hidden="true" />
107
125
  {i18n.t("settings.title")}
@@ -109,18 +127,18 @@ export function UserFooter() {
109
127
  {/* ⚠️ Drawn only for an administrator, and that is a DRAWING decision — `POST
110
128
  /nodes/reindex` asks Gate for `intel/admin` itself and would refuse this row's press
111
129
  just the same (#416). Hiding it is not the check; it is not offering everybody a
112
- door they cannot open. Its own separator, because the rows above belong to the
113
- person and this one belongs to the installation. */}
130
+ door they cannot open.
131
+ ⚠️ Its own separator is GONE (#452): it set a single row apart, and that is exactly
132
+ the line this ticket removes. The reason it carried — this one belongs to the
133
+ installation — is true and still not worth a rule of its own on a five-row menu. */}
114
134
  {session.data?.isAdmin ? (
115
- <>
116
- <DropdownMenuSeparator />
117
- <DropdownMenuItem onSelect={() => setReindexOpen(true)}>
118
- <RefreshCw aria-hidden="true" />
119
- {i18n.t("reindex.title")}
120
- </DropdownMenuItem>
121
- </>
135
+ <DropdownMenuItem onSelect={() => setReindexOpen(true)}>
136
+ <RefreshCw aria-hidden="true" />
137
+ {i18n.t("reindex.title")}
138
+ </DropdownMenuItem>
122
139
  ) : null}
123
- <DropdownMenuSeparator />
140
+ {/* Signing out stays recognisable as destructive — the COLOUR does that, and it needs no
141
+ line of its own to say it twice. */}
124
142
  <DropdownMenuItem
125
143
  disabled={logout.isPending}
126
144
  onSelect={() => logout.mutate()}