@anchrd/intel-ui 0.12.0 → 0.14.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 (46) hide show
  1. package/package.json +3 -2
  2. package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
  3. package/src/agent/agent-chat/agent-chat.tsx +3 -1
  4. package/src/agent/agent-cron/agent-cron.ts +49 -113
  5. package/src/agent/agent-definition/agent-definition.ts +18 -9
  6. package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
  7. package/src/agent/agent-log/agent-log.tsx +6 -3
  8. package/src/agent/agent-profile/agent-profile.tsx +37 -12
  9. package/src/agent/agent.tsx +17 -9
  10. package/src/app/app-sidebar/app-sidebar.tsx +2 -2
  11. package/src/app/app-tree/app-tree.tsx +5 -3
  12. package/src/app/app.tsx +2 -2
  13. package/src/app/header-search/header-search.tsx +3 -1
  14. package/src/app/settings-dialog/settings-dialog.tsx +115 -0
  15. package/src/app/tree-move/tree-move.tsx +3 -1
  16. package/src/app/user-footer/user-footer.tsx +25 -2
  17. package/src/app/view-toggle/view-toggle.tsx +2 -2
  18. package/src/archive/archive.tsx +3 -1
  19. package/src/components/ui/popover.tsx +41 -0
  20. package/src/entry-picker/entry-picker.tsx +3 -1
  21. package/src/flow-runs/flow-runs.tsx +6 -3
  22. package/src/flows/flows.tsx +13 -9
  23. package/src/flows/node-palette/node-palette.tsx +2 -2
  24. package/src/folder-contents/folder-contents.tsx +3 -1
  25. package/src/graph-pane/graph-pane.tsx +2 -2
  26. package/src/i18n/de.json +406 -0
  27. package/src/i18n/en.json +20 -5
  28. package/src/i18n/es.json +406 -0
  29. package/src/i18n/i18n-context.tsx +65 -0
  30. package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  31. package/src/i18n/i18n.ts +15 -11
  32. package/src/i18n/i18n.types.ts +43 -0
  33. package/src/main.tsx +33 -12
  34. package/src/modal/modal.tsx +2 -2
  35. package/src/node-table/node-table.tsx +3 -1
  36. package/src/nodes/nodes.tsx +7 -3
  37. package/src/resource-menu/resource-menu.tsx +8 -4
  38. package/src/router/router.types.ts +3 -2
  39. package/src/save-button/save-button.tsx +3 -3
  40. package/src/sign-in-refused/sign-in-refused.tsx +3 -2
  41. package/src/theme/theme-context.tsx +89 -0
  42. package/src/theme/theme.ts +51 -21
  43. package/src/timezone/timezone-combobox/timezone-combobox.tsx +149 -0
  44. package/src/timezone/timezone-context.tsx +45 -0
  45. package/src/timezone/timezone.ts +159 -0
  46. package/src/tools/tools.tsx +3 -1
@@ -1,6 +1,49 @@
1
+ // A catalog is a flat file of dotted keys → text. Flat, because the lookup is then a map read and
2
+ // the completeness test compares two key sets instead of walking two trees. The reserved `$locale`
3
+ // key carries the file's BCP-47 locale (the sigil keeps it out of the UI key namespace), so every
4
+ // language file is self-describing and date/number formats need no second configuration.
1
5
  export type Catalog = Record<string, string>;
2
6
 
7
+ // What `intel build` writes into src/i18n/custom.json: the starting language plus every language
8
+ // file listed in the customer's intel.json. The runtime loads nothing on demand — WHICH languages
9
+ // exist is decided at build time; only which one runs is the reader's choice.
10
+ export interface CatalogBundle {
11
+ defaultLanguage: string;
12
+ catalogs: Record<string, Catalog>;
13
+ }
14
+
15
+ export interface I18nDeps {
16
+ // The active language file. Without one the UI runs on the English catalog (the default).
17
+ catalog?: Catalog;
18
+ }
19
+
3
20
  export interface I18n {
4
21
  locale: string;
5
22
  t(key: string, variables?: Record<string, string | number>): string;
6
23
  }
