@tulipfarm/sprout 0.1.0 → 0.3.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 (98) hide show
  1. package/README.md +111 -0
  2. package/dist/components/agent-fanout.d.ts +46 -0
  3. package/dist/components/agent-fanout.js +228 -0
  4. package/dist/components/attachment.js +2 -2
  5. package/dist/components/brand-icon.js +4 -0
  6. package/dist/components/canvas.js +2 -2
  7. package/dist/components/chat-input.d.ts +94 -0
  8. package/dist/components/chat-input.js +279 -0
  9. package/dist/components/chat-sources.js +11 -2
  10. package/dist/components/code-block.js +2 -33
  11. package/dist/components/file-explorer.d.ts +27 -0
  12. package/dist/components/file-explorer.js +300 -0
  13. package/dist/components/file-viewer.d.ts +18 -1
  14. package/dist/components/file-viewer.js +35 -8
  15. package/dist/components/presence-cursor.d.ts +19 -0
  16. package/dist/components/presence-cursor.js +14 -0
  17. package/dist/components/questionnaire.js +2 -1
  18. package/dist/components/schema-diagram.js +5 -78
  19. package/dist/components/search-dialog.js +5 -4
  20. package/dist/components/sql-viewer.d.ts +76 -0
  21. package/dist/components/sql-viewer.js +409 -0
  22. package/dist/components/state-panel.js +2 -2
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +5 -0
  25. package/dist/internal/brand-glyphs.d.ts +28 -0
  26. package/dist/internal/brand-glyphs.js +24 -0
  27. package/dist/internal/code-tokens.d.ts +4 -0
  28. package/dist/internal/code-tokens.js +34 -0
  29. package/dist/internal/flow.d.ts +22 -0
  30. package/dist/internal/flow.js +82 -0
  31. package/dist/styles/accordion.css +2 -2
  32. package/dist/styles/agent-fanout.css +529 -0
  33. package/dist/styles/alert-dialog.css +2 -2
  34. package/dist/styles/alert.css +2 -2
  35. package/dist/styles/attachment.css +4 -24
  36. package/dist/styles/badge.css +2 -2
  37. package/dist/styles/brand-icon.css +2 -2
  38. package/dist/styles/breadcrumb.css +2 -2
  39. package/dist/styles/button.css +2 -2
  40. package/dist/styles/calendar.css +3 -3
  41. package/dist/styles/canvas.css +4 -18
  42. package/dist/styles/carousel.css +2 -2
  43. package/dist/styles/chart.css +2 -2
  44. package/dist/styles/chat-confirmation.css +2 -2
  45. package/dist/styles/chat-input.css +240 -0
  46. package/dist/styles/chat-sources.css +2 -2
  47. package/dist/styles/checkbox.css +2 -3
  48. package/dist/styles/choice-list.css +2 -2
  49. package/dist/styles/code-block.css +2 -2
  50. package/dist/styles/collapsible.css +2 -2
  51. package/dist/styles/combobox.css +6 -6
  52. package/dist/styles/command.css +2 -2
  53. package/dist/styles/context-menu.css +3 -3
  54. package/dist/styles/dialog.css +2 -2
  55. package/dist/styles/dropdown-menu.css +1 -1
  56. package/dist/styles/field.css +2 -3
  57. package/dist/styles/file-explorer.css +517 -0
  58. package/dist/styles/file-viewer.css +113 -16
  59. package/dist/styles/hover-card.css +2 -2
  60. package/dist/styles/index.css +9 -0
  61. package/dist/styles/input-group.css +6 -11
  62. package/dist/styles/input-otp.css +2 -2
  63. package/dist/styles/input.css +3 -4
  64. package/dist/styles/mention-list.css +1 -1
  65. package/dist/styles/menubar.css +3 -3
  66. package/dist/styles/message-response.css +4 -4
  67. package/dist/styles/message-scroller.css +2 -2
  68. package/dist/styles/message.css +5 -5
  69. package/dist/styles/navigation-menu.css +2 -2
  70. package/dist/styles/page-header.css +4 -4
  71. package/dist/styles/plan-outline.css +6 -6
  72. package/dist/styles/presence-cursor.css +68 -0
  73. package/dist/styles/prompt-list.css +2 -2
  74. package/dist/styles/questionnaire.css +2 -17
  75. package/dist/styles/radio-group.css +2 -3
  76. package/dist/styles/reasoning-effort.css +2 -2
  77. package/dist/styles/review-card.css +2 -2
  78. package/dist/styles/rich-text-editor.css +2 -2
  79. package/dist/styles/schema-diagram.css +4 -4
  80. package/dist/styles/scroll-area.css +2 -2
  81. package/dist/styles/search-dialog.css +0 -83
  82. package/dist/styles/select.css +3 -3
  83. package/dist/styles/separator.css +2 -2
  84. package/dist/styles/sheet.css +2 -2
  85. package/dist/styles/sidebar.css +2 -2
  86. package/dist/styles/slider.css +1 -1
  87. package/dist/styles/sql-viewer.css +406 -0
  88. package/dist/styles/state-panel.css +1 -10
  89. package/dist/styles/switch.css +2 -2
  90. package/dist/styles/table.css +10 -10
  91. package/dist/styles/tabs.css +4 -4
  92. package/dist/styles/textarea.css +3 -4
  93. package/dist/styles/themes/tulipfarm.css +89 -99
  94. package/dist/styles/toast.css +2 -2
  95. package/dist/styles/toggle-group.css +2 -2
  96. package/dist/styles/toggle.css +2 -2
  97. package/dist/styles/tool-activity.css +5 -5
  98. package/package.json +27 -6
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { Toggle } from "@base-ui/react/toggle";
4
3
  import { Children, Fragment, isValidElement, } from "react";
