@anchrd/intel-ui 0.4.0 → 0.6.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/app/action-slot/action-slot.tsx +27 -0
  3. package/src/app/app-sidebar/app-sidebar.tsx +39 -24
  4. package/src/app/app-tree/app-tree.tsx +332 -60
  5. package/src/app/app.tsx +31 -5
  6. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
  7. package/src/app/tree-move/tree-move.tsx +331 -0
  8. package/src/app/user-footer/user-footer.tsx +73 -46
  9. package/src/app/view-toggle/view-toggle.tsx +77 -0
  10. package/src/blocknote-view/blocknote-view.tsx +19 -2
  11. package/src/branding/favicon.default.svg +2 -2
  12. package/src/branding/favicon.svg +2 -2
  13. package/src/components/ui/dropdown-menu.tsx +78 -0
  14. package/src/data/intel-data-provider/intel-data-provider.ts +135 -57
  15. package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
  16. package/src/document-link/document-link.tsx +132 -0
  17. package/src/flow-runs/flow-runs.tsx +225 -0
  18. package/src/flows/flows.tsx +665 -271
  19. package/src/flows/node-icon/node-icon.ts +28 -0
  20. package/src/flows/node-palette/node-palette.tsx +200 -0
  21. package/src/flows/node-palette/node-palette.types.ts +15 -0
  22. package/src/graph-pane/graph-pane.tsx +44 -0
  23. package/src/i18n/en.json +144 -29
  24. package/src/knowledge/knowledge.tsx +91 -367
  25. package/src/knowledge-editor/knowledge-editor.tsx +169 -21
  26. package/src/knowledge-graph/knowledge-graph.ts +26 -24
  27. package/src/knowledge-graph/knowledge-graph.tsx +33 -24
  28. package/src/knowledge-table/knowledge-table.tsx +141 -0
  29. package/src/main.tsx +2 -2
  30. package/src/resource-menu/resource-menu.tsx +615 -0
  31. package/src/router/selection-search.ts +27 -3
  32. package/src/save-button/save-button.tsx +103 -0
  33. package/src/styles.css +37 -0
  34. package/src/theme/theme.ts +24 -0
  35. package/src/title-row/title-row.tsx +49 -0
  36. package/src/tools/tools.tsx +57 -38
  37. package/src/app/header-actions/header-actions.tsx +0 -15
@@ -0,0 +1,103 @@
1
+ import { useBlocker } from "@tanstack/react-router";
2
+ import { Save } from "lucide-react";
3
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
4
+ import { Modal } from "@/modal/modal.tsx";
5
+ import { useIntelRouterContext } from "@/router/router-context.ts";
6
+
7
+ /**
8
+ * Saving, in the title line beside the name of the thing being saved (#24).
9
+ *
10
+ * ⚠️ Unsaved is a state that must survive colour blindness, so it is never carried by colour alone.
11
+ * At rest the button is a quiet icon; with something to save it fills, grows a dot and spells the
12
+ * word out. Three changes at once, of which two are readable without seeing any colour at all.
13
+ *
14
+ * ⚠️ The accessible name names the state as well — "Saved" and "Save unsaved changes" are two
15
+ * different pieces of information, and a screen reader gets neither the fill nor the dot. The
16
+ * visible word stays a prefix of it, so speech input still reaches the button by what it reads.
17
+ */
18
+ export function SaveButton({
19
+ dirty,
20
+ saving,
21
+ onSave,
22
+ }: {
23
+ dirty: boolean;
24
+ saving: boolean;
25
+ onSave(): void;
26
+ }) {
27
+ const { i18n } = useIntelRouterContext();
28
+ const label = i18n.t(dirty ? "common.saveDirty" : "common.saved");
29
+ return (
30
+ <TooltipProvider delayDuration={300}>
31
+ <Tooltip>
32
+ {/* ⚠️ The wrapper is the trigger, not the button. At rest the button is disabled, and a
33
+ disabled control fires no pointer events — a tooltip hung on it would stay shut in the
34
+ one state where the button carries no words at all. */}
35
+ <TooltipTrigger asChild>
36
+ <span className="inline-flex">
37
+ <button
38
+ type="button"
39
+ disabled={!dirty || saving}
40
+ onClick={onSave}
41
+ aria-label={label}
42
+ data-dirty={dirty}
43
+ className={
44
+ dirty
45
+ ? "inline-flex h-8 items-center gap-2 rounded-md bg-primary px-2.5 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-70"
46
+ : "inline-flex size-8 items-center justify-center rounded-md border bg-background text-muted-foreground outline-none disabled:opacity-60"
47
+ }
48
+ >
49
+ {dirty ? (
50
+ <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />
51
+ ) : null}
52
+ <Save aria-hidden="true" className="size-4 shrink-0" />
53
+ {dirty ? (
54
+ <span>{saving ? i18n.t("common.saving") : i18n.t("common.save")}</span>
55
+ ) : null}
56
+ </button>
57
+ </span>
58
+ </TooltipTrigger>
59
+ <TooltipContent>{label}</TooltipContent>
60
+ </Tooltip>
61
+ </TooltipProvider>
62
+ );
63
+ }
64
+
65
+ /**
66
+ * The state, said out loud at the moment it would otherwise be lost.
67
+ *
68
+ * ⚠️ Without this the button is the only carrier of "not saved yet", and the one person who needs
69
+ * that answer is the one who has already looked away — clicking the next document in the tree is a
70
+ * plain navigation, and the editor would unmount with the work in it. `enableBeforeUnload` covers
71
+ * the other exit, the one the router never sees: closing the tab or reloading.
72
+ */
73
+ export function UnsavedChangesGuard({ dirty }: { dirty: boolean }) {
74
+ const { i18n } = useIntelRouterContext();
75
+ const blocker = useBlocker({
76
+ shouldBlockFn: () => true,
77
+ disabled: !dirty,
78
+ enableBeforeUnload: () => dirty,
79
+ withResolver: true,
80
+ });
81
+ if (blocker.status !== "blocked") return null;
82
+ return (
83
+ <Modal title={i18n.t("common.unsavedTitle")} close={blocker.reset}>
84
+ <p className="text-sm text-muted-foreground">{i18n.t("common.unsavedBody")}</p>
85
+ <div className="mt-5 flex gap-2">
86
+ <button
87
+ type="button"
88
+ onClick={blocker.reset}
89
+ className="flex-1 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
90
+ >
91
+ {i18n.t("common.unsavedStay")}
92
+ </button>
93
+ <button
94
+ type="button"
95
+ onClick={blocker.proceed}
96
+ className="flex-1 rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring"
97
+ >
98
+ {i18n.t("common.unsavedLeave")}
99
+ </button>
100
+ </div>
101
+ </Modal>
102
+ );
103
+ }
package/src/styles.css CHANGED
@@ -114,6 +114,43 @@
114
114
  }