24
+
25
+ // The slice of the Web Storage API the preferences need — as a port, not a global, so a choice is
26
+ // testable without a DOM and localStorage is touched in exactly one place per preference.
27
+ export interface PreferenceStore {
28
+ getItem(key: string): string | null;
29
+ setItem(key: string, value: string): void;
30
+ }
31
+
32
+ export interface LanguagesDeps {
33
+ store?: PreferenceStore;
34
+ }
35
+
36
+ export interface Languages {
37
+ // Every selectable code: the built-in ones first, then whatever the customer added.
38
+ codes: string[];
39
+ // The language the app starts in: the remembered choice, otherwise the bundle's default.
40
+ initial: string;
41
+ i18n(code: string): I18n;
42
+ remember(code: string): void;
43
+ }
44
+
45
+ export interface LanguageChoice {
46
+ codes: string[];
47
+ current: string;
48
+ select(code: string): void;
49
+ }
package/src/main.tsx CHANGED
@@ -7,18 +7,30 @@ import {
7
7
  loginPath,
8
8
  } from "@/data/intel-data-provider/intel-data-provider.ts";
9
9
  import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.ts";
10
- import { createI18n } from "@/i18n/i18n.ts";
10
+ import { I18nProvider } from "@/i18n/i18n-context.tsx";
11
+ import { createLanguages } from "@/i18n/i18n-languages/i18n-languages.ts";
11
12
  import { createIntelRouter } from "@/router/router.tsx";
12
13
  import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
13
- import { SystemThemeQuery, watchSystemTheme } from "@/theme/theme.ts";
14
+ import { applyTheme, readThemeChoice, SystemThemeQuery, themeFor } from "@/theme/theme.ts";
15
+ import { ThemeProvider } from "@/theme/theme-context.tsx";
16
+ import { TimezoneProvider } from "@/timezone/timezone-context.tsx";
14
17
  import "./styles.css";
15
18
  import "./theme/custom.css";
16
19
 
17
- watchSystemTheme(document.documentElement, window.matchMedia(SystemThemeQuery));
20
+ // The first paint, BEFORE React — otherwise the light shell flashes until the provider mounts. Only
21
+ // applied here and deliberately NOT subscribed: the subscription belongs to the ThemeProvider, and
22
+ // two of them would overwrite one another.
23
+ applyTheme(
24
+ document.documentElement,
25
+ themeFor(readThemeChoice(window.localStorage), window.matchMedia(SystemThemeQuery).matches),
26
+ );
18
27
 
19
28
  const root = document.getElementById("root");
20
29
  if (!root) throw new Error("Missing #root element");
21
- const i18n = createI18n();
30
+
31
+ // Which languages exist is decided at build time and lies ready in the bundle; only which one runs
32
+ // is the reader's choice, and that choice is remembered in localStorage.
33
+ const languages = createLanguages({ store: window.localStorage });
22
34
  const signIn = createBrowserSignIn(loginPath);
23
35
  const mounted = createRoot(root);
24
36
  const queryClient = new QueryClient();
@@ -33,18 +45,27 @@ const data = createIntelDataProvider({
33
45
  paint();
34
46
  }),
35
47
  });
36
- const router = createIntelRouter({ data, i18n });
48
+ const router = createIntelRouter({ data });
37
49
 
38
50
  function paint(): void {
39
51
  mounted.render(
40
52
  <StrictMode>
41
- {refused ? (
42
- <SignInRefused i18n={i18n} />
43
- ) : (
44
- <QueryClientProvider client={queryClient}>
45
- <RouterProvider router={router} />
46
- </QueryClientProvider>
47
- )}
53
+ {/* The refusal screen sits INSIDE the providers too: it is text like any other, and it is the
54
+ one screen a reader sees when nothing else works — English-only there would be the worst
55
+ place for it. */}
56
+ <I18nProvider languages={languages}>
57
+ <ThemeProvider store={window.localStorage}>
58
+ <TimezoneProvider store={window.localStorage}>
59
+ {refused ? (
60
+ <SignInRefused />
61
+ ) : (
62
+ <QueryClientProvider client={queryClient}>
63
+ <RouterProvider router={router} />
64
+ </QueryClientProvider>
65
+ )}
66
+ </TimezoneProvider>
67
+ </ThemeProvider>
68
+ </I18nProvider>
48
69
  </StrictMode>,
49
70
  );
50
71
  }