5
4
  import { X } from "reicon-react/icons/X";
6
5
  import { cn } from "../internal/classes.js";
7
6
  import { Button } from "./button.js";
7
+ import { Kbd } from "./kbd.js";
8
+ import { Toggle } from "./toggle.js";
8
9
  import { Command, CommandGroup, CommandInput, CommandItem, CommandList, } from "./command.js";
9
10
  import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, } from "./dialog.js";
10
11
  /**
@@ -24,7 +25,7 @@ export function SearchFilters({ label, children }) {
24
25
  }
25
26
  /** A pressable filter chip. */
26
27
  export function SearchFilter({ pressed, onPressedChange, icon, children, }) {
27
- return (_jsxs(Toggle, { "data-slot": "search-filter", pressed: pressed, onPressedChange: (next) => onPressedChange(next), className: "sprout-search-filter", children: [icon, children] }));
28
+ return (_jsxs(Toggle, { "data-slot": "search-filter", variant: "outline", size: "sm", pressed: pressed, onPressedChange: (next) => onPressedChange(next), children: [icon, children] }));
28
29
  }
29
30
  /** The scrolling results area. It keeps a steady height so typing does not resize the dialog. */
30
31
  export function SearchList({ label, children }) {
@@ -42,7 +43,7 @@ export function SearchItemIcon({ children }) {
42
43
  }
43
44
  /** One result. The active item shows an Enter key hint. */
44
45
  export function SearchItem({ value, onSelect, media, title, meta, detail, trailing, }) {
45
- return (_jsxs(CommandItem, { value: value, onSelect: () => onSelect(), className: "sprout-search-item", children: [_jsx("span", { "data-slot": "search-item-media", className: "sprout-search-item-media", children: media }), _jsxs("span", { className: "sprout-search-item-text", children: [_jsxs("span", { className: "sprout-search-item-line", children: [_jsx("span", { "data-slot": "search-item-title", className: "sprout-search-item-title", children: title }), meta && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "sprout-search-item-dot", children: "\u00B7" }), _jsx("span", { "data-slot": "search-item-meta", className: "sprout-search-item-meta", children: meta })] }))] }), detail && (_jsx("span", { "data-slot": "search-item-detail", className: "sprout-search-item-detail", children: detail }))] }), trailing && (_jsx("span", { "data-slot": "search-item-trailing", className: "sprout-search-item-trailing", children: trailing })), _jsx("kbd", { "aria-hidden": "true", className: "sprout-search-kbd sprout-search-item-enter", children: "\u21B5" })] }));
46
+ return (_jsxs(CommandItem, { value: value, onSelect: () => onSelect(), className: "sprout-search-item", children: [_jsx("span", { "data-slot": "search-item-media", className: "sprout-search-item-media", children: media }), _jsxs("span", { className: "sprout-search-item-text", children: [_jsxs("span", { className: "sprout-search-item-line", children: [_jsx("span", { "data-slot": "search-item-title", className: "sprout-search-item-title", children: title }), meta && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "sprout-search-item-dot", children: "\u00B7" }), _jsx("span", { "data-slot": "search-item-meta", className: "sprout-search-item-meta", children: meta })] }))] }), detail && (_jsx("span", { "data-slot": "search-item-detail", className: "sprout-search-item-detail", children: detail }))] }), trailing && (_jsx("span", { "data-slot": "search-item-trailing", className: "sprout-search-item-trailing", children: trailing })), _jsx(Kbd, { variant: "outline", "aria-hidden": "true", className: "sprout-search-item-enter", children: "\u21B5" })] }));
46
47
  }