115
115
  }
116
116
 
117
+ /* `colorMode` on `<ReactFlow>` decides which of the library's two palettes it paints from; these
118
+ decide what the palette is made of. They are React Flow's own theming variables — its documented
119
+ extension point, set on its root class, not overrides hung on the internal class names of its
120
+ controls — so an installation with its own theme colours the canvas, the grid, the zoom buttons
121
+ and the overview map along with everything else. Same arrangement as `.react-sigma` below. */
122
+ .react-flow {
123
+ --xy-background-color: var(--background);
124
+ --xy-background-pattern-color: var(--border);
125
+ --xy-controls-button-background-color: var(--card);
126
+ --xy-controls-button-background-color-hover: var(--accent);
127
+ --xy-controls-button-color: var(--card-foreground);
128
+ --xy-controls-button-color-hover: var(--accent-foreground);
129
+ --xy-controls-button-border-color: var(--border);
130
+ --xy-minimap-background-color: var(--card);
131
+ /* The nodes have to read as nodes against the map's own background rather than as one white
132
+ block, so they take the token that is defined to contrast with it. */
133
+ --xy-minimap-node-background-color: var(--muted-foreground);
134
+ /* The veil over everything outside the viewport: a token, thinned, because opaque would hide the
135
+ part of the graph the map exists to show. */
136
+ --xy-minimap-mask-background-color: color-mix(in oklab, var(--muted) 70%, transparent);
137
+ }
138
+
139
+ /* A context edge is not a step in the order of work (#37), so it is drawn as material rather than as
140
+ procedure: dashed, thinner, and in the muted token instead of the edge colour. The dash pattern is
141
+ set on the edge itself; the colour belongs here, with the rest of the canvas theming, so an
142
+ installation's own tokens reach it too. */
143
+ .react-flow .intel-context-edge .react-flow__edge-path {
144
+ stroke: var(--muted-foreground);
145
+ stroke-width: 1.5;
146
+ }
147
+
148
+ /* The start node in the overview map (#39). Nothing is written there, so the only thing that can
149
+ carry "this is where it begins" that far out is the colour. */
150
+ .react-flow__minimap-node.intel-minimap-start {
151
+ fill: var(--primary);
152
+ }
153
+
117
154
  .react-sigma {
118
155
  --sigma-background-color: var(--background);
119
156
  --sigma-controls-background-color: var(--card);
@@ -1,5 +1,11 @@
1
+ import { useSyncExternalStore } from "react";
2
+
1
3
  export type Theme = "dark" | "light";
2
4
 
5
+ // The one query the whole theme hangs off. Exported so that the `.dark` class and everything that
6
+ // has to agree with it read the same string rather than two copies of it.
7
+ export const SystemThemeQuery = "(prefers-color-scheme: dark)";
8
+
3
9
  export function resolveTheme(prefersDark: boolean): Theme {
4
10
  return prefersDark ? "dark" : "light";
5
11
  }
@@ -14,3 +20,21 @@ export function watchSystemTheme(root: HTMLElement, media: MediaQueryList): () =
14
20
  media.addEventListener("change", onChange);
15
21
  return () => media.removeEventListener("change", onChange);
16
22
  }