@@ -1,7 +1,7 @@
1
1
  import { useId } from "react";
2
2
  import { Modal as AriaModal, Dialog, ModalOverlay } from "react-aria-components";
3
+ import { useI18n } from "@/i18n/i18n-context.tsx";
3
4
  import { cn } from "@/lib/utils.ts";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
5
5
 
6
6
  export function Modal({
7
7
  title,
@@ -14,7 +14,7 @@ export function Modal({
14
14
  children: React.ReactNode;
15
15
  className?: string;
16
16
  }) {
17
- const { i18n } = useIntelRouterContext();
17
+ const i18n = useI18n();
18
18
  const titleId = useId();
19
19
  return (
20
20
  <ModalOverlay
@@ -2,6 +2,7 @@ import type { Node } from "@anchrd/intel-contract";
2
2
  import { useMutation, useQuery } from "@tanstack/react-query";
3
3
  import { Download } from "lucide-react";
4
4
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
5
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
7
 
7
8
  /**
@@ -16,7 +17,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
16
17
  * the export does not contain.
17
18
  */
18
19
  export function NodeTablePanel({ node }: { node: Node }) {
19
- const { data, i18n } = useIntelRouterContext();
20
+ const { data } = useIntelRouterContext();
21
+ const i18n = useI18n();
20
22
  const table = useQuery({
21
23
  queryKey: ["node-table", node.id],
22
24
  queryFn: () => data.getNodeTable(node.id),
@@ -9,6 +9,7 @@ import { ViewToggle } from "@/app/view-toggle/view-toggle.tsx";
9
9
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
10
10
  import { FolderContents } from "@/folder-contents/folder-contents.tsx";
11
11
  import { GraphPane } from "@/graph-pane/graph-pane.tsx";
12
+ import { useI18n } from "@/i18n/i18n-context.tsx";
12
13
  import { NodeTablePanel } from "@/node-table/node-table.tsx";
13
14
  import { useIntelRouterContext } from "@/router/router-context.ts";
14
15
  import { graphViewFrom, selectedFrom } from "@/router/selection-search.ts";
@@ -24,7 +25,8 @@ const AgentPanel = lazy(async () => ({
24
25
  }));
25
26
 
26
27
  export function Nodes() {
27
- const { data, i18n } = useIntelRouterContext();
28
+ const { data } = useIntelRouterContext();
29
+ const i18n = useI18n();
28
30
  const queryClient = useQueryClient();
29
31
  const navigate = useNavigate();
30
32
  // The tree in the sidebar and the header search both name what to open through `?select=`, so this
@@ -187,7 +189,8 @@ export function Nodes() {
187
189
  }
188
190
 
189
191
  function AttachmentPanel({ node }: { node: Node }) {
190
- const { data, i18n } = useIntelRouterContext();
192
+ const { data } = useIntelRouterContext();
193
+ const i18n = useI18n();
191
194
  const download = useMutation({
192
195
  mutationFn: () => data.getNodeAttachment(node.id),
193
196
  onSuccess: (blob) => {
@@ -227,7 +230,8 @@ function AttachmentPanel({ node }: { node: Node }) {
227
230
  }
228
231
 
229
232
  function VersionHistory({ nodeId, close }: { nodeId: string; close(): void }) {
230
- const { data, i18n } = useIntelRouterContext();
233
+ const { data } = useIntelRouterContext();
234
+ const i18n = useI18n();
231
235
  const versions = useQuery({
232
236
  queryKey: ["node-versions", nodeId],
233
237
  queryFn: () => data.listNodeVersions(nodeId),
@@ -31,6 +31,7 @@ import {
31
31
  } from "@/components/ui/dropdown-menu";
32
32
  import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
33
33
  import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
34
+ import { useI18n } from "@/i18n/i18n-context.tsx";
34
35
  import { Modal } from "@/modal/modal.tsx";
35
36
  import { useIntelRouterContext } from "@/router/router-context.ts";
36
37
  import { selectedFrom } from "@/router/selection-search.ts";
@@ -133,7 +134,8 @@ export function ResourceMenu({
133
134
  target: ResourceTarget;
134
135
  variant: "row" | "title";
135
136
  }) {
136
- const { data, i18n } = useIntelRouterContext();
137
+ const { data } = useIntelRouterContext();
138
+ const i18n = useI18n();
137
139
  const queryClient = useQueryClient();
138
140
  const navigate = useNavigate();
139
141
  const [renaming, setRenaming] = useState(false);
@@ -417,7 +419,7 @@ function RenameDialog({
417
419
  close(): void;
418
420
  submit(title: string): void;
419
421
  }) {
420
- const { i18n } = useIntelRouterContext();
422
+ const i18n = useI18n();
421
423
  const [next, setNext] = useState(title);
422
424
  const trimmed = next.trim();
423
425
  return (
@@ -469,7 +471,8 @@ function RenameDialog({
469
471
  * not enter is absent rather than shown without a name.
470
472
  */
471
473
  function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
472
- const { data, i18n } = useIntelRouterContext();
474
+ const { data } = useIntelRouterContext();
475
+ const i18n = useI18n();
473
476
  const navigate = useNavigate();
474
477
  const links = useQuery({
475
478
  queryKey: ["node-links", node.id],
@@ -528,7 +531,8 @@ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
528
531
  // server's answer, not this component's: `execute` never appears on a document, because a document
529
532
  // has nothing to run.
530
533
  function SharePanel({ node, close }: { node: Node; close(): void }) {
531
- const { data, i18n } = useIntelRouterContext();
534
+ const { data } = useIntelRouterContext();
535
+ const i18n = useI18n();
532
536
  const queryClient = useQueryClient();
533
537
  const [email, setEmail] = useState("");
534
538
  const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
@@ -1,7 +1,8 @@
1
1
  import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
2
- import type { I18n } from "@/i18n/i18n.types.ts";
3
2
 
3
+ // ⚠️ No `i18n` here, and it must not come back (#227). The router context is static: an instance
4
+ // travelling in it can never change, which is why the language was unswitchable before. Text comes
5
+ // from `useI18n()` and from nowhere else — two ways to the same string are two states that drift.
4
6
  export interface RouterContext {
5
7
  data: IntelDataProvider;
6
- i18n: I18n;
7
8
  }
@@ -1,8 +1,8 @@
1
1
  import { useBlocker } from "@tanstack/react-router";
2
2
  import { Save } from "lucide-react";
3
3
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
4
5
  import { Modal } from "@/modal/modal.tsx";
5
- import { useIntelRouterContext } from "@/router/router-context.ts";
6
6
 
7
7
  /**
8
8
  * Saving, in the title line beside the name of the thing being saved (#24).
@@ -24,7 +24,7 @@ export function SaveButton({
24
24
  saving: boolean;
25
25
  onSave(): void;
26
26
  }) {
27
- const { i18n } = useIntelRouterContext();
27
+ const i18n = useI18n();
28
28
  const label = i18n.t(dirty ? "common.saveDirty" : "common.saved");
29
29
  return (
30
30
  <TooltipProvider delayDuration={300}>
@@ -71,7 +71,7 @@ export function SaveButton({
71
71
  * the other exit, the one the router never sees: closing the tab or reloading.
72
72
  */
73
73
  export function UnsavedChangesGuard({ dirty }: { dirty: boolean }) {
74
- const { i18n } = useIntelRouterContext();
74
+ const i18n = useI18n();
75
75
  const blocker = useBlocker({
76
76
  shouldBlockFn: () => true,
77
77
  disabled: !dirty,
@@ -1,5 +1,5 @@
1
1
  import { ShieldAlert } from "lucide-react";
2
- import type { I18n } from "@/i18n/i18n.types.ts";
2
+ import { useI18n } from "@/i18n/i18n-context.tsx";
3
3
 
4
4
  /**
5
5
  * What stands on the screen when signing in worked and the surface still says no (#118).
@@ -13,7 +13,8 @@ import type { I18n } from "@/i18n/i18n.types.ts";
13
13
  * the credential a second ago will refuse it again, and a page that keeps trying by itself is the
14
14
  * behaviour being fixed. Retrying is the reader's decision, and it costs them one click.
15
15
  */
16
- export function SignInRefused({ i18n }: { i18n: I18n }) {
16
+ export function SignInRefused() {
17
+ const i18n = useI18n();
17
18
  return (
18
19
  <div role="alert" className="grid min-h-dvh place-items-center bg-background p-8">
19
20
  <div className="max-w-prose text-center">
@@ -0,0 +1,89 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
10
+ import {
11
+ applyTheme,
12
+ readThemeChoice,
13
+ rememberThemeChoice,
14
+ SystemThemeQuery,
15
+ type Theme,
16
+ type ThemeChoice,
17
+ themeFor,
18
+ } from "./theme.ts";
19
+
20
+ // The React seam of the theme, the same shape as i18n-context: the choice is client state (a
21
+ // preference), so it lives locally and is remembered in the store.
22
+
23
+ export interface ThemeSelection {
24
+ // What the reader chose — including "system", which is not a colour.
25
+ current: ThemeChoice;
26
+ // What is painted right now. The one consumer a CSS class cannot serve is a foreign library that
27
+ // paints from a prop instead of from our tokens (React Flow in flows.tsx), and it needs this
28
+ // resolved value rather than the choice.
29
+ resolved: Theme;
30
+ select(choice: ThemeChoice): void;
31
+ }
32
+
33
+ const ThemeContext = createContext<ThemeSelection | null>(null);
34
+
35
+ function subscribeSystemTheme(onChange: () => void): () => void {
36
+ const media = window.matchMedia(SystemThemeQuery);
37
+ media.addEventListener("change", onChange);
38
+ return () => media.removeEventListener("change", onChange);
39
+ }
40
+
41
+ function systemPrefersDark(): boolean {
42
+ return window.matchMedia(SystemThemeQuery).matches;
43
+ }
44
+
45
+ interface ThemeProviderProps {
46
+ store?: Pick<Storage, "getItem" | "setItem">;
47
+ children: ReactNode;
48
+ }
49
+
50
+ export function ThemeProvider({ store, children }: ThemeProviderProps) {
51
+ const [choice, setChoice] = useState<ThemeChoice>(() => readThemeChoice(store));
52
+ // A subscription rather than a read, so a system switch mid-session moves the class AND the
53
+ // resolved value at once, without a reload.
54
+ const prefersDark = useSyncExternalStore(subscribeSystemTheme, systemPrefersDark, () => false);
55
+ const resolved = themeFor(choice, prefersDark);
56
+
57
+ // ⚠️ The class is written from the resolved value, so a fixed choice simply ignores `prefersDark`
58
+ // changing. That is why there is no second effect subscribing to the media query here: one source,
59
+ // one writer.
60
+ useEffect(() => {
61
+ applyTheme(document.documentElement, resolved);
62
+ }, [resolved]);
63
+
64
+ const selection = useMemo<ThemeSelection>(
65
+ () => ({
66
+ current: choice,
67
+ resolved,
68
+ select: (next) => {
69
+ rememberThemeChoice(next, store);
70
+ setChoice(next);
71
+ },
72
+ }),
73
+ [choice, resolved, store],
74
+ );
75
+
76
+ return <ThemeContext value={selection}>{children}</ThemeContext>;
77
+ }
78
+
79
+ // `null` without a provider — not a substitute value: the settings dialog asks precisely whether
80
+ // there is anything to choose.
81
+ export function useTheme(): ThemeSelection | null {
82
+ return useContext(ThemeContext);
83
+ }
84
+
85
+ // The painted theme as a value, for a library that takes it as a prop. Never null: a component tree
86
+ // without a provider still has to draw something, and "light" is what an unstyled page is.
87
+ export function useResolvedTheme(): Theme {
88
+ return useContext(ThemeContext)?.resolved ?? "light";
89
+ }
@@ -1,40 +1,70 @@
1
- import { useSyncExternalStore } from "react";
2
-
3
1
  export type Theme = "dark" | "light";
4
2
 
3
+ // What a reader chooses is one level above what gets painted: "system" is not a colour, it is the
4
+ // instruction to keep following the media query. That distinction is the reason for two types —
5
+ // storing only `Theme` would make "light" and "light because the system is light" the same value,
6
+ // with no way back.
7
+ export type ThemeChoice = Theme | "system";
8
+
9
+ // The order in the switcher. "system" first, because it is the shipped state.
10
+ export const ThemeChoices: readonly ThemeChoice[] = ["system", "light", "dark"];
11
+
5
12
  // The one query the whole theme hangs off. Exported so that the `.dark` class and everything that
6
13
  // has to agree with it read the same string rather than two copies of it.
7
14
  export const SystemThemeQuery = "(prefers-color-scheme: dark)";
8
15
 
16
+ // Next to `intel.language`: the same kind of preference, the same place.
17
+ const STORE_KEY = "intel.theme";
18
+
9
19
  export function resolveTheme(prefersDark: boolean): Theme {
10
20
  return prefersDark ? "dark" : "light";
11
21
  }
12
22
 
13
- export function applyTheme(root: HTMLElement, theme: Theme): void {
14
- root.classList.toggle("dark", theme === "dark");
23
+ // What a choice means right now. The only place that resolves "system" — main.tsx and the provider
24
+ // both ask here, so the first paint and every later one cannot drift apart.
25
+ export function themeFor(choice: ThemeChoice, prefersDark: boolean): Theme {
26
+ return choice === "system" ? resolveTheme(prefersDark) : choice;
15
27
  }
16
28
 
17
- export function watchSystemTheme(root: HTMLElement, media: MediaQueryList): () => void {
18
- applyTheme(root, resolveTheme(media.matches));
19
- const onChange = (event: MediaQueryListEvent) => applyTheme(root, resolveTheme(event.matches));
20
- media.addEventListener("change", onChange);
21
- return () => media.removeEventListener("change", onChange);
29
+ export function applyTheme(root: HTMLElement, theme: Theme): void {
30
+ root.classList.toggle("dark", theme === "dark");
22
31
  }
23
32
 
24
- function subscribeSystemTheme(onChange: () => void): () => void {
25
- const media = window.matchMedia(SystemThemeQuery);
26
- media.addEventListener("change", onChange);
27
- return () => media.removeEventListener("change", onChange);
33
+ // Read tolerantly: anything that is not one of the three choices is "system". A hand-edited value,
34
+ // or one left behind by an older version, must not leave the UI without a theme — and "system" is
35
+ // the answer that would apply with no stored choice at all.
36
+ export function readThemeChoice(store?: Pick<Storage, "getItem">): ThemeChoice {
37
+ let stored: string | null = null;
38
+ // Reading `localStorage` throws outright in a few privacy modes, so the guard is a try, not a
39
+ // typeof check. Losing the preference is acceptable; losing the shell is not.
40
+ try {
41
+ stored = store?.getItem(STORE_KEY) ?? null;
42
+ } catch {
43
+ return "system";
44
+ }
45
+ const choices: readonly string[] = ThemeChoices;
46
+ return stored !== null && choices.includes(stored) ? (stored as ThemeChoice) : "system";
28
47
  }
29
48
 
30
- function systemTheme(): Theme {
31
- return resolveTheme(window.matchMedia(SystemThemeQuery).matches);
49
+ export function rememberThemeChoice(choice: ThemeChoice, store?: Pick<Storage, "setItem">): void {
50
+ try {
51
+ store?.setItem(STORE_KEY, choice);
52
+ } catch {
53
+ // A preference that cannot be written is still a preference for this session.
54
+ }
32
55
  }
33
56
 
34
- // The theme as a value, for the one consumer a CSS class cannot serve: a foreign library that
35
- // paints from a prop instead of from our tokens. It reads the same media query `watchSystemTheme`
36
- // puts the `.dark` class on the root from, so the two cannot drift apart — and because it is a
37
- // subscription rather than a read, a switch mid-session moves both at once, without a reload.
38
- export function useSystemTheme(): Theme {
39
- return useSyncExternalStore(subscribeSystemTheme, systemTheme);
57
+ // Wiring: apply the choice and only for "system" react to every system change. A fixed choice
58
+ // deliberately does NOT subscribe: a listener that overrides the chosen theme the moment the
59
+ // operating system switches is exactly the bug the choice removes.
60
+ export function watchTheme(
61
+ root: HTMLElement,
62
+ media: MediaQueryList,
63
+ choice: ThemeChoice,
64
+ ): () => void {
65
+ applyTheme(root, themeFor(choice, media.matches));
66
+ if (choice !== "system") return () => {};
67
+ const onChange = (event: MediaQueryListEvent) => applyTheme(root, resolveTheme(event.matches));
68
+ media.addEventListener("change", onChange);
69
+ return () => media.removeEventListener("change", onChange);
40
70
  }
@@ -0,0 +1,149 @@
1
+ import { Check, ChevronsUpDown } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import {
4
+ Command,
5
+ CommandEmpty,
6
+ CommandGroup,
7
+ CommandInput,
8
+ CommandItem,
9
+ CommandList,
10
+ } from "@/components/ui/command";
11
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
12
+ import { useI18n } from "@/i18n/i18n-context.tsx";
13
+ import { cn } from "@/lib/utils";
14
+ import {
15
+ browserTimezone,
16
+ type TimezoneOption,
17
+ timezoneOption,
18
+ timezoneOptions,
19
+ } from "../timezone.ts";
20
+
21
+ /**
22
+ * Picking one of 418 timezones.
23
+ *
24
+ * ⚠️ A `Select` was the wrong shape and this replaces it (#243). Four hundred entries cannot be
25
+ * scrolled to, and a bare zone name does not say whether you got the right one — `Europe/Berlin`
26
+ * and `Europe/Madrid` are the same clock, `America/New_York` is not. So: type to search, and the
27
+ * current offset stands in front of every name.
28
+ *
29
+ * ⚠️ The label is computed here, at render time, and never stored. A zone's offset AND its
30
+ * abbreviation move with daylight saving, so a written-down label is wrong for half of every year.
31
+ */
32
+ interface TimezoneComboboxProps {
33
+ id: string;
34
+ value: string;
35
+ onChange(zone: string): void;
36
+ /** Handed in rather than read from the clock, so a test does not have to travel in time. */
37
+ now?: Date;
38
+ describedBy?: string;
39
+ }
40
+
41
+ function OptionLabel({ option }: { option: TimezoneOption }) {
42
+ return (
43
+ <>
44
+ {/* Fixed width, so the zone names line up into a column instead of stepping left and right
45
+ with every offset. Tabular figures keep "GMT+05:30" and "GMT-04:00" the same width, and
46
+ ⚠️ `whitespace-nowrap` is not decoration: the longest label ("GMT-09:00 · HADT") wrapped
47
+ onto a second line and pushed its own row out of the column. */}
48
+ <span className="w-36 shrink-0 whitespace-nowrap text-xs tabular-nums text-muted-foreground">
49
+ {option.offsetLabel}
50
+ {option.abbreviation ? ` · ${option.abbreviation}` : ""}
51
+ </span>
52
+ <span className="truncate">{option.zone.replace(/_/g, " ")}</span>
53
+ </>
54
+ );
55
+ }
56
+
57
+ export function TimezoneCombobox({ id, value, onChange, now, describedBy }: TimezoneComboboxProps) {
58
+ const i18n = useI18n();
59
+ const [open, setOpen] = useState(false);
60
+ const at = now ?? new Date();
61
+ // The clock is a dependency on purpose: without it in the key, a session that crossed a DST
62
+ // boundary would keep showing yesterday's offsets.
63
+ const at_ = at.getTime();
64
+
65
+ const options = useMemo(
66
+ () => timezoneOptions(value, new Date(at_), i18n.locale),
67
+ [value, at_, i18n.locale],
68
+ );
69
+ const selected = useMemo(
70
+ () => timezoneOption(value, new Date(at_), i18n.locale),
71
+ [value, at_, i18n.locale],
72
+ );
73
+ // The reader's own zone, pinned above the list: it is the answer in the overwhelming majority of
74
+ // cases, and finding it in a sorted list of 418 is the work this saves.
75
+ const own = useMemo(
76
+ () => timezoneOption(browserTimezone(), new Date(at_), i18n.locale),
77
+ [at_, i18n.locale],
78
+ );
79
+
80
+ const choose = (zone: string) => {
81
+ onChange(zone);
82
+ setOpen(false);
83
+ };
84
+
85
+ return (
86
+ <Popover open={open} onOpenChange={setOpen}>
87
+ <PopoverTrigger asChild>
88
+ <button
89
+ id={id}
90
+ type="button"
91
+ role="combobox"
92
+ aria-expanded={open}
93
+ aria-describedby={describedBy}
94
+ className="flex w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
95
+ >
96
+ <OptionLabel option={selected} />
97
+ <ChevronsUpDown aria-hidden="true" className="ml-auto size-4 shrink-0 opacity-50" />
98
+ </button>
99
+ </PopoverTrigger>
100
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
101
+ {/* ⚠️ A substring filter, not cmdk's default fuzzy one. Fuzzy matches a scattered
102
+ subsequence, so over 418 zones "berlin" also surfaced Brisbane, Dublin and Lisbon —
103
+ technically a match, useless as an answer. Prefix beats contains, so typing a city puts
104
+ that city first. An empty search returns 1 for everything, which leaves the list in DOM
105
+ order: sorted west to east. */}
106
+ <Command
107
+ filter={(value, search, keywords) => {
108
+ const needle = search.trim().toLowerCase();
109
+ if (!needle) return 1;
110
+ const haystack = [value.toLowerCase(), ...(keywords ?? [])];
111
+ if (haystack.some((word) => word.startsWith(needle))) return 2;
112
+ return haystack.some((word) => word.includes(needle)) ? 1 : 0;
113
+ }}
114
+ >
115
+ <CommandInput placeholder={i18n.t("settings.timezoneSearch")} />
116
+ <CommandList>
117
+ <CommandEmpty>{i18n.t("settings.timezoneNoMatch")}</CommandEmpty>
118
+ {own.zone !== value ? (
119
+ <CommandGroup heading={i18n.t("settings.timezoneOwn")}>
120
+ <CommandItem value={own.zone} keywords={own.keywords} onSelect={choose}>
121
+ <OptionLabel option={own} />
122
+ </CommandItem>
123
+ </CommandGroup>
124
+ ) : null}
125
+ <CommandGroup heading={i18n.t("settings.timezoneAll")}>
126
+ {options.map((option) => (
127
+ <CommandItem
128
+ key={option.zone}
129
+ value={option.zone}
130
+ keywords={option.keywords}
131
+ onSelect={choose}
132
+ >
133
+ <OptionLabel option={option} />
134
+ <Check
135
+ aria-hidden="true"
136
+ className={cn(
137
+ "ml-auto size-4 shrink-0",
138
+ option.zone === value ? "opacity-100" : "opacity-0",
139
+ )}
140
+ />
141
+ </CommandItem>
142
+ ))}
143
+ </CommandGroup>
144
+ </CommandList>
145
+ </Command>
146
+ </PopoverContent>
147
+ </Popover>
148
+ );
149
+ }