47
48
  function tiles(media) {
48
49
  const nodes = isValidElement(media) && media.type === Fragment
@@ -60,5 +61,5 @@ export function SearchFooter({ children }) {
60
61
  }
61
62
  /** A key and what it does. */
62
63
  export function SearchHint({ keys, children }) {
63
- return (_jsxs("span", { "data-slot": "search-hint", className: "sprout-search-hint", children: [_jsx("kbd", { className: "sprout-search-kbd", children: keys }), children] }));
64
+ return (_jsxs("span", { "data-slot": "search-hint", className: "sprout-search-hint", children: [_jsx(Kbd, { variant: "outline", children: keys }), children] }));
64
65
  }
@@ -0,0 +1,76 @@
1
+ import { type HTMLAttributes } from "react";
2
+ export declare const sqlViewerLayoutNames: readonly ["auto", "horizontal", "vertical"];
3
+ export type SqlViewerLayout = (typeof sqlViewerLayoutNames)[number];
4
+ export type SqlViewerCardinality = "one" | "many";
5
+ export interface SqlViewerOption {
6
+ id: string;
7
+ label: string;
8
+ retired: boolean;
9
+ }
10
+ /** One field of a TulipFarm table definition (format 2) or table document (format 3). */
11
+ export type SqlViewerField = {
12
+ id: string;
13
+ label: string;
14
+ required: boolean;
15
+ } & ({
16
+ kind: "text";
17
+ maxLength: number;
18
+ } | {
19
+ kind: "numeric";
20
+ precision: number;
21
+ scale: number;
22
+ } | {
23
+ kind: "boolean";
24
+ } | {
25
+ kind: "date";
26
+ } | {
27
+ kind: "timestamp";
28
+ timezone?: "UTC";
29
+ } | {
30
+ kind: "single-select" | "multi-select";
31
+ options: readonly SqlViewerOption[];
32
+ } | {
33
+ kind: "relationship";
34
+ /** A table name (format 3) or table UUID (format 2), matching a table's `id`. */
35
+ targetTableId: string;
36
+ cardinality: SqlViewerCardinality;
37
+ inverseCardinality: SqlViewerCardinality;
38
+ reverse?: {
39
+ fieldId: string;
40
+ label: string;
41
+ } | null;
42
+ onDelete?: "restrict";
43
+ } | {
44
+ kind: "file";
45
+ cardinality: SqlViewerCardinality;
46
+ access?: "independent";
47
+ });
48
+ export interface SqlViewerUniqueKey {
49
+ id: string;
50
+ fieldIds: readonly string[];
51
+ }
52
+ /** A TulipFarm table definition or table document. Extra keys are ignored. */
53
+ export interface SqlViewerTable {
54
+ id: string;
55
+ label: string;
56
+ owningTeamId?: string;
57
+ fields: readonly SqlViewerField[];
58
+ uniqueKeys?: readonly SqlViewerUniqueKey[];
59
+ }
60
+ export interface SqlViewerProps extends Omit<HTMLAttributes<HTMLDivElement>, "role" | "children"> {
61
+ /** TulipFarm table definitions or table documents, in display order. */
62
+ tables: readonly SqlViewerTable[];
63
+ /** Names the diagram for assistive tech, such as “Tables in Sales”. */
64
+ label: string;
65
+ /** Team names by `owningTeamId`. Tables of a named team sit in one tinted group. */
66
+ groups?: Readonly<Record<string, string>> | undefined;
67
+ /** Tables shown before the rest collapse into one “+N tables” card. Default 4. */
68
+ maxVisible?: number | undefined;
69
+ /** `auto` stacks tables under 768px and uses columns otherwise. */
70
+ layout?: SqlViewerLayout | undefined;
71
+ }
72
+ /**
73
+ * A pan-and-zoom map of SQL tables, their columns and keys, and the joins
74
+ * between them. Hover or pin a table or a foreign key to trace its joins.
75
+ */
76
+ export declare function SqlViewer({ tables, label, groups, maxVisible, layout, className, ...props }: SqlViewerProps): import("react").JSX.Element;
@@ -0,0 +1,409 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Background, getSmoothStepPath, Handle, Panel, Position, ReactFlow, ReactFlowProvider, useInternalNode, useNodesState, } from "@xyflow/react";
4
+ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, } from "react";
5
+ import { Fingerprint } from "reicon-react/icons/Fingerprint";
6
+ import { Key } from "reicon-react/icons/Key";
7
+ import { Layers } from "reicon-react/icons/Layers";
8
+ import { Link } from "reicon-react/icons/Link";
9
+ import { cn } from "../internal/classes.js";
10
+ import { FitOnResize, flowMaxZoom, flowMinZoom, useKeyboardView, useViewportMobile, ZoomControls, } from "../internal/flow.js";
11
+ import { Button } from "./button.js";
12
+ export const sqlViewerLayoutNames = ["auto", "horizontal", "vertical"];
13
+ const moreId = "~more";
14
+ const groupId = (key) => `~group:${key}`;
15
+ // Card geometry. The CSS uses the same numbers so layout never waits for a measure.
16
+ const cardWidth = 272;
17
+ const cardChrome = 4;
18
+ const headerHeight = 40;
19
+ const rowHeight = 28;
20
+ const keysChrome = 36;
21
+ const keyHeight = 26;
22
+ const moreHeight = 132;
23
+ const colGap = 104;
24
+ const rowGap = 32;
25
+ const groupPad = 20;
26
+ const groupHeader = 44;
27
+ const groupGap = 56;
28
+ const wrapWidth = 1400;
29
+ const tones = 5;
30
+ const fitAll = { padding: 0.12, maxZoom: 1 };
31
+ const compositeKeys = (table) => (table.uniqueKeys ?? []).filter((key) => key.fieldIds.length > 1);
32
+ const cardHeight = (table) => {
33
+ const keys = compositeKeys(table).length;
34
+ return (cardChrome +
35
+ headerHeight +
36
+ (table.fields.length + 1) * rowHeight +
37
+ (keys ? keysChrome + keys * keyHeight : 0));
38
+ };
39
+ function columnType(field) {
40
+ switch (field.kind) {
41
+ case "text":
42
+ return `varchar(${field.maxLength})`;
43
+ case "numeric":
44
+ return `numeric(${field.precision},${field.scale})`;
45
+ case "timestamp":
46
+ return "timestamptz";
47
+ case "single-select":
48
+ return "enum";
49
+ case "multi-select":
50
+ return "enum[]";
51
+ case "relationship":
52
+ return field.cardinality === "one" ? "uuid" : "uuid[]";
53
+ case "file":
54
+ return field.cardinality === "one" ? "file" : "file[]";
55
+ default:
56
+ return field.kind;
57
+ }
58
+ }
59
+ function joinText(from, field, to, many) {
60
+ return many
61
+ ? `${to}.id = ANY(${from}.${field})`
62
+ : `${from}.${field} = ${to}.id`;
63
+ }
64
+ /** Places tables in team groups. Linked tables sit one column right of the tables that link to them. */
65
+ function arrange(visible, hidden, groups, direction) {
66
+ const blocks = [];
67
+ for (const table of visible) {
68
+ const team = table.owningTeamId;
69
+ const key = team !== undefined && groups?.[team] ? team : null;
70
+ let block = blocks.find((candidate) => candidate.key === key);
71
+ if (!block) {
72
+ block = { key, label: key ? groups[key] : "", tables: [] };
73
+ blocks.push(block);
74
+ }
75
+ block.tables.push(table);
76
+ }
77
+ const nodes = [];
78
+ let cursorX = 0;
79
+ let cursorY = 0;
80
+ let lineHeight = 0;
81
+ let right = 0;
82
+ let bottom = 0;
83
+ let tone = 0;
84
+ for (const block of blocks) {
85
+ const ids = new Set(block.tables.map((table) => table.id));
86
+ const depth = new Map(block.tables.map((table) => [table.id, 0]));
87
+ if (direction === "horizontal") {
88
+ for (let pass = 0; pass < block.tables.length; pass++) {
89
+ let moved = false;
90
+ for (const table of block.tables)
91
+ for (const field of table.fields) {
92
+ if (field.kind !== "relationship")
93
+ continue;
94
+ const target = field.targetTableId;
95
+ const next = depth.get(table.id) + 1;
96
+ if (target !== table.id &&
97
+ ids.has(target) &&
98
+ next > depth.get(target) &&
99
+ next < block.tables.length) {
100
+ depth.set(target, next);
101
+ moved = true;
102
+ }
103
+ }
104
+ if (!moved)
105
+ break;
106
+ }
107
+ }
108
+ const columnBottoms = new Map();
109
+ const placed = block.tables.map((table) => {
110
+ const column = depth.get(table.id);
111
+ const y = columnBottoms.get(column) ?? 0;
112
+ columnBottoms.set(column, y + cardHeight(table) + rowGap);
113
+ return { table, x: column * (cardWidth + colGap), y };
114
+ });
115
+ const innerWidth = Math.max(...placed.map((item) => item.x)) + cardWidth;
116
+ const innerHeight = Math.max(...columnBottoms.values()) - rowGap;
117
+ const framed = block.key !== null;
118
+ const width = framed ? innerWidth + groupPad * 2 : innerWidth;
119
+ const height = framed ? innerHeight + groupHeader + groupPad : innerHeight;
120
+ if (direction === "horizontal" &&
121
+ cursorX > 0 &&
122
+ cursorX + width > wrapWidth) {
123
+ cursorX = 0;
124
+ cursorY += lineHeight + groupGap;
125
+ lineHeight = 0;
126
+ }
127
+ const blockTone = framed ? (tone++ % tones) + 1 : 0;
128
+ const parent = framed ? groupId(block.key) : undefined;
129
+ if (framed)
130
+ nodes.push({
131
+ id: parent,
132
+ type: "group",
133
+ position: { x: cursorX, y: cursorY },
134
+ width,
135
+ height,
136
+ data: {
137
+ label: block.label,
138
+ tone: blockTone,
139
+ count: block.tables.length,
140
+ },
141
+ dragHandle: ".sprout-sql-group-label",
142
+ focusable: false,
143
+ selectable: false,
144
+ });
145
+ for (const item of placed)
146
+ nodes.push({
147
+ id: item.table.id,
148
+ type: "table",
149
+ position: framed
150
+ ? { x: groupPad + item.x, y: groupHeader + item.y }
151
+ : { x: cursorX + item.x, y: cursorY + item.y },
152
+ ...(parent ? { parentId: parent, expandParent: true } : {}),
153
+ data: { table: item.table, tone: blockTone },
154
+ ariaRole: "group",
155
+ ariaLabel: `${item.table.label} table, ${item.table.fields.length + 1} columns`,
156
+ domAttributes: { "aria-roledescription": "table card" },
157
+ });
158
+ right = Math.max(right, cursorX + width);
159
+ bottom = Math.max(bottom, cursorY + height);
160
+ if (direction === "horizontal") {
161
+ cursorX += width + groupGap;
162
+ lineHeight = Math.max(lineHeight, height);
163
+ }
164
+ else {
165
+ cursorY += height + groupGap;
166
+ }
167
+ }
168
+ if (hidden.length)
169
+ nodes.push({
170
+ id: moreId,
171
+ type: "more",
172
+ position: direction === "horizontal"
173
+ ? { x: right + colGap, y: 0 }
174
+ : { x: 0, y: bottom + groupGap },
175
+ data: { tables: [...hidden] },
176
+ focusable: false,
177
+ });
178
+ return nodes;
179
+ }
180
+ function joins(visible, hidden) {
181
+ const shown = new Set(visible.map((table) => table.id));
182
+ const collapsed = new Set(hidden.map((table) => table.id));
183
+ const labels = new Map([...visible, ...hidden].map((table) => [table.id, table.label]));
184
+ const node = (id) => shown.has(id) ? id : collapsed.has(id) ? moreId : null;
185
+ const edges = [];
186
+ for (const table of [...visible, ...hidden])
187
+ for (const field of table.fields) {
188
+ if (field.kind !== "relationship")
189
+ continue;
190
+ const source = node(table.id);
191
+ const target = node(field.targetTableId);
192
+ if (!source || !target || (source === moreId && target === moreId))
193
+ continue;
194
+ const to = labels.get(field.targetTableId);
195
+ const sourceMany = field.inverseCardinality === "many";
196
+ const targetMany = field.cardinality === "many";
197
+ edges.push({
198
+ id: `${table.id}/${field.id}`,
199
+ type: "join",
200
+ source,
201
+ sourceHandle: source === moreId ? "out:right" : `${field.id}:right`,
202
+ target,
203
+ targetHandle: target === moreId ? "in:left" : "id:left",
204
+ data: {
205
+ join: joinText(table.label, field.label, to, targetMany),
206
+ description: `${table.label}.${field.label} joins ${to}.id, ${sourceMany ? "many" : "one"} to ${targetMany ? "many" : "one"}`,
207
+ sourceMany,
208
+ targetMany,
209
+ },
210
+ });
211
+ }
212
+ return edges;
213
+ }
214
+ const ViewerContext = createContext(null);
215
+ const useViewer = () => useContext(ViewerContext);
216
+ function sideHandles(type, id) {
217
+ return (_jsxs(_Fragment, { children: [_jsx(Handle, { id: `${id}:left`, type: type, position: Position.Left, isConnectable: false, className: "sprout-sql-handle" }), _jsx(Handle, { id: `${id}:right`, type: type, position: Position.Right, isConnectable: false, className: "sprout-sql-handle" })] }));
218
+ }
219
+ function TableCard({ id, data: { table, tone } }) {
220
+ const viewer = useViewer();
221
+ const composite = compositeKeys(table);
222
+ const unique = new Set((table.uniqueKeys ?? [])
223
+ .filter((key) => key.fieldIds.length === 1)
224
+ .map((key) => key.fieldIds[0]));
225
+ const labels = new Map(table.fields.map((field) => [field.id, field.label]));
226
+ const focused = viewer.focus?.table === id;
227
+ return (_jsxs("section", { "data-slot": "sql-table", "data-tone": tone, "data-dimmed": viewer.focus && !viewer.related.has(id) ? "" : undefined, "data-active": focused ? "" : undefined, className: "sprout-sql-table", onMouseLeave: () => viewer.hover(null), children: [_jsx("header", { className: "sprout-sql-table-header", children: _jsx("h3", { className: "sprout-sql-table-title", title: table.label, children: table.label }) }), _jsxs("ul", { className: "sprout-sql-columns", children: [_jsxs("li", { className: "sprout-sql-column", "data-key": "primary", children: [sideHandles("target", "id"), _jsx("span", { className: "sprout-sql-column-icon", children: _jsx(Key, { size: 12, role: "img", "aria-label": "Primary key" }) }), _jsx("span", { className: "sprout-sql-column-name", children: "id" }), _jsx("span", { className: "sprout-sql-column-type", children: "uuid" })] }), table.fields.map((field) => {
228
+ const join = field.kind === "relationship";
229
+ const type = columnType(field);
230
+ return (_jsxs("li", { className: "sprout-sql-column", "data-join": join ? "" : undefined, "data-active": focused && viewer.focus?.field === field.id ? "" : undefined, title: field.kind === "single-select" || field.kind === "multi-select"
231
+ ? field.options
232
+ .filter((option) => !option.retired)
233
+ .map((option) => option.label)
234
+ .join(" · ")
235
+ : undefined, onMouseEnter: join
236
+ ? () => viewer.hover({ table: id, field: field.id })
237
+ : undefined, onMouseLeave: join ? () => viewer.hover({ table: id }) : undefined, onClick: join
238
+ ? (event) => {
239
+ event.stopPropagation();
240
+ viewer.pin({ table: id, field: field.id });
241
+ }
242
+ : undefined, children: [join && sideHandles("source", field.id), _jsx("span", { className: "sprout-sql-column-icon", children: join ? (_jsx(Link, { size: 12, role: "img", "aria-label": "Foreign key" })) : unique.has(field.id) ? (_jsx(Fingerprint, { size: 12, role: "img", "aria-label": "Unique" })) : null }), _jsx("span", { className: "sprout-sql-column-name", title: field.label, children: field.label }), _jsxs("span", { className: "sprout-sql-column-type", children: [type, !field.required && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: "?" }), _jsx("span", { className: "sprout-sql-sr-only", children: ", nullable" })] }))] })] }, field.id));
243
+ })] }), composite.length > 0 && (_jsxs("div", { className: "sprout-sql-keys", children: [_jsx("p", { className: "sprout-sql-keys-title", children: "Unique keys" }), _jsx("ul", { className: "sprout-sql-keys-list", children: composite.map((key) => (_jsxs("li", { className: "sprout-sql-key", children: [_jsx(Fingerprint, { size: 12, "aria-hidden": "true" }), _jsx("span", { children: key.fieldIds
244
+ .map((field) => labels.get(field) ?? field)
245
+ .join(", ") })] }, key.id))) })] }))] }));
246
+ }
247
+ function GroupFrame({ data: { label, tone, count } }) {
248
+ return (_jsx("section", { "data-slot": "sql-group", "data-tone": tone, "aria-label": `${label}, ${count} ${count === 1 ? "table" : "tables"}`, className: "sprout-sql-group", children: _jsxs("header", { className: "sprout-sql-group-label", children: [_jsx(Layers, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: "sprout-sql-group-name", children: label }), _jsx("span", { className: "sprout-sql-group-count", children: count })] }) }));
249
+ }
250
+ function MoreCard({ data: { tables } }) {
251
+ const { expand } = useViewer();
252
+ const names = tables.slice(0, 3).map((table) => table.label);
253
+ const rest = tables.length - names.length;
254
+ return (_jsxs("div", { "data-slot": "sql-more", className: "sprout-sql-more", children: [sideHandles("target", "in"), sideHandles("source", "out"), _jsx("span", { "aria-hidden": "true", className: "sprout-sql-more-sheet" }), _jsx("span", { "aria-hidden": "true", className: "sprout-sql-more-sheet" }), _jsxs("button", { type: "button", className: "nodrag sprout-sql-more-button", onClick: expand, "aria-label": `Show ${tables.length} more ${tables.length === 1 ? "table" : "tables"}: ${tables.map((table) => table.label).join(", ")}`, children: [_jsxs("span", { className: "sprout-sql-more-count", children: ["+", tables.length, " ", tables.length === 1 ? "table" : "tables"] }), _jsxs("span", { className: "sprout-sql-more-names", children: [names.join(", "), rest > 0 && ` and ${rest} more`] }), _jsx("span", { className: "sprout-sql-more-action", children: "Show all" })] })] }));
255
+ }
256
+ function handlePoint(node, type, id) {
257
+ const handle = node.internals.handleBounds?.[type]?.find((candidate) => candidate.id === id);
258
+ if (!handle)
259
+ return null;
260
+ return {
261
+ x: node.internals.positionAbsolute.x + handle.x + handle.width / 2,
262
+ y: node.internals.positionAbsolute.y + handle.y + handle.height / 2,
263
+ };
264
+ }
265
+ /** A crow's foot for many, a bar for one, drawn outward from the card edge. */
266
+ function endMark(x, y, side, many) {
267
+ const out = side === "right" ? 1 : -1;
268
+ return many
269
+ ? `M ${x + out * 12} ${y} L ${x} ${y - 6} M ${x + out * 12} ${y} L ${x} ${y + 6}`
270
+ : `M ${x + out * 9} ${y - 5} L ${x + out * 9} ${y + 5}`;
271
+ }
272
+ // Picks the facing sides of the two cards on every render, so links stay tidy while tables are dragged.
273
+ function JoinLine({ id, source, target, sourceHandleId, targetHandleId, data, }) {
274
+ const from = useInternalNode(source);
275
+ const to = useInternalNode(target);
276
+ const viewer = useViewer();
277
+ if (!from || !to || !data || !sourceHandleId || !targetHandleId)
278
+ return null;
279
+ const fromLeft = from.internals.positionAbsolute.x;
280
+ const toLeft = to.internals.positionAbsolute.x;
281
+ const fromRight = fromLeft + (from.measured.width ?? 0);
282
+ const toRight = toLeft + (to.measured.width ?? 0);
283
+ const [sourceSide, targetSide] = fromRight + 24 <= toLeft
284
+ ? ["right", "left"]
285
+ : toRight + 24 <= fromLeft
286
+ ? ["left", "right"]
287
+ : ["right", "right"];
288
+ const base = (handle) => handle.slice(0, handle.lastIndexOf(":"));
289
+ const start = handlePoint(from, "source", `${base(sourceHandleId)}:${sourceSide}`);
290
+ const end = handlePoint(to, "target", `${base(targetHandleId)}:${targetSide}`);
291
+ if (!start || !end)
292
+ return null;
293
+ const position = (side) => side === "right" ? Position.Right : Position.Left;
294
+ const [path] = getSmoothStepPath({
295
+ sourceX: start.x,
296
+ sourceY: start.y,
297
+ sourcePosition: position(sourceSide),
298
+ targetX: end.x,
299
+ targetY: end.y,
300
+ targetPosition: position(targetSide),
301
+ borderRadius: 10,
302
+ offset: 28,
303
+ });
304
+ const active = viewer.activeEdges.has(id);
305
+ return (_jsx("path", { d: `${path} ${endMark(start.x, start.y, sourceSide, data.sourceMany)} ${endMark(end.x, end.y, targetSide, data.targetMany)}`, className: "react-flow__edge-path sprout-sql-join", "data-active": active ? "" : undefined, "data-dimmed": viewer.focus && !active ? "" : undefined }));
306
+ }
307
+ const nodeTypes = { table: TableCard, group: GroupFrame, more: MoreCard };
308
+ const edgeTypes = { join: JoinLine };
309
+ const keyboardHint = "Arrow keys pan, plus and minus zoom, and 0 fits the diagram. Tab moves between tables; Enter pins a table's joins.";
310
+ function ViewerFlow({ layoutNodes, edges, direction, label, hintId, expanded, setExpanded, collapsible, maxVisible, }) {
311
+ const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
312
+ useEffect(() => setNodes(layoutNodes), [layoutNodes, setNodes]);
313
+ const [hovered, setHovered] = useState(null);
314
+ const [pinned, setPinned] = useState(null);
315
+ const focus = hovered ?? pinned;
316
+ const pin = useCallback((next) => setPinned((current) => current?.table === next.table && current.field === next.field
317
+ ? null
318
+ : next), []);
319
+ const state = useMemo(() => {
320
+ const activeEdges = new Set();
321
+ const related = new Set();
322
+ if (focus) {
323
+ related.add(focus.table);
324
+ for (const edge of edges) {
325
+ const hit = focus.field
326
+ ? edge.id === `${focus.table}/${focus.field}`
327
+ : edge.source === focus.table || edge.target === focus.table;
328
+ if (!hit)
329
+ continue;
330
+ activeEdges.add(edge.id);
331
+ related.add(edge.source).add(edge.target);
332
+ }
333
+ }
334
+ return {
335
+ focus,
336
+ related,
337
+ activeEdges,
338
+ hover: setHovered,
339
+ pin,
340
+ expand: () => setExpanded(true),
341
+ };
342
+ }, [focus, edges, pin, setExpanded]);
343
+ const shownEdges = useMemo(() => state.activeEdges.size
344
+ ? edges.map((edge) => state.activeEdges.has(edge.id) ? { ...edge, zIndex: 1000 } : edge)
345
+ : edges, [edges, state.activeEdges]);
346
+ // Phones fit the first block to the width and let people pan down; wider panes fit everything.
347
+ const fitFirst = useMemo(() => direction === "vertical" && layoutNodes[0]
348
+ ? { padding: 0.06, maxZoom: 1, nodes: [{ id: layoutNodes[0].id }] }
349
+ : fitAll, [direction, layoutNodes]);
350
+ const keyboardView = useKeyboardView(fitAll);
351
+ const onKeyDown = (event) => {
352
+ const target = event.target;
353
+ const id = target.dataset["id"];
354
+ if ((event.key === "Enter" || event.key === " ") &&
355
+ target.classList.contains("react-flow__node-table") &&
356
+ id) {
357
+ event.preventDefault();
358
+ pin({ table: id });
359
+ return;
360
+ }
361
+ if (event.key === "Escape" && pinned) {
362
+ setPinned(null);
363
+ return;
364
+ }
365
+ keyboardView(event);
366
+ };
367
+ const onFocus = (event) => {
368
+ const id = event.target.dataset["id"];
369
+ if (event.target.classList.contains("react-flow__node-table") && id)
370
+ setHovered({ table: id });
371
+ };
372
+ const onNodeMouseEnter = (_, node) => {
373
+ if (node.type === "table")
374
+ setHovered({ table: node.id });
375
+ };
376
+ const onNodeClick = (_, node) => {
377
+ if (node.type === "table")
378
+ pin({ table: node.id });
379
+ };
380
+ return (_jsx(ViewerContext.Provider, { value: state, children: _jsxs(ReactFlow, { className: "sprout-schema-flow sprout-sql-flow", "data-slot": "sql-viewer-viewport", "data-focus": focus ? "" : undefined, "aria-label": label, "aria-describedby": hintId, tabIndex: 0, onKeyDown: onKeyDown, onFocus: onFocus, onBlur: () => setHovered(null), nodes: nodes, edges: shownEdges, onNodesChange: onNodesChange, nodeTypes: nodeTypes, edgeTypes: edgeTypes, onNodeMouseEnter: onNodeMouseEnter, onNodeClick: onNodeClick, onPaneClick: () => setPinned(null), fitView: true, fitViewOptions: fitFirst, minZoom: flowMinZoom, maxZoom: flowMaxZoom, nodesConnectable: false, elementsSelectable: false, edgesFocusable: false, ariaLabelConfig: {
381
+ "node.a11yDescription.default": keyboardHint,
382
+ "node.a11yDescription.keyboardDisabled": keyboardHint,
383
+ }, proOptions: { hideAttribution: true }, children: [_jsx(Background, { gap: 16, size: 1 }), _jsx(FitOnResize, { nodes: layoutNodes, fit: fitFirst }), collapsible && expanded && (_jsx(Panel, { position: "top-left", "data-slot": "sql-viewer-collapse", children: _jsxs(Button, { variant: "secondary", size: "sm", onClick: () => {
384
+ setPinned(null);
385
+ setExpanded(false);
386
+ }, children: ["Show ", maxVisible, " tables"] }) })), state.activeEdges.size > 0 && (_jsxs(Panel, { position: "bottom-left", "data-slot": "sql-viewer-joins", "aria-hidden": "true", className: "sprout-sql-joins", children: [edges
387
+ .filter((edge) => state.activeEdges.has(edge.id))
388
+ .slice(0, 3)
389
+ .map((edge) => (_jsx("code", { className: "sprout-sql-join-text", children: edge.data.join }, edge.id))), state.activeEdges.size > 3 && (_jsxs("span", { className: "sprout-sql-joins-more", children: ["and ", state.activeEdges.size - 3, " more"] }))] })), _jsx(ZoomControls, { position: direction === "vertical" ? "top-right" : "bottom-right", fit: fitAll, className: "sprout-schema-controls", slot: "sql-viewer-controls" })] }) }));
390
+ }
391
+ /**
392
+ * A pan-and-zoom map of SQL tables, their columns and keys, and the joins
393
+ * between them. Hover or pin a table or a foreign key to trace its joins.
394
+ */
395
+ export function SqlViewer({ tables, label, groups, maxVisible = 4, layout = "auto", className, ...props }) {
396
+ const mobile = useViewportMobile();
397
+ const direction = layout === "auto" ? (mobile ? "vertical" : "horizontal") : layout;
398
+ const hintId = useId();
399
+ const [expanded, setExpanded] = useState(false);
400
+ const limit = Math.max(1, Math.floor(maxVisible));
401
+ const collapsible = tables.length > limit;
402
+ const [visible, hidden] = useMemo(() => collapsible && !expanded
403
+ ? [tables.slice(0, limit), tables.slice(limit)]
404
+ : [tables, []], [tables, limit, collapsible, expanded]);
405
+ const layoutNodes = useMemo(() => arrange(visible, hidden, groups, direction), [visible, hidden, groups, direction]);
406
+ const edges = useMemo(() => joins(visible, hidden), [visible, hidden]);
407
+ const descriptions = joins(tables, []);
408
+ return (_jsxs("div", { ...props, role: "figure", "aria-label": label, "data-slot": "sql-viewer", "data-layout": direction, className: cn("sprout-sql-viewer", className), children: [_jsx("p", { id: hintId, className: "sprout-sql-sr-only", children: keyboardHint }), descriptions.length > 0 && (_jsx("ul", { "aria-label": "Joins", className: "sprout-sql-sr-only", children: descriptions.map((edge) => (_jsx("li", { children: edge.data.description }, edge.id))) })), tables.length === 0 ? (_jsx("p", { className: "sprout-sql-empty", children: "No tables to show" })) : (_jsx(ReactFlowProvider, { children: _jsx(ViewerFlow, { layoutNodes: layoutNodes, edges: edges, direction: direction, label: label, hintId: hintId, expanded: expanded, setExpanded: setExpanded, collapsible: collapsible, maxVisible: limit }) }, direction))] }));
409
+ }
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check } from "reicon-react/icons/Check";
3
3
  import { Inbox } from "reicon-react/icons/Inbox";
