@anchrd/intel-ui 0.12.0 → 0.13.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 +3 -2
- package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
- package/src/agent/agent-chat/agent-chat.tsx +3 -1
- package/src/agent/agent-cron/agent-cron.ts +49 -113
- package/src/agent/agent-definition/agent-definition.ts +18 -9
- package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
- package/src/agent/agent-log/agent-log.tsx +6 -3
- package/src/agent/agent-profile/agent-profile.tsx +46 -12
- package/src/agent/agent.tsx +17 -9
- package/src/app/app-sidebar/app-sidebar.tsx +2 -2
- package/src/app/app-tree/app-tree.tsx +5 -3
- package/src/app/app.tsx +2 -2
- package/src/app/header-search/header-search.tsx +3 -1
- package/src/app/settings-dialog/settings-dialog.tsx +133 -0
- package/src/app/tree-move/tree-move.tsx +3 -1
- package/src/app/user-footer/user-footer.tsx +25 -2
- package/src/app/view-toggle/view-toggle.tsx +2 -2
- package/src/archive/archive.tsx +3 -1
- package/src/entry-picker/entry-picker.tsx +3 -1
- package/src/flow-runs/flow-runs.tsx +6 -3
- package/src/flows/flows.tsx +13 -9
- package/src/flows/node-palette/node-palette.tsx +2 -2
- package/src/folder-contents/folder-contents.tsx +3 -1
- package/src/graph-pane/graph-pane.tsx +2 -2
- package/src/i18n/de.json +402 -0
- package/src/i18n/en.json +16 -5
- package/src/i18n/es.json +402 -0
- package/src/i18n/i18n-context.tsx +65 -0
- package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/src/i18n/i18n.ts +15 -11
- package/src/i18n/i18n.types.ts +43 -0
- package/src/main.tsx +33 -12
- package/src/modal/modal.tsx +2 -2
- package/src/node-table/node-table.tsx +3 -1
- package/src/nodes/nodes.tsx +7 -3
- package/src/resource-menu/resource-menu.tsx +8 -4
- package/src/router/router.types.ts +3 -2
- package/src/save-button/save-button.tsx +3 -3
- package/src/sign-in-refused/sign-in-refused.tsx +3 -2
- package/src/theme/theme-context.tsx +89 -0
- package/src/theme/theme.ts +51 -21
- package/src/timezone/timezone-context.tsx +45 -0
- package/src/timezone/timezone.ts +60 -0
- package/src/tools/tools.tsx +3 -1
package/src/i18n/i18n.types.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
+
const router = createIntelRouter({ data });
|
|
37
49
|
|
|
38
50
|
function paint(): void {
|
|
39
51
|
mounted.render(
|
|
40
52
|
<StrictMode>
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/modal/modal.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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),
|
package/src/nodes/nodes.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/theme/theme.ts
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,45 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useMemo, useState } from "react";
|
|
2
|
+
import { browserTimezone, readTimezone, rememberTimezone } from "./timezone.ts";
|
|
3
|
+
|
|
4
|
+
// The React seam of the timezone preference, the same shape as i18n-context and theme-context.
|
|
5
|
+
|
|
6
|
+
export interface TimezoneSelection {
|
|
7
|
+
current: string;
|
|
8
|
+
select(zone: string): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TimezoneContext = createContext<TimezoneSelection | null>(null);
|
|
12
|
+
|
|
13
|
+
interface TimezoneProviderProps {
|
|
14
|
+
store?: Pick<Storage, "getItem" | "setItem">;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TimezoneProvider({ store, children }: TimezoneProviderProps) {
|
|
19
|
+
const [current, setCurrent] = useState(() => readTimezone(store));
|
|
20
|
+
|
|
21
|
+
const selection = useMemo<TimezoneSelection>(
|
|
22
|
+
() => ({
|
|
23
|
+
current,
|
|
24
|
+
select: (zone) => {
|
|
25
|
+
rememberTimezone(zone, store);
|
|
26
|
+
setCurrent(zone);
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
[current, store],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return <TimezoneContext value={selection}>{children}</TimezoneContext>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// `null` without a provider — the settings dialog asks whether there is anything to choose.
|
|
36
|
+
export function useTimezoneSelection(): TimezoneSelection | null {
|
|
37
|
+
return useContext(TimezoneContext);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The zone to read times in. Never null: a component without a provider still has to format a date,
|
|
41
|
+
// and the browser's own zone is the honest answer there.
|
|
42
|
+
export function useTimezone(): string {
|
|
43
|
+
const selection = useContext(TimezoneContext);
|
|
44
|
+
return selection?.current ?? browserTimezone();
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The reader's timezone. Next to `intel.language` and `intel.theme`: the same kind of preference,
|
|
2
|
+
// the same place.
|
|
3
|
+
//
|
|
4
|
+
// ⚠️ This is a preference, NOT the truth about when an agent fires. A schedule carries its own
|
|
5
|
+
// timezone (#228); this value is the suggestion when one is created and the lens the calendar is
|
|
6
|
+
// read through. The difference matters the day somebody travels: "08:00 Europe/Berlin" must stay
|
|
7
|
+
// Berlin, and it would not if the schedule took whatever the reader's browser says today.
|
|
8
|
+
const STORE_KEY = "intel.timezone";
|
|
9
|
+
|
|
10
|
+
// What the browser thinks. Used when nothing was ever chosen — deliberately not "UTC": a default
|
|
11
|
+
// nobody lives in is a default that produces schedules nobody meant.
|
|
12
|
+
export function browserTimezone(): string {
|
|
13
|
+
try {
|
|
14
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
15
|
+
} catch {
|
|
16
|
+
return "UTC";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Every IANA name this runtime knows, for the picker. An engine without `supportedValuesOf` (or one
|
|
21
|
+
// built without full ICU) yields the empty list, and the caller falls back to offering just the
|
|
22
|
+
// browser's own zone — one real choice beats a picker that throws.
|
|
23
|
+
export function availableTimezones(): string[] {
|
|
24
|
+
try {
|
|
25
|
+
return Intl.supportedValuesOf("timeZone");
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Whether this runtime can actually format in that zone. The check is the formatting itself: a name
|
|
32
|
+
// the engine rejects throws a RangeError here rather than in the middle of a calendar.
|
|
33
|
+
export function isKnownTimezone(zone: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A stored zone the engine no longer knows is discarded rather than kept: IANA renames zones, and a
|
|
43
|
+
// name that stopped resolving would otherwise throw on every date the calendar draws.
|
|
44
|
+
export function readTimezone(store?: Pick<Storage, "getItem">): string {
|
|
45
|
+
let stored: string | null = null;
|
|
46
|
+
try {
|
|
47
|
+
stored = store?.getItem(STORE_KEY) ?? null;
|
|
48
|
+
} catch {
|
|
49
|
+
return browserTimezone();
|
|
50
|
+
}
|
|
51
|
+
return stored !== null && isKnownTimezone(stored) ? stored : browserTimezone();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rememberTimezone(zone: string, store?: Pick<Storage, "setItem">): void {
|
|
55
|
+
try {
|
|
56
|
+
store?.setItem(STORE_KEY, zone);
|
|
57
|
+
} catch {
|
|
58
|
+
// A preference that cannot be written is still a preference for this session.
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/tools/tools.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import * as React from "react";
|
|
|
6
6
|
import { Button } from "@/components/ui/button";
|
|
7
7
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
8
8
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
9
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
9
10
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
10
11
|
import { selectedFrom } from "@/router/selection-search.ts";
|
|
11
12
|
|
|
@@ -80,7 +81,8 @@ function attemptStore(): Storage | null {
|
|
|
80
81
|
// The catalog is one live `tools/list` with the signed-in user's own portal token. Nothing here is
|
|
81
82
|
// stored, mirrored or administered — the screen shows what the portal answers and nothing else.
|
|
82
83
|
export function Tools() {
|
|
83
|
-
const { data
|
|
84
|
+
const { data } = useIntelRouterContext();
|
|
85
|
+
const i18n = useI18n();
|
|
84
86
|
const catalog = useQuery({ queryKey: ["tools"], queryFn: () => data.listTools() });
|
|
85
87
|
// The servers behind that same live list (D30). Its own query, and its own failure: a portal that
|
|
86
88
|
// answers tools but no directory still has a usable screen, it just has no boxes to put them in.
|