@iloveagents/foundry-web-ui 0.1.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/AGENTS.md +59 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +61 -0
- package/src/__tests__/no-spaces-imports.test.ts +59 -0
- package/src/__tests__/open-core-guards.test.ts +307 -0
- package/src/__tests__/theme-runtime.test.ts +81 -0
- package/src/__tests__/tool-fallback.test.tsx +109 -0
- package/src/components/ag-ui-runtime-provider.tsx +59 -0
- package/src/components/app-brand.tsx +38 -0
- package/src/components/assistant-chat.tsx +423 -0
- package/src/components/chat-attachments.tsx +68 -0
- package/src/components/chat-bubble.tsx +339 -0
- package/src/components/chat-header.tsx +44 -0
- package/src/components/client-tool-executor.tsx +230 -0
- package/src/components/collection-empty-state.tsx +37 -0
- package/src/components/collection-filter-bar.tsx +168 -0
- package/src/components/collection-search-input.tsx +41 -0
- package/src/components/collection-skeleton.tsx +55 -0
- package/src/components/collection-sort-menu.tsx +62 -0
- package/src/components/collection-surface.tsx +46 -0
- package/src/components/collection-toolbar.tsx +33 -0
- package/src/components/collection-view-toggle.tsx +61 -0
- package/src/components/confirm-dialog.tsx +49 -0
- package/src/components/confirmation-card.tsx +32 -0
- package/src/components/context-badges.tsx +124 -0
- package/src/components/context-bar.tsx +202 -0
- package/src/components/form-dialog.tsx +56 -0
- package/src/components/global-selection-popover.tsx +135 -0
- package/src/components/infinite-scroll-sentinel.tsx +48 -0
- package/src/components/json-viewer.tsx +232 -0
- package/src/components/loading-indicator.tsx +40 -0
- package/src/components/markdown-text.tsx +379 -0
- package/src/components/name-dialog.tsx +87 -0
- package/src/components/selection-popover.tsx +156 -0
- package/src/components/show-document-tool-ui.tsx +90 -0
- package/src/components/sidebar.test.tsx +182 -0
- package/src/components/sidebar.tsx +1174 -0
- package/src/components/surface-card.tsx +45 -0
- package/src/components/theme-runtime-provider.tsx +93 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/tool-call-card.tsx +126 -0
- package/src/components/tool-fallback.tsx +169 -0
- package/src/components/tool-panel-layout.tsx +36 -0
- package/src/components/tool-panel.tsx +233 -0
- package/src/components/tooltip-icon-button.tsx +28 -0
- package/src/components/user-menu.tsx +60 -0
- package/src/index.ts +170 -0
- package/src/lib/__tests__/ag-ui-adapter.test.ts +182 -0
- package/src/lib/__tests__/app-store.test.ts +330 -0
- package/src/lib/__tests__/attachment-adapter.test.ts +48 -0
- package/src/lib/__tests__/chat-bubble-store.test.ts +67 -0
- package/src/lib/__tests__/client-tools.test.ts +124 -0
- package/src/lib/__tests__/dev-store.test.ts +78 -0
- package/src/lib/__tests__/nav-config.test.ts +135 -0
- package/src/lib/__tests__/nav-dnd.test.ts +24 -0
- package/src/lib/__tests__/nav-store.test.ts +59 -0
- package/src/lib/__tests__/sidebar-store.test.ts +144 -0
- package/src/lib/__tests__/theme-store.test.ts +83 -0
- package/src/lib/__tests__/tool-panel-store.test.ts +61 -0
- package/src/lib/__tests__/use-page-context.test.ts +58 -0
- package/src/lib/ag-ui-adapter.ts +362 -0
- package/src/lib/app-store.ts +294 -0
- package/src/lib/attachment-adapter.ts +92 -0
- package/src/lib/auth-provider.tsx +144 -0
- package/src/lib/chat-bubble-store.ts +46 -0
- package/src/lib/client-tools.ts +293 -0
- package/src/lib/dev-store.ts +69 -0
- package/src/lib/nav-config.ts +292 -0
- package/src/lib/nav-dnd.ts +14 -0
- package/src/lib/nav-store.ts +76 -0
- package/src/lib/sidebar-store.ts +113 -0
- package/src/lib/theme-runtime.ts +660 -0
- package/src/lib/theme-store.ts +67 -0
- package/src/lib/tool-panel-store.ts +121 -0
- package/src/lib/use-new-conversation.test.tsx +124 -0
- package/src/lib/use-new-conversation.ts +33 -0
- package/src/lib/use-page-tools.ts +40 -0
- package/src/ui/collapsible.tsx +7 -0
- package/src/ui/dialog.tsx +109 -0
- package/src/ui/dropdown-menu.tsx +71 -0
- package/src/ui/popover.tsx +34 -0
- package/src/ui/select.tsx +15 -0
- package/src/ui/tooltip.tsx +30 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { findNavItem, DEFAULT_NAV_CONFIG, type NavGroup } from "../nav-config";
|
|
3
|
+
|
|
4
|
+
describe("findNavItem", () => {
|
|
5
|
+
it("finds item in default config", () => {
|
|
6
|
+
const result = findNavItem(DEFAULT_NAV_CONFIG, "/");
|
|
7
|
+
expect(result).not.toBeNull();
|
|
8
|
+
expect(result!.group).toBe("Pages");
|
|
9
|
+
expect(result!.item.label).toBe("Chat");
|
|
10
|
+
expect(result!.item.description).toBeTruthy();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for unknown path", () => {
|
|
14
|
+
expect(findNavItem(DEFAULT_NAV_CONFIG, "/unknown")).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("finds item across multiple groups", () => {
|
|
18
|
+
const config: NavGroup[] = [
|
|
19
|
+
...DEFAULT_NAV_CONFIG,
|
|
20
|
+
{
|
|
21
|
+
label: "Contracts",
|
|
22
|
+
items: [
|
|
23
|
+
{
|
|
24
|
+
to: "/contracts/123",
|
|
25
|
+
label: "Acme NDA",
|
|
26
|
+
icon: DEFAULT_NAV_CONFIG[0].items[0].icon,
|
|
27
|
+
description: "Review NDA",
|
|
28
|
+
meta: { contractId: "123" },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const result = findNavItem(config, "/contracts/123");
|
|
35
|
+
expect(result).not.toBeNull();
|
|
36
|
+
expect(result!.group).toBe("Contracts");
|
|
37
|
+
expect(result!.item.label).toBe("Acme NDA");
|
|
38
|
+
expect(result!.item.meta).toEqual({ contractId: "123" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns first match when paths duplicate across groups", () => {
|
|
42
|
+
const config: NavGroup[] = [
|
|
43
|
+
{
|
|
44
|
+
label: "A",
|
|
45
|
+
items: [{ to: "/x", label: "X-A", icon: DEFAULT_NAV_CONFIG[0].items[0].icon }],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: "B",
|
|
49
|
+
items: [{ to: "/x", label: "X-B", icon: DEFAULT_NAV_CONFIG[0].items[0].icon }],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
const result = findNavItem(config, "/x");
|
|
53
|
+
expect(result!.group).toBe("A");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("all default config items are findable", () => {
|
|
57
|
+
for (const group of DEFAULT_NAV_CONFIG) {
|
|
58
|
+
for (const item of group.items) {
|
|
59
|
+
const result = findNavItem(DEFAULT_NAV_CONFIG, item.to);
|
|
60
|
+
expect(result).not.toBeNull();
|
|
61
|
+
expect(result!.item.to).toBe(item.to);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("finds deeply nested child items", () => {
|
|
67
|
+
const icon = DEFAULT_NAV_CONFIG[0].items[0].icon;
|
|
68
|
+
const config: NavGroup[] = [
|
|
69
|
+
{
|
|
70
|
+
label: "Workspaces",
|
|
71
|
+
items: [
|
|
72
|
+
{
|
|
73
|
+
to: "/spaces/root",
|
|
74
|
+
label: "Workspace",
|
|
75
|
+
icon,
|
|
76
|
+
children: [
|
|
77
|
+
{
|
|
78
|
+
to: "/spaces/folder",
|
|
79
|
+
label: "Folder",
|
|
80
|
+
icon,
|
|
81
|
+
children: [
|
|
82
|
+
{
|
|
83
|
+
to: "/spaces/entity",
|
|
84
|
+
label: "Entity",
|
|
85
|
+
icon,
|
|
86
|
+
meta: { spacesEntityId: "entity" },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const result = findNavItem(config, "/spaces/entity");
|
|
97
|
+
expect(result).not.toBeNull();
|
|
98
|
+
expect(result!.item.label).toBe("Entity");
|
|
99
|
+
expect(result!.item.meta).toEqual({ spacesEntityId: "entity" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("uses the deepest prefix match for nested routes", () => {
|
|
103
|
+
const icon = DEFAULT_NAV_CONFIG[0].items[0].icon;
|
|
104
|
+
const config: NavGroup[] = [
|
|
105
|
+
{
|
|
106
|
+
label: "Workspaces",
|
|
107
|
+
items: [
|
|
108
|
+
{
|
|
109
|
+
to: "/spaces/root",
|
|
110
|
+
label: "Workspace",
|
|
111
|
+
icon,
|
|
112
|
+
children: [
|
|
113
|
+
{
|
|
114
|
+
to: "/spaces/root/views",
|
|
115
|
+
label: "Views",
|
|
116
|
+
icon,
|
|
117
|
+
children: [
|
|
118
|
+
{
|
|
119
|
+
to: "/spaces/root/views/contradictions",
|
|
120
|
+
label: "Contradictions",
|
|
121
|
+
icon,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const result = findNavItem(config, "/spaces/root/views/contradictions/details");
|
|
132
|
+
expect(result).not.toBeNull();
|
|
133
|
+
expect(result!.item.label).toBe("Contradictions");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getContainerDropZoneLabel, hasAnyDropTarget, hasExternalFilesData } from "../nav-dnd";
|
|
3
|
+
|
|
4
|
+
describe("nav-dnd helpers", () => {
|
|
5
|
+
it("detects external file drags from Files type alone", () => {
|
|
6
|
+
expect(
|
|
7
|
+
hasExternalFilesData({
|
|
8
|
+
types: ["Files"],
|
|
9
|
+
files: { length: 0 },
|
|
10
|
+
}),
|
|
11
|
+
).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("treats file-only targets as active drop targets", () => {
|
|
15
|
+
expect(hasAnyDropTarget(false, true)).toBe(true);
|
|
16
|
+
expect(hasAnyDropTarget(true, false)).toBe(true);
|
|
17
|
+
expect(hasAnyDropTarget(false, false)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("uses upload copy for external file container drops", () => {
|
|
21
|
+
expect(getContainerDropZoneLabel(true)).toBe("Upload to root");
|
|
22
|
+
expect(getContainerDropZoneLabel(false)).toBe("Move to root");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { useNavStore } from "../nav-store.ts";
|
|
3
|
+
import { DEFAULT_NAV_CONFIG } from "../nav-config.ts";
|
|
4
|
+
import type { NavGroup } from "../nav-config.ts";
|
|
5
|
+
|
|
6
|
+
const testGroup: NavGroup = {
|
|
7
|
+
label: "TestGroup",
|
|
8
|
+
items: [{ to: "/test", label: "Test", icon: {} as NavGroup["items"][0]["icon"] }],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("useNavStore", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
useNavStore.setState({ config: DEFAULT_NAV_CONFIG });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("initializes with DEFAULT_NAV_CONFIG", () => {
|
|
17
|
+
expect(useNavStore.getState().config).toBe(DEFAULT_NAV_CONFIG);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("setConfig", () => {
|
|
21
|
+
it("replaces entire config", () => {
|
|
22
|
+
useNavStore.getState().setConfig([testGroup]);
|
|
23
|
+
expect(useNavStore.getState().config).toEqual([testGroup]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("addGroup", () => {
|
|
28
|
+
it("appends new group", () => {
|
|
29
|
+
const before = useNavStore.getState().config.length;
|
|
30
|
+
useNavStore.getState().addGroup(testGroup);
|
|
31
|
+
expect(useNavStore.getState().config.length).toBe(before + 1);
|
|
32
|
+
expect(useNavStore.getState().config.at(-1)).toEqual(testGroup);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("replaces existing group with same label (preserves order)", () => {
|
|
36
|
+
useNavStore.getState().addGroup(testGroup);
|
|
37
|
+
const updated: NavGroup = { ...testGroup, description: "updated" };
|
|
38
|
+
useNavStore.getState().addGroup(updated);
|
|
39
|
+
|
|
40
|
+
const matches = useNavStore.getState().config.filter((g) => g.label === "TestGroup");
|
|
41
|
+
expect(matches.length).toBe(1);
|
|
42
|
+
expect(matches[0].description).toBe("updated");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("removeGroup", () => {
|
|
47
|
+
it("removes group by label", () => {
|
|
48
|
+
useNavStore.getState().addGroup(testGroup);
|
|
49
|
+
useNavStore.getState().removeGroup("TestGroup");
|
|
50
|
+
expect(useNavStore.getState().config.find((g) => g.label === "TestGroup")).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("no-op for non-existent label", () => {
|
|
54
|
+
const before = useNavStore.getState().config.length;
|
|
55
|
+
useNavStore.getState().removeGroup("NoSuchGroup");
|
|
56
|
+
expect(useNavStore.getState().config.length).toBe(before);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SIDEBAR_MAX_WIDTH,
|
|
4
|
+
SIDEBAR_MIN_WIDTH,
|
|
5
|
+
SIDEBAR_WIDTH,
|
|
6
|
+
useSidebarStore,
|
|
7
|
+
} from "../sidebar-store";
|
|
8
|
+
|
|
9
|
+
const SIDEBAR_WIDTH_VERSION = "2026-03-27-narrow";
|
|
10
|
+
|
|
11
|
+
const reset = () =>
|
|
12
|
+
useSidebarStore.setState({
|
|
13
|
+
isOpen: true,
|
|
14
|
+
isMobileOpen: false,
|
|
15
|
+
width: SIDEBAR_WIDTH,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const store = () => useSidebarStore.getState();
|
|
19
|
+
|
|
20
|
+
async function importFreshSidebarStore(
|
|
21
|
+
storageValue: string | null,
|
|
22
|
+
storageVersion: string | null = SIDEBAR_WIDTH_VERSION,
|
|
23
|
+
) {
|
|
24
|
+
vi.resetModules();
|
|
25
|
+
vi.stubGlobal("localStorage", {
|
|
26
|
+
getItem: vi.fn().mockImplementation((key: string) => {
|
|
27
|
+
if (key === "sidebar-width") return storageValue;
|
|
28
|
+
if (key === "sidebar-width-version") return storageVersion;
|
|
29
|
+
return null;
|
|
30
|
+
}),
|
|
31
|
+
setItem: vi.fn(),
|
|
32
|
+
});
|
|
33
|
+
return import("../sidebar-store");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("sidebar-store", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
reset();
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
vi.stubGlobal("localStorage", {
|
|
41
|
+
getItem: vi.fn(),
|
|
42
|
+
setItem: vi.fn(),
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("toggle", () => {
|
|
47
|
+
it("flips isOpen from true to false", () => {
|
|
48
|
+
store().toggle();
|
|
49
|
+
expect(store().isOpen).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("flips isOpen from false to true", () => {
|
|
53
|
+
useSidebarStore.setState({ isOpen: false });
|
|
54
|
+
store().toggle();
|
|
55
|
+
expect(store().isOpen).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("persists state to localStorage", () => {
|
|
59
|
+
store().toggle();
|
|
60
|
+
expect(localStorage.setItem).toHaveBeenCalledWith("sidebar", "false");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("open / close", () => {
|
|
65
|
+
it("open sets isOpen to true", () => {
|
|
66
|
+
useSidebarStore.setState({ isOpen: false });
|
|
67
|
+
store().open();
|
|
68
|
+
expect(store().isOpen).toBe(true);
|
|
69
|
+
expect(localStorage.setItem).toHaveBeenCalledWith("sidebar", "true");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("close sets isOpen to false", () => {
|
|
73
|
+
store().close();
|
|
74
|
+
expect(store().isOpen).toBe(false);
|
|
75
|
+
expect(localStorage.setItem).toHaveBeenCalledWith("sidebar", "false");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("mobile drawer", () => {
|
|
80
|
+
it("toggleMobile flips isMobileOpen", () => {
|
|
81
|
+
store().toggleMobile();
|
|
82
|
+
expect(store().isMobileOpen).toBe(true);
|
|
83
|
+
store().toggleMobile();
|
|
84
|
+
expect(store().isMobileOpen).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("closeMobile sets isMobileOpen to false", () => {
|
|
88
|
+
useSidebarStore.setState({ isMobileOpen: true });
|
|
89
|
+
store().closeMobile();
|
|
90
|
+
expect(store().isMobileOpen).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("setWidth", () => {
|
|
95
|
+
it("persists sidebar width to localStorage", () => {
|
|
96
|
+
store().setWidth(360);
|
|
97
|
+
vi.runAllTimers();
|
|
98
|
+
expect(store().width).toBe(360);
|
|
99
|
+
expect(localStorage.setItem).toHaveBeenCalledWith("sidebar-width", "360");
|
|
100
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
101
|
+
"sidebar-width-version",
|
|
102
|
+
SIDEBAR_WIDTH_VERSION,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("clamps width to the supported range", () => {
|
|
107
|
+
store().setWidth(999);
|
|
108
|
+
expect(store().width).toBe(SIDEBAR_MAX_WIDTH);
|
|
109
|
+
|
|
110
|
+
store().setWidth(100);
|
|
111
|
+
expect(store().width).toBe(SIDEBAR_MIN_WIDTH);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("initial width from localStorage", () => {
|
|
116
|
+
it("falls back to default width when sidebar-width is missing", async () => {
|
|
117
|
+
const { useSidebarStore: freshStore } = await importFreshSidebarStore(null);
|
|
118
|
+
expect(freshStore.getState().width).toBe(SIDEBAR_WIDTH);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("falls back to default width when sidebar-width is invalid", async () => {
|
|
122
|
+
const { useSidebarStore: freshStore } = await importFreshSidebarStore("not-a-number");
|
|
123
|
+
expect(freshStore.getState().width).toBe(SIDEBAR_WIDTH);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("uses stored width when within the supported range", async () => {
|
|
127
|
+
const { useSidebarStore: freshStore } = await importFreshSidebarStore("360");
|
|
128
|
+
expect(freshStore.getState().width).toBe(360);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("clamps stored width to the supported range", async () => {
|
|
132
|
+
const { useSidebarStore: largeStore } = await importFreshSidebarStore("999");
|
|
133
|
+
expect(largeStore.getState().width).toBe(SIDEBAR_MAX_WIDTH);
|
|
134
|
+
|
|
135
|
+
const { useSidebarStore: smallStore } = await importFreshSidebarStore("100");
|
|
136
|
+
expect(smallStore.getState().width).toBe(SIDEBAR_MIN_WIDTH);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("resets legacy stored widths when the version changes", async () => {
|
|
140
|
+
const { useSidebarStore: freshStore } = await importFreshSidebarStore("394", null);
|
|
141
|
+
expect(freshStore.getState().width).toBe(SIDEBAR_WIDTH);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Mock matchMedia before importing the store (jsdom doesn't implement it)
|
|
5
|
+
const matchMediaMock = vi.fn().mockReturnValue({
|
|
6
|
+
matches: false,
|
|
7
|
+
addEventListener: vi.fn(),
|
|
8
|
+
removeEventListener: vi.fn(),
|
|
9
|
+
});
|
|
10
|
+
Object.defineProperty(window, "matchMedia", { value: matchMediaMock, writable: true });
|
|
11
|
+
|
|
12
|
+
// Import after mock is in place (store initializes on import)
|
|
13
|
+
const { useThemeStore } = await import("../theme-store");
|
|
14
|
+
|
|
15
|
+
const store = () => useThemeStore.getState();
|
|
16
|
+
|
|
17
|
+
describe("theme-store", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
localStorage.clear();
|
|
20
|
+
document.documentElement.classList.remove("dark");
|
|
21
|
+
matchMediaMock.mockReturnValue({
|
|
22
|
+
matches: false,
|
|
23
|
+
addEventListener: vi.fn(),
|
|
24
|
+
removeEventListener: vi.fn(),
|
|
25
|
+
});
|
|
26
|
+
useThemeStore.setState({ mode: "system" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("setMode", () => {
|
|
30
|
+
it("applies dark class for dark mode", () => {
|
|
31
|
+
store().setMode("dark");
|
|
32
|
+
expect(store().mode).toBe("dark");
|
|
33
|
+
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
34
|
+
expect(localStorage.getItem("theme")).toBe("dark");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("removes dark class for light mode", () => {
|
|
38
|
+
store().setMode("dark");
|
|
39
|
+
store().setMode("light");
|
|
40
|
+
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
|
41
|
+
expect(localStorage.getItem("theme")).toBe("light");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("persists to localStorage", () => {
|
|
45
|
+
store().setMode("dark");
|
|
46
|
+
expect(localStorage.getItem("theme")).toBe("dark");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("cycle", () => {
|
|
51
|
+
it("cycles light -> dark -> system -> light", () => {
|
|
52
|
+
store().setMode("light");
|
|
53
|
+
store().cycle();
|
|
54
|
+
expect(store().mode).toBe("dark");
|
|
55
|
+
store().cycle();
|
|
56
|
+
expect(store().mode).toBe("system");
|
|
57
|
+
store().cycle();
|
|
58
|
+
expect(store().mode).toBe("light");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("system mode", () => {
|
|
63
|
+
it("respects OS dark preference", () => {
|
|
64
|
+
matchMediaMock.mockReturnValue({
|
|
65
|
+
matches: true,
|
|
66
|
+
addEventListener: vi.fn(),
|
|
67
|
+
removeEventListener: vi.fn(),
|
|
68
|
+
});
|
|
69
|
+
store().setMode("system");
|
|
70
|
+
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("respects OS light preference", () => {
|
|
74
|
+
matchMediaMock.mockReturnValue({
|
|
75
|
+
matches: false,
|
|
76
|
+
addEventListener: vi.fn(),
|
|
77
|
+
removeEventListener: vi.fn(),
|
|
78
|
+
});
|
|
79
|
+
store().setMode("system");
|
|
80
|
+
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { useToolPanelStore } from "../tool-panel-store";
|
|
3
|
+
|
|
4
|
+
const reset = () =>
|
|
5
|
+
useToolPanelStore.setState({
|
|
6
|
+
isOpen: false,
|
|
7
|
+
content: null,
|
|
8
|
+
isFullscreen: false,
|
|
9
|
+
// Node env fallbacks
|
|
10
|
+
panelWidth: 800,
|
|
11
|
+
useOverlay: true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const store = () => useToolPanelStore.getState();
|
|
15
|
+
|
|
16
|
+
const content = { title: "Test", content: "body", type: "markdown" as const };
|
|
17
|
+
|
|
18
|
+
describe("tool-panel-store", () => {
|
|
19
|
+
beforeEach(reset);
|
|
20
|
+
|
|
21
|
+
describe("toggleFullscreen", () => {
|
|
22
|
+
it("entering fullscreen sets overlay and expands width", () => {
|
|
23
|
+
store().toggleFullscreen();
|
|
24
|
+
expect(store().isFullscreen).toBe(true);
|
|
25
|
+
expect(store().useOverlay).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("exiting fullscreen restores layout defaults", () => {
|
|
29
|
+
store().toggleFullscreen(); // on
|
|
30
|
+
store().toggleFullscreen(); // off
|
|
31
|
+
expect(store().isFullscreen).toBe(false);
|
|
32
|
+
// jsdom: innerWidth=1024 < 1280, so overlay=true, panelWidth=1024
|
|
33
|
+
expect(store().useOverlay).toBe(true);
|
|
34
|
+
expect(store().panelWidth).toBeLessThanOrEqual(1280);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("updateLayout", () => {
|
|
39
|
+
it("is a no-op when fullscreen", () => {
|
|
40
|
+
store().openPanel(content);
|
|
41
|
+
store().toggleFullscreen();
|
|
42
|
+
const widthBefore = store().panelWidth;
|
|
43
|
+
store().updateLayout();
|
|
44
|
+
expect(store().panelWidth).toBe(widthBefore);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("recalculates when not fullscreen", () => {
|
|
48
|
+
store().updateLayout();
|
|
49
|
+
// jsdom: innerWidth=1024 < 1280, so overlay=true
|
|
50
|
+
expect(store().useOverlay).toBe(true);
|
|
51
|
+
expect(store().panelWidth).toBeLessThanOrEqual(1280);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("SSR / Node fallbacks", () => {
|
|
56
|
+
it("defaults to panelWidth=800 and useOverlay=true without window", () => {
|
|
57
|
+
expect(store().panelWidth).toBe(800);
|
|
58
|
+
expect(store().useOverlay).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for usePageContext — tests the underlying store operations
|
|
3
|
+
* since the hook is a thin mount/unmount wrapper over addContextItem/removeContextItem.
|
|
4
|
+
*/
|
|
5
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { useAppStore } from "../app-store";
|
|
7
|
+
|
|
8
|
+
const reset = () =>
|
|
9
|
+
useAppStore.setState({
|
|
10
|
+
contextItems: [],
|
|
11
|
+
sentContext: {},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const store = () => useAppStore.getState();
|
|
15
|
+
|
|
16
|
+
const noteItem = () => ({
|
|
17
|
+
type: "note" as const,
|
|
18
|
+
label: "Dashboard metrics",
|
|
19
|
+
payload: { kind: "text" as const, text: "Q1 revenue data" },
|
|
20
|
+
sourcePage: "/dashboard",
|
|
21
|
+
persistence: "ephemeral" as const,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("usePageContext (store operations)", () => {
|
|
25
|
+
beforeEach(reset);
|
|
26
|
+
|
|
27
|
+
it("addContextItem adds note items", () => {
|
|
28
|
+
store().addContextItem(noteItem());
|
|
29
|
+
expect(store().contextItems).toHaveLength(1);
|
|
30
|
+
expect(store().contextItems[0].label).toBe("Dashboard metrics");
|
|
31
|
+
expect(store().contextItems[0].type).toBe("note");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("removeContextItem removes by id (simulates unmount cleanup)", () => {
|
|
35
|
+
store().addContextItem(noteItem());
|
|
36
|
+
const id = store().contextItems[0].id;
|
|
37
|
+
|
|
38
|
+
store().removeContextItem(id);
|
|
39
|
+
expect(store().contextItems).toHaveLength(0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("re-adding after removal works (simulates remount)", () => {
|
|
43
|
+
store().addContextItem(noteItem());
|
|
44
|
+
const id = store().contextItems[0].id;
|
|
45
|
+
store().removeContextItem(id);
|
|
46
|
+
|
|
47
|
+
store().addContextItem(noteItem());
|
|
48
|
+
expect(store().contextItems).toHaveLength(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ephemeral note items are cleared on navigation", () => {
|
|
52
|
+
store().addContextItem(noteItem());
|
|
53
|
+
expect(store().contextItems).toHaveLength(1);
|
|
54
|
+
|
|
55
|
+
store().setCurrentPage("/other-page");
|
|
56
|
+
expect(store().contextItems).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
});
|