4
- import { Loader } from "reicon-react/icons/Loader";
5
4
  import { Warning } from "reicon-react/icons/Warning";
6
5
  import { cn } from "../internal/classes.js";
6
+ import { Spinner } from "./spinner.js";
7
7
  export const statePanelKindNames = [
8
8
  "empty",
9
9
  "loading",
@@ -17,7 +17,7 @@ const roles = {
17
17
  success: "status",
18
18
  };
19
19
  function Media({ kind, Icon }) {
20
- return (_jsx("div", { "data-slot": "state-panel-media", className: "sprout-state-panel-media", "aria-hidden": "true", children: kind === "loading" ? (_jsx(Loader, { size: 16, className: "sprout-state-panel-spinner" })) : kind === "success" ? (_jsx(Check, { size: 20 })) : kind === "error" ? (_jsx(Warning, { size: 20, className: "sprout-state-panel-danger" })) : (_jsx(Icon, { size: 20 })) }));
20
+ return (_jsx("div", { "data-slot": "state-panel-media", className: "sprout-state-panel-media", "aria-hidden": "true", children: kind === "loading" ? (_jsx(Spinner, { "aria-hidden": "true" })) : kind === "success" ? (_jsx(Check, { size: 20 })) : kind === "error" ? (_jsx(Warning, { size: 20, className: "sprout-state-panel-danger" })) : (_jsx(Icon, { size: 20 })) }));
21
21
  }
22
22
  export function StatePanel({ kind, title, description, heading: Heading = "h2", icon = Inbox, children, sample, className, ...props }) {
23
23
  const media = icon === null && (kind === "empty" || kind === "unavailable") ? null : (_jsx(Media, { kind: kind, Icon: icon ?? Inbox }));
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components/accordion.js";
2
+ export * from "./components/agent-fanout.js";
2
3
  export * from "./components/alert.js";
3
4
  export * from "./components/alert-dialog.js";
4
5
  export * from "./components/aspect-ratio.js";
@@ -15,6 +16,7 @@ export * from "./components/card.js";
15
16
  export * from "./components/carousel.js";
16
17
  export * from "./components/chart.js";
17
18
  export * from "./components/chat-confirmation.js";
19
+ export * from "./components/chat-input.js";
18
20
  export * from "./components/chat-plan.js";
19
21
  export * from "./components/chat-sources.js";
20
22
  export * from "./components/checkbox.js";
@@ -28,6 +30,7 @@ export * from "./components/dialog.js";
28
30
  export * from "./components/direction.js";
29
31
  export * from "./components/dropdown-menu.js";
30
32
  export * from "./components/field.js";
33
+ export * from "./components/file-explorer.js";
31
34
  export * from "./components/file-viewer.js";
32
35
  export * from "./components/form.js";
33
36
  export * from "./components/glass-banner.js";
@@ -51,6 +54,7 @@ export * from "./components/page-header.js";
51
54
  export * from "./components/pagination.js";
52
55
  export * from "./components/plan-outline.js";
53
56
  export * from "./components/popover.js";
57
+ export * from "./components/presence-cursor.js";
54
58
  export * from "./components/progress.js";
55
59
  export * from "./components/prompt-list.js";
56
60
  export * from "./components/questionnaire.js";
@@ -61,6 +65,7 @@ export * from "./components/result-card.js";
61
65
  export * from "./components/review-card.js";
62
66
  export * from "./components/rich-text-editor.js";
63
67
  export * from "./components/schema-diagram.js";
68
+ export * from "./components/sql-viewer.js";
64
69
  export * from "./components/scroll-area.js";
65
70
  export * from "./components/search-dialog.js";
66
71
  export * from "./components/search-field.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components/accordion.js";
2
+ export * from "./components/agent-fanout.js";
2
3
  export * from "./components/alert.js";
3
4
  export * from "./components/alert-dialog.js";
4
5
  export * from "./components/aspect-ratio.js";
@@ -15,6 +16,7 @@ export * from "./components/card.js";
15
16
  export * from "./components/carousel.js";
16
17
  export * from "./components/chart.js";
17
18
  export * from "./components/chat-confirmation.js";
19
+ export * from "./components/chat-input.js";
18
20
  export * from "./components/chat-plan.js";
19
21
  export * from "./components/chat-sources.js";
20
22
  export * from "./components/checkbox.js";
@@ -28,6 +30,7 @@ export * from "./components/dialog.js";
28
30
  export * from "./components/direction.js";
29
31
  export * from "./components/dropdown-menu.js";
30
32
  export * from "./components/field.js";
33
+ export * from "./components/file-explorer.js";
31
34
  export * from "./components/file-viewer.js";
32
35
  export * from "./components/form.js";
33
36
  export * from "./components/glass-banner.js";
@@ -51,6 +54,7 @@ export * from "./components/page-header.js";
51
54
  export * from "./components/pagination.js";
52
55
  export * from "./components/plan-outline.js";
53
56
  export * from "./components/popover.js";
57
+ export * from "./components/presence-cursor.js";
54
58
  export * from "./components/progress.js";
55
59
  export * from "./components/prompt-list.js";
56
60
  export * from "./components/questionnaire.js";
@@ -61,6 +65,7 @@ export * from "./components/result-card.js";
61
65
  export * from "./components/review-card.js";
62
66
  export * from "./components/rich-text-editor.js";
63
67
  export * from "./components/schema-diagram.js";
68
+ export * from "./components/sql-viewer.js";
64
69
  export * from "./components/scroll-area.js";
65
70
  export * from "./components/search-dialog.js";
66
71
  export * from "./components/search-field.js";