@anchrd/intel-ui 0.4.0 → 0.5.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 (36) hide show
  1. package/package.json +1 -1
  2. package/src/app/action-slot/action-slot.tsx +23 -0
  3. package/src/app/app-sidebar/app-sidebar.tsx +39 -24
  4. package/src/app/app-tree/app-tree.tsx +431 -60
  5. package/src/app/app.tsx +17 -2
  6. package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
  7. package/src/app/tree-move/tree-move.tsx +197 -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 +120 -54
  15. package/src/data/intel-data-provider/intel-data-provider.types.ts +47 -12
  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 +670 -271
  19. package/src/flows/node-icon/node-icon.ts +28 -0
  20. package/src/flows/node-palette/node-palette.tsx +174 -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 +141 -24
  24. package/src/knowledge/knowledge.tsx +69 -355
  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 +129 -0
  29. package/src/main.tsx +2 -2
  30. package/src/resource-menu/resource-menu.tsx +580 -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/tools/tools.tsx +3 -3
  36. 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
+ }
@@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
4
  import { AlertTriangle, ChevronRight, LogIn, PlugZap, Wrench } from "lucide-react";
5
5
  import type * as React from "react";
6
- import { HeaderActions } from "@/app/header-actions/header-actions.tsx";
6
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
7
  import { Button, buttonVariants } from "@/components/ui/button";
8
8
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
9
9
  import type { I18n } from "@/i18n/i18n.types.ts";
@@ -37,7 +37,7 @@ export function Tools() {
37
37
  return (
38
38
  <div className="min-h-full p-8">
39
39
  {catalog.data?.portalConnected ? (
40
- <HeaderActions>
40
+ <ActionSlot>
41
41
  <a
42
42
  href={data.portalConnectUrl("/tools")}
43
43
  className={buttonVariants({ variant: "outline", size: "sm" })}
@@ -45,7 +45,7 @@ export function Tools() {
45
45
  <LogIn aria-hidden="true" />
46
46
  {i18n.t("tools.reconnect")}
47
47
  </a>
48
- </HeaderActions>
48
+ </ActionSlot>
49
49
  ) : null}
50
50
 
51
51
  <div className="mx-auto w-full max-w-4xl space-y-5">
@@ -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
- }