23
+
24
+ function subscribeSystemTheme(onChange: () => void): () => void {
25
+ const media = window.matchMedia(SystemThemeQuery);
26
+ media.addEventListener("change", onChange);
27
+ return () => media.removeEventListener("change", onChange);
28
+ }
29
+
30
+ function systemTheme(): Theme {
31
+ return resolveTheme(window.matchMedia(SystemThemeQuery).matches);
32
+ }
33
+
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);
40
+ }
@@ -0,0 +1,49 @@
1
+ import type * as React from "react";
2
+ import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
3
+
4
+ /**
5
+ * The one line an open thing gets: its name on the left, everything that acts on it on the right
6
+ * (#53).
7
+ *
8
+ * ⚠️ The order of the right-hand group belongs to this component, not to its callers. Whatever they
9
+ * pass as `children` is rendered before the menu and the menu is appended last, always — so a screen
10
+ * that grows a new button cannot push the three dots to the left, and there is no second place where
11
+ * the rule could be forgotten. That is the whole point: the menu holds rename, move, share and
12
+ * archive, and a position one has to look for is a position one stops using.
13
+ *
14
+ * The two slots are for what belongs to this thing but lives further down the tree, where its state
15
+ * is. `title-meta` is a quiet word beside the name (a table's row count); `title-actions` is a
16
+ * button in the group (saving a document, exporting a table). Both are filled through `ActionSlot`
17
+ * and both sit before the menu for the same reason `children` do.
18
+ */
19
+ export function TitleRow({
20
+ title,
21
+ description,
22
+ target,
23
+ children,
24
+ }: {
25
+ title: string;
26
+ description?: string | null;
27
+ target: ResourceTarget;
28
+ children?: React.ReactNode;
29
+ }) {
30
+ return (
31
+ <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
32
+ <div className="min-w-0">
33
+ <div className="flex min-w-0 items-baseline gap-3">
34
+ <h2 className="truncate text-lg font-semibold">{title}</h2>
35
+ <span data-slot="title-meta" className="shrink-0 text-sm text-muted-foreground" />
36
+ </div>
37
+ {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
38
+ </div>
39
+ {/* ⚠️ Document order is tab order here — nothing carries a `tabIndex`. The menu is therefore
40
+ reached last by the keyboard for the same reason it stands last on screen, and the two
41
+ cannot drift apart without someone rewriting this line. */}
42
+ <div className="flex shrink-0 items-center gap-2">
43
+ <div data-slot="title-actions" className="flex items-center gap-2" />
44
+ {children}
45
+ <ResourceMenu target={target} variant="title" />
46
+ </div>
47
+ </div>
48
+ );
49
+ }
@@ -1,10 +1,9 @@
1
1
  import type { ToolCapability } from "@anchrd/intel-contract";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
- import { AlertTriangle, ChevronRight, LogIn, PlugZap, Wrench } from "lucide-react";
5
- import type * as React from "react";
6
- import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
7
- import { Button, buttonVariants } from "@/components/ui/button";
4
+ import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Wrench } from "lucide-react";
5
+ import * as React from "react";
6
+ import { Button } from "@/components/ui/button";
8
7
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
9
8
  import type { I18n } from "@/i18n/i18n.types.ts";
10
9
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -20,6 +19,23 @@ function toolOrigin(name: string): string | null {
20
19
  return boundary > 0 ? name.slice(0, boundary) : null;
21
20
  }
22
21
 
22
+ // Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
23
+ // visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
24
+ // the marker in the URL only survives until the next navigation — the browser session remembers it
25
+ // instead (#60).
26
+ const SignInAttemptKey = "intel.portal-sign-in-attempted";
27
+
28
+ // Reading `sessionStorage` throws outright in a few privacy modes, so the guard is a try, not a
29
+ // feature check. Losing the note costs one extra redirect, and the marker the refusal leaves in the
30
+ // URL still ends the walk — it must never cost the screen.
31
+ function attemptStore(): Storage | null {
32
+ try {
33
+ return typeof window === "undefined" ? null : window.sessionStorage;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
23
39
  // The catalog is one live `tools/list` with the signed-in user's own portal token. Nothing here is
24
40
  // stored, mirrored or administered — the screen shows what the portal answers and nothing else.
25
41
  export function Tools() {
@@ -28,36 +44,34 @@ export function Tools() {
28
44
  // A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
29
45
  // the list. Nothing else about the row changes: it is still only a disclosure.
30
46
  const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
31
- // The portal connect flow returns here with a marker when it refuses. Only its presence is used:
32
- // the value is attacker-controlled and could carry provider detail, so it is never rendered.
33
- const connectFailed =
47
+ // The silent sign-in returns here with a marker when the portal refused. Only its presence is
48
+ // used: the value comes from outside and could carry provider detail, so it is never rendered.
49
+ const refused =
34
50
  typeof window !== "undefined" &&
35
51
  new URLSearchParams(window.location.search).has("connectError");
52
+ const [signingIn, setSigningIn] = React.useState(false);
53
+
54
+ // ⚠️ Nobody is asked to start this. The portal sign-in is a browser redirect, so the screen
55
+ // begins it itself the moment the catalog says there is no portal session yet — that is the whole
56
+ // of what replaced the "Connect the portal" button.
57
+ React.useEffect(() => {
58
+ if (!catalog.data) return;
59
+ const attempts = attemptStore();
60
+ if (catalog.data.portalConnected) {
61
+ // An answered sign-in frees the next one: when the token later expires beyond renewal, the
62
+ // same silent walk runs again rather than stopping at a screen.
63
+ attempts?.removeItem(SignInAttemptKey);
64
+ return;
65
+ }
66
+ if (refused || attempts?.getItem(SignInAttemptKey)) return;
67
+ attempts?.setItem(SignInAttemptKey, "1");
68
+ setSigningIn(true);
69
+ data.startPortalSignIn("/tools");
70
+ }, [catalog.data, data, refused]);
36
71
 
37
72
  return (
38
73
  <div className="min-h-full p-8">
39
- {catalog.data?.portalConnected ? (
40
- <HeaderActions>
41
- <a
42
- href={data.portalConnectUrl("/tools")}
43
- className={buttonVariants({ variant: "outline", size: "sm" })}
44
- >
45
- <LogIn aria-hidden="true" />
46
- {i18n.t("tools.reconnect")}
47
- </a>
48
- </HeaderActions>
49
- ) : null}
50
-
51
74
  <div className="mx-auto w-full max-w-4xl space-y-5">
52
- {connectFailed && (
53
- <p
54
- role="alert"
55
- className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
56
- >
57
- {i18n.t("tools.connectFailed")}
58
- </p>
59
- )}
60
-
61
75
  {catalog.isPending ? (
62
76
  <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
63
77
  ) : catalog.isError ? (
@@ -74,16 +88,21 @@ export function Tools() {
74
88
  </Button>
75
89
  </Notice>
76
90
  ) : !catalog.data.portalConnected ? (
77
- <Notice
78
- icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
79
- title={i18n.t("tools.disconnected")}
80
- help={i18n.t("tools.disconnectedHelp")}
81
- >
82
- <a href={data.portalConnectUrl("/tools")} className={buttonVariants()}>
83
- <LogIn aria-hidden="true" />
84
- {i18n.t("tools.connect")}
85
- </a>
86
- </Notice>
91
+ signingIn ? (
92
+ <p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
93
+ ) : (
94
+ // The end of the silent walk for somebody no Access policy carries. It is a sentence
95
+ // about access, not an invitation to connect: there is nothing they could click that
96
+ // would change the answer, and what the portal replied is not repeated here.
97
+ <Notice
98
+ alert
99
+ icon={
100
+ <ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
101
+ }
102
+ title={i18n.t("tools.noAccess")}
103
+ help={i18n.t("tools.noAccessHelp")}
104
+ />
105
+ )
87
106
  ) : catalog.data.items.length === 0 ? (
88
107
  <Notice
89
108
  icon={<Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
@@ -1,15 +0,0 @@
1
- import type * as React from "react";
2
- import { useEffect, useState } from "react";
3
- import { createPortal } from "react-dom";
4
-
5
- // The shell owns the header bar, the screen owns what belongs in it: a screen renders its actions
6
- // here and they appear beside the breadcrumb instead of in a second heading of its own.
7
- //
8
- // ⚠️ The slot lives in the shell above the outlet, so it is not in the document while the screen
9
- // first renders. Looking it up in an effect costs one extra render and is the only order that
10
- // works; reading it during render finds nothing on the first paint.
11
- export function HeaderActions({ children }: { children: React.ReactNode }) {
12
- const [slot, setSlot] = useState<Element | null>(null);
13
- useEffect(() => setSlot(document.querySelector('[data-slot="header-actions"]')), []);
14
- return slot ? createPortal(children, slot) : null;
15
- }