create-keboola-app 0.0.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/README.md +79 -0
- package/dist/blocks-DYU5Ke29.js +147 -0
- package/dist/cli.js +451 -0
- package/dist/extract.js +133 -0
- package/dist/regions-B3r5LhXl.js +95 -0
- package/dist/sanitize-CKs8pAzZ.js +158 -0
- package/dist/sanitize.js +2 -0
- package/dist/template/AGENTS.md +133 -0
- package/dist/template/README.md +56 -0
- package/dist/template/_env.example +6 -0
- package/dist/template/_gitignore +7 -0
- package/dist/template/_oxfmtrc.json +18 -0
- package/dist/template/index.html +13 -0
- package/dist/template/oxlint.config.ts +43 -0
- package/dist/template/package.json +53 -0
- package/dist/template/public/favicon.svg +10 -0
- package/dist/template/src/components/AppChrome.tsx +201 -0
- package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
- package/dist/template/src/components/AssistantPanel.tsx +96 -0
- package/dist/template/src/components/AssistantToggle.tsx +37 -0
- package/dist/template/src/components/ColorModeToggle.tsx +15 -0
- package/dist/template/src/components/HomePrompt.tsx +35 -0
- package/dist/template/src/components/KpiCard.test.tsx +20 -0
- package/dist/template/src/components/KpiCard.tsx +24 -0
- package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
- package/dist/template/src/components/SelectionFloater.tsx +88 -0
- package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
- package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
- package/dist/template/src/index.css +26 -0
- package/dist/template/src/main.tsx +57 -0
- package/dist/template/src/nav.test.ts +25 -0
- package/dist/template/src/nav.ts +38 -0
- package/dist/template/src/overview-sections.test.tsx +36 -0
- package/dist/template/src/overview-sections.tsx +40 -0
- package/dist/template/src/recipes/Chart.test.tsx +24 -0
- package/dist/template/src/recipes/Chart.tsx +56 -0
- package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
- package/dist/template/src/recipes/DataGrid.tsx +151 -0
- package/dist/template/src/routes/-fixtures.ts +113 -0
- package/dist/template/src/routes/-hooks.ts +15 -0
- package/dist/template/src/routes/-index.test.tsx +123 -0
- package/dist/template/src/routes/__root.tsx +20 -0
- package/dist/template/src/routes/index.tsx +95 -0
- package/dist/template/src/routes/tokens/$id.tsx +95 -0
- package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
- package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
- package/dist/template/src/routes/tokens/-hooks.ts +37 -0
- package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
- package/dist/template/src/routes/tokens/-nav.ts +17 -0
- package/dist/template/src/routes/tokens/-types.ts +22 -0
- package/dist/template/src/routes/tokens/index.tsx +182 -0
- package/dist/template/src/routes/tokens/new.tsx +163 -0
- package/dist/template/src/sections/chart.tsx +34 -0
- package/dist/template/src/sections/grid.tsx +21 -0
- package/dist/template/src/sections/kpi.tsx +36 -0
- package/dist/template/src/slices/assistant.ts +68 -0
- package/dist/template/src/slices/shell.ts +54 -0
- package/dist/template/src/store.test.tsx +92 -0
- package/dist/template/src/store.ts +67 -0
- package/dist/template/src/test-setup.ts +11 -0
- package/dist/template/src/vite-env.d.ts +1 -0
- package/dist/template/templates/agent-safety/README.md +14 -0
- package/dist/template/templates/agent-safety/aiexclude +25 -0
- package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
- package/dist/template/templates/agent-safety/cursorignore +25 -0
- package/dist/template/tsconfig.json +10 -0
- package/dist/template/vercel.json +4 -0
- package/dist/template/vite.config.ts +21 -0
- package/dist/template/vitest.config.ts +21 -0
- package/dist/template/workspace-deps.json +7 -0
- package/package.json +64 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.12"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "vite build",
|
|
11
|
+
"dev": "vite",
|
|
12
|
+
"lint": "oxfmt --check && oxlint .",
|
|
13
|
+
"lint:fix": "oxlint --fix . && oxfmt",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"type-check": "tsc"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@keboola/brand-registry": "^1.9.2",
|
|
19
|
+
"@keboola/design": "^11.0.1",
|
|
20
|
+
"@tanstack/react-form": "^1.29.1",
|
|
21
|
+
"@tanstack/react-query": "^5.96.2",
|
|
22
|
+
"@tanstack/react-router": "^1.120.4",
|
|
23
|
+
"@tanstack/react-table": "^8.21.3",
|
|
24
|
+
"@tanstack/router-plugin": "^1.120.4",
|
|
25
|
+
"react": "^19.2.7",
|
|
26
|
+
"react-dom": "^19.2.7",
|
|
27
|
+
"zod": "^4.3.6",
|
|
28
|
+
"zustand": "^5.0.8"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@keboola/eslint-plugin-design": "^0.4.2",
|
|
32
|
+
"@keboola/oxlint-config": "^10.0.1",
|
|
33
|
+
"@keboola/tsconfig": "^0.2.0",
|
|
34
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
35
|
+
"@tanstack/react-query-devtools": "^5.96.2",
|
|
36
|
+
"@testing-library/jest-dom": "^6.8.0",
|
|
37
|
+
"@testing-library/react": "^16.3.2",
|
|
38
|
+
"@testing-library/user-event": "^14.6.1",
|
|
39
|
+
"@types/node": "24.12.0",
|
|
40
|
+
"@types/react": "^19.2.15",
|
|
41
|
+
"@types/react-dom": "^19.2.3",
|
|
42
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
43
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
44
|
+
"eslint-plugin-better-tailwindcss": "^4.7.0",
|
|
45
|
+
"jsdom": "^26.1.0",
|
|
46
|
+
"oxfmt": "^0.57.0",
|
|
47
|
+
"oxlint": "^1.72.0",
|
|
48
|
+
"tailwindcss": "^4.3.3",
|
|
49
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
50
|
+
"vite": "^8.1.5",
|
|
51
|
+
"vitest": "^4.1.10"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 512">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="kbc" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#61E4FF" />
|
|
5
|
+
<stop offset="0.5" stop-color="#1F8FFF" />
|
|
6
|
+
<stop offset="1" stop-color="#7C5CFF" />
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<path fill="url(#kbc)" d="M152 320C79.7 320 24.5 233.9 24.5 124.7C24.5 15.6 70.5 0 152 0s123.6 15.6 123.6 124.7C275.5 233.9 217.7 320 152 320zm-8.7-50.5c0-11.2-9.5-20.3-21.2-20.3s-21.2 9.1-21.2 20.3c0 11.2 9.5 20.3 21.2 20.3s21.2-9.1 21.2-20.3zm55.7 0c0-11.2-9.5-20.3-21.2-20.3s-21.2 9.1-21.2 20.3c0 11.2 9.5 20.3 21.2 20.3s21.2-9.1 21.2-20.3zm68.9 115.1c10.2 9.3 20.2 20.5 29 35c5.9 9.7 2.6 22.1-7.5 27.8c-3.4 1.9-7.1 2.8-10.7 2.8c-7.2 0-14.3-3.6-18.3-10c-6.8-11.1-13.8-18.9-21.7-26.1c-7.3-6.7-15.6-12.7-24.9-19.5c-.7-.5-1.4-1-2.1-1.5c-5-3.6-10-6.8-14.9-9.4c.6 1 1.2 2.1 1.8 3.2c14.4 26.4 29.2 63.1 29.2 104.9c0 11.2-9.5 20.3-21.2 20.3c-11.7 0-21.2-9.1-21.2-20.3c.1-31.8-11.6-62.3-23.8-84.9c-4-7.4-8-13.9-11.6-19.3c-3.4 5.2-7.2 11.3-11 18.3c-12.5 22.7-24.5 53.8-24.4 86c0 11.2-9.5 20.3-21.2 20.3s-21.2-9.1-21.2-20.3c.1-41.8 14.9-78.4 29.2-104.9c.6-1.1 1.2-2.1 1.8-3.2c-5 2.7-9.9 5.8-14.9 9.4c-.6 .5-1.3 .9-1.9 1.4c-9.5 6.9-17.8 12.9-25.1 19.7c-7.9 7.2-14.9 15.1-21.7 26.1c-5.9 9.7-18.9 12.9-29 7.2C.5 441.8-2.9 429.3 3.1 419.7c8.9-14.4 18.8-25.7 29-35c9.2-8.4 18.5-15.1 27.6-21.7c1-.7 2-1.4 3-2.2c4.7-3.5 9.6-6.7 14.6-9.6c-5-2.9-9.8-6.1-14.6-9.6C39.8 325 19.9 302.5 3.1 275.1c-5.9-9.7-2.6-22.1 7.5-27.8c10.1-5.7 23.1-2.5 29 7.2c14.6 23.7 31.1 42 48.7 54.9c18.9 13.7 37.4 20.5 56.5 21.5c1.7-.1 3.5-.1 5.2-.1c1.8 0 3.5 .1 5.2 .1c0 0 0 0 0 0c19.1-1.1 37.6-7.9 56.5-21.5c17.6-12.8 34.2-31.2 48.7-54.9c6-9.7 18.9-12.9 29-7.2c10.1 5.7 13.4 18.1 7.5 27.8c-16.8 27.4-36.7 49.9-59.6 66.6c-4.7 3.5-9.6 6.7-14.5 9.6c4.9 2.9 9.8 6.1 14.5 9.6c.9 .7 1.8 1.3 2.7 2c9.2 6.7 18.5 13.5 27.9 22z" />
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Link, useRouterState } from '@tanstack/react-router';
|
|
3
|
+
|
|
4
|
+
import { AppShell, BrandLogo, Button, Navbar, Sidebar, SidebarProvider } from '@keboola/design';
|
|
5
|
+
|
|
6
|
+
import { isActive, NAV } from '../nav';
|
|
7
|
+
import { useAppStore } from '../store';
|
|
8
|
+
|
|
9
|
+
// #region block:assistant
|
|
10
|
+
import { AssistantPanel } from './AssistantPanel';
|
|
11
|
+
import { AssistantToggle } from './AssistantToggle';
|
|
12
|
+
// #endregion block:assistant
|
|
13
|
+
import { ColorModeToggle } from './ColorModeToggle';
|
|
14
|
+
import { SidebarModeMenu } from './SidebarModeMenu';
|
|
15
|
+
|
|
16
|
+
// Chrome is composed inline (not a shared package) so the AppShell/Navbar/Sidebar
|
|
17
|
+
// wiring is legible in this exemplar. The Navbar composition follows kbc-ui's
|
|
18
|
+
// AppSiteHeader — Header (brand) / Content (primary nav) / Footer (actions).
|
|
19
|
+
// The rail's destinations and the active-match rule live in `src/nav.ts`.
|
|
20
|
+
export function AppChrome({ children }: { children: ReactNode }) {
|
|
21
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
22
|
+
const helpVisible = useAppStore((s) => s.helpVisible);
|
|
23
|
+
const toggleHelp = useAppStore((s) => s.toggleHelp);
|
|
24
|
+
// #region block:assistant
|
|
25
|
+
const fullscreen = useAppStore((s) => s.fullscreen);
|
|
26
|
+
// #endregion block:assistant
|
|
27
|
+
const sidebarMode = useAppStore((s) => s.sidebarMode);
|
|
28
|
+
const setSidebarMode = useAppStore((s) => s.setSidebarMode);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<SidebarProvider
|
|
32
|
+
// Left rail — app navigation.
|
|
33
|
+
// - Controlled: the mode is a persisted user preference, and the store is its
|
|
34
|
+
// single owner. `SidebarModeMenu` in the rail's footer writes it.
|
|
35
|
+
leftMode={sidebarMode}
|
|
36
|
+
onLeftModeChange={setSidebarMode}
|
|
37
|
+
leftVariant="inset"
|
|
38
|
+
// `icon` collapse keeps every destination reachable; `off-canvas` would hide them
|
|
39
|
+
// all. It is also what `expand-on-hover` collapses to between hovers.
|
|
40
|
+
leftCollapsible="icon"
|
|
41
|
+
// #region block:assistant
|
|
42
|
+
// Right rail — the assistant. `rightVariant` is required: a `Sidebar` whose variant
|
|
43
|
+
// is `none` (the provider's default) renders NOTHING, silently, so configuring only
|
|
44
|
+
// the left side makes the assistant disappear with no error. Mirrors kbc-ui's Kai
|
|
45
|
+
// rail, which uses the same pair.
|
|
46
|
+
rightVariant="sided-floating"
|
|
47
|
+
rightCollapsible="off-canvas"
|
|
48
|
+
// Closed until asked for. The assistant being *available* on every route is
|
|
49
|
+
// the point; holding ~30% of the viewport open on a transcript nobody has
|
|
50
|
+
// started is not. The Navbar's AssistantToggle opens it (and badges unread
|
|
51
|
+
// replies seeded from the page's prompt while it's shut).
|
|
52
|
+
defaultRightMode="collapsed"
|
|
53
|
+
// Matches kbc-ui's Kai rail. The provider's default is narrower than a chat
|
|
54
|
+
// transcript wants — message bubbles wrap after a few words at that width.
|
|
55
|
+
rightWidth={384}
|
|
56
|
+
// #endregion block:assistant
|
|
57
|
+
>
|
|
58
|
+
{/* `headerSpan="full"` puts the header above both rails so the brand mark aligns to
|
|
59
|
+
the viewport's left edge — the geometry kbc-ui uses. Without it the left rail
|
|
60
|
+
owns the top-left corner and the bar starts beside it. */}
|
|
61
|
+
<AppShell headerSpan="full">
|
|
62
|
+
{/* `flush` drops the slot's padding, background AND bottom border, so the Navbar
|
|
63
|
+
renders edge to edge. The border is restated here.
|
|
64
|
+
- Both rails below carry a hairline; without a matching edge under the header
|
|
65
|
+
those lines start mid-air instead of closing a frame.
|
|
66
|
+
- `sidebar-border`, not `border`: the two meet at the corners, and `border`
|
|
67
|
+
is 4 units lighter. */}
|
|
68
|
+
<AppShell.Header flush className="tw:border-b tw:border-sidebar-border">
|
|
69
|
+
{/* `flex-1` makes the bar fill the header — as a flex item it would otherwise
|
|
70
|
+
shrink to its content width. */}
|
|
71
|
+
<Navbar bordered className="tw:flex-1">
|
|
72
|
+
{/* Extra left gutter, set here rather than on `Navbar`: the bar's own
|
|
73
|
+
`pl-2` comes from a cva, which concatenates instead of tailwind-merging,
|
|
74
|
+
so a competing `pl-*` there would be settled by cascade order. This slot
|
|
75
|
+
merges with `cn`. The DS default is tuned for the header sitting beside
|
|
76
|
+
the rail; with `headerSpan="full"` the logo would otherwise hug the
|
|
77
|
+
viewport edge instead of lining up with the rail's icons below it.
|
|
78
|
+
`pl-2.5` on top of the bar's `pl-2` puts the logo at 18px — the same
|
|
79
|
+
x as the rail's icons, measured rather than guessed. */}
|
|
80
|
+
<Navbar.Header className="tw:pl-2.5">
|
|
81
|
+
<Link to="/" aria-label="Home" className="tw:flex tw:items-center">
|
|
82
|
+
<BrandLogo className="tw:h-6" />
|
|
83
|
+
</Link>
|
|
84
|
+
</Navbar.Header>
|
|
85
|
+
|
|
86
|
+
{/* Navbar.Content is the flexible middle band: it carries `flex-1` inside the
|
|
87
|
+
bar, so it is what pushes Navbar.Footer out to the right edge. */}
|
|
88
|
+
<Navbar.Content>
|
|
89
|
+
<Navbar.Menu marker="forward-slash">
|
|
90
|
+
<Navbar.MenuItem>
|
|
91
|
+
<Navbar.MenuButton asChild>
|
|
92
|
+
<Link to="/">Boilerplate</Link>
|
|
93
|
+
</Navbar.MenuButton>
|
|
94
|
+
</Navbar.MenuItem>
|
|
95
|
+
</Navbar.Menu>
|
|
96
|
+
</Navbar.Content>
|
|
97
|
+
|
|
98
|
+
<Navbar.Footer>
|
|
99
|
+
<Button
|
|
100
|
+
variant="invisible"
|
|
101
|
+
size="small"
|
|
102
|
+
onClick={toggleHelp}
|
|
103
|
+
aria-pressed={helpVisible}
|
|
104
|
+
>
|
|
105
|
+
{helpVisible ? 'Hide help' : 'Show help'}
|
|
106
|
+
</Button>
|
|
107
|
+
{/* #region block:assistant */}
|
|
108
|
+
<AssistantToggle />
|
|
109
|
+
{/* #endregion block:assistant */}
|
|
110
|
+
<ColorModeToggle />
|
|
111
|
+
</Navbar.Footer>
|
|
112
|
+
</Navbar>
|
|
113
|
+
</AppShell.Header>
|
|
114
|
+
|
|
115
|
+
<AppShell.Sidebar>
|
|
116
|
+
{/* Elevation while `expand-on-hover` is hovered open. That mode widens the rail
|
|
117
|
+
without widening the column it occupies, so the expanded rail overlays the
|
|
118
|
+
page, and `inset`'s 1px `border-r` does not read as "above".
|
|
119
|
+
|
|
120
|
+
App-scoped rather than a `Sidebar` variant rule: kbc-ui runs this mode by
|
|
121
|
+
default and paints its own corner over the rail's right edge with tuned
|
|
122
|
+
`::before` / `::after` wedges, so a shadow from the design side would land
|
|
123
|
+
on that seam unreviewed. Promote it once that composition has been looked
|
|
124
|
+
at in a browser. */}
|
|
125
|
+
<Sidebar
|
|
126
|
+
side="left"
|
|
127
|
+
className="tw:group-data-[mode=expand-on-hover]/sidebar:group-data-[state=expanded]/sidebar:[&>[data-slot=sidebar]]:shadow-lift"
|
|
128
|
+
>
|
|
129
|
+
<Sidebar.Content>
|
|
130
|
+
{/* AppShell.Sidebar and Sidebar are plain divs, so without this the rail's
|
|
131
|
+
links sit outside any landmark — axe flags the content as unreachable by
|
|
132
|
+
landmark navigation. Labelled distinctly from the Navbar's "Main
|
|
133
|
+
navigation" so the two are tellable apart in a landmark list. */}
|
|
134
|
+
<nav aria-label="Sections">
|
|
135
|
+
<Sidebar.Menu>
|
|
136
|
+
{NAV.map((item) => (
|
|
137
|
+
<Sidebar.MenuItem key={item.to}>
|
|
138
|
+
{/* The slot renders an <a>, so the active state is `aria-current="page"`
|
|
139
|
+
— `aria-selected` is not a supported attribute on a link. Both get
|
|
140
|
+
the same active styling from Sidebar.MenuButton. */}
|
|
141
|
+
<Sidebar.MenuButton
|
|
142
|
+
asChild
|
|
143
|
+
tooltip={item.label}
|
|
144
|
+
aria-current={isActive(pathname, item.to) ? 'page' : undefined}
|
|
145
|
+
>
|
|
146
|
+
<Link to={item.to}>
|
|
147
|
+
<Sidebar.MenuIcon>
|
|
148
|
+
<item.icon />
|
|
149
|
+
</Sidebar.MenuIcon>
|
|
150
|
+
{item.label}
|
|
151
|
+
</Link>
|
|
152
|
+
</Sidebar.MenuButton>
|
|
153
|
+
</Sidebar.MenuItem>
|
|
154
|
+
))}
|
|
155
|
+
</Sidebar.Menu>
|
|
156
|
+
</nav>
|
|
157
|
+
</Sidebar.Content>
|
|
158
|
+
|
|
159
|
+
{/* Footer, not Content: `Sidebar.Content` is the scroll container, so a
|
|
160
|
+
control inside it scrolls away with the nav. The footer is pinned. */}
|
|
161
|
+
<Sidebar.Footer>
|
|
162
|
+
<Sidebar.Menu>
|
|
163
|
+
<SidebarModeMenu />
|
|
164
|
+
</Sidebar.Menu>
|
|
165
|
+
</Sidebar.Footer>
|
|
166
|
+
</Sidebar>
|
|
167
|
+
</AppShell.Sidebar>
|
|
168
|
+
|
|
169
|
+
<AppShell.Main>{children}</AppShell.Main>
|
|
170
|
+
{/* #region block:assistant */}
|
|
171
|
+
{/* The assistant is chrome, not a page feature: it rides every route. In normal
|
|
172
|
+
use it is the right rail; fullscreen adds it as a sibling overlay after
|
|
173
|
+
AppShell (it renders its own `tw:fixed tw:inset-0 tw:z-50`) instead of
|
|
174
|
+
replacing the rail, so exiting fullscreen returns to the page underneath.
|
|
175
|
+
Render exactly one instance per mode — the rail is omitted from the Aside
|
|
176
|
+
slot while fullscreen, so the two never appear at once. */}
|
|
177
|
+
{!fullscreen && (
|
|
178
|
+
// The right rail is a `Sidebar`, not a bare div: it brings the width, border,
|
|
179
|
+
// scroll container and a drag-to-resize handle, so the panel doesn't reimplement
|
|
180
|
+
// them. `flush` stops the slot painting a second surface underneath it. Same
|
|
181
|
+
// composition kbc-ui uses for its Kai rail.
|
|
182
|
+
// The parked `off-canvas` rail used to need `overflow-x-clip` here. The
|
|
183
|
+
// design system now clips this slot itself while the rail is collapsed
|
|
184
|
+
// (see `styles.css`), which an unconditional class could not do safely:
|
|
185
|
+
// a filled rail widens past the slot and would be cropped. The two are
|
|
186
|
+
// therefore coupled — reverting that rule alone brings the horizontal
|
|
187
|
+
// scroll back here, so restore this class if you ever do.
|
|
188
|
+
<AppShell.Aside flush>
|
|
189
|
+
<Sidebar side="right" container resizable>
|
|
190
|
+
<AssistantPanel />
|
|
191
|
+
</Sidebar>
|
|
192
|
+
</AppShell.Aside>
|
|
193
|
+
)}
|
|
194
|
+
{/* #endregion block:assistant */}
|
|
195
|
+
</AppShell>
|
|
196
|
+
{/* #region block:assistant */}
|
|
197
|
+
{fullscreen && <AssistantPanel />}
|
|
198
|
+
{/* #endregion block:assistant */}
|
|
199
|
+
</SidebarProvider>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { screen, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { renderWithTheme } from '@keboola/design/testing';
|
|
6
|
+
|
|
7
|
+
import { useAppStore } from '../store';
|
|
8
|
+
|
|
9
|
+
import { AssistantPanel } from './AssistantPanel';
|
|
10
|
+
|
|
11
|
+
// `renderWithTheme`, not a bare `render`: the panel's PromptInput submit button is
|
|
12
|
+
// wrapped in a `Tooltip`, which throws outside a `TooltipProvider`. The app gets one
|
|
13
|
+
// from `ThemeProvider` in main.tsx, so rendering bare here tests a tree the app
|
|
14
|
+
// never has — and it fails only once a prior test has opened a tooltip.
|
|
15
|
+
const render = (ui: Parameters<typeof renderWithTheme>[0]) => renderWithTheme(ui);
|
|
16
|
+
|
|
17
|
+
describe('AssistantPanel', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
useAppStore.setState({ messages: [], fullscreen: false });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sends a prompt and renders the reply as Markdown', async () => {
|
|
23
|
+
const user = userEvent.setup();
|
|
24
|
+
render(<AssistantPanel />);
|
|
25
|
+
|
|
26
|
+
const form = screen.getByTestId('prompt-input');
|
|
27
|
+
await user.type(within(form).getByRole('textbox'), 'where do credits go?');
|
|
28
|
+
await user.click(within(form).getByRole('button'));
|
|
29
|
+
|
|
30
|
+
// The reply echoes the question back as a bold heading, so the raw string
|
|
31
|
+
// appears twice — the user's own turn is the one that is *not* emphasised.
|
|
32
|
+
const turns = screen.getAllByText('where do credits go?');
|
|
33
|
+
expect(turns.some((el) => el.tagName !== 'STRONG')).toBe(true);
|
|
34
|
+
|
|
35
|
+
// Markdown, not a plain string: the reply's table renders as a real table.
|
|
36
|
+
expect(screen.getByText('Database extractor')).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('leaves the user turn as plain text', async () => {
|
|
41
|
+
const user = userEvent.setup();
|
|
42
|
+
render(<AssistantPanel />);
|
|
43
|
+
|
|
44
|
+
const form = screen.getByTestId('prompt-input');
|
|
45
|
+
// Markdown syntax a user typed must survive verbatim rather than being
|
|
46
|
+
// rendered — only assistant turns go through the Markdown renderer.
|
|
47
|
+
await user.type(within(form).getByRole('textbox'), '**not bold**');
|
|
48
|
+
await user.click(within(form).getByRole('button'));
|
|
49
|
+
|
|
50
|
+
expect(screen.getByText('**not bold**')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('toggles fullscreen when the header control is clicked', async () => {
|
|
54
|
+
const user = userEvent.setup();
|
|
55
|
+
render(<AssistantPanel />);
|
|
56
|
+
|
|
57
|
+
expect(useAppStore.getState().fullscreen).toBe(false);
|
|
58
|
+
|
|
59
|
+
await user.click(screen.getByRole('button', { name: /expand to fullscreen/i }));
|
|
60
|
+
expect(useAppStore.getState().fullscreen).toBe(true);
|
|
61
|
+
|
|
62
|
+
await user.click(screen.getByRole('button', { name: /exit fullscreen/i }));
|
|
63
|
+
expect(useAppStore.getState().fullscreen).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Button, EmptyState, Markdown, MessageBubble, PromptInput } from '@keboola/design';
|
|
4
|
+
import { Expand, MessagesSquare, Minimize2 } from '@keboola/design/icons';
|
|
5
|
+
|
|
6
|
+
import { useAppStore } from '../store';
|
|
7
|
+
|
|
8
|
+
export function AssistantPanel() {
|
|
9
|
+
const messages = useAppStore((s) => s.messages);
|
|
10
|
+
const send = useAppStore((s) => s.send);
|
|
11
|
+
const fullscreen = useAppStore((s) => s.fullscreen);
|
|
12
|
+
const toggleFullscreen = useAppStore((s) => s.toggleFullscreen);
|
|
13
|
+
|
|
14
|
+
const [value, setValue] = useState('');
|
|
15
|
+
|
|
16
|
+
const handleSend = (text: string) => {
|
|
17
|
+
const trimmed = text.trim();
|
|
18
|
+
if (!trimmed) return;
|
|
19
|
+
send(trimmed);
|
|
20
|
+
setValue('');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
data-assistant-panel
|
|
26
|
+
className={
|
|
27
|
+
fullscreen
|
|
28
|
+
? 'tw:fixed tw:inset-0 tw:z-50 tw:flex tw:flex-col tw:bg-background'
|
|
29
|
+
: // Rail mode paints no background of its own — the host `Sidebar` supplies
|
|
30
|
+
// `bg-sidebar`. Fullscreen has no Sidebar under it, so there the panel *is*
|
|
31
|
+
// the page and carries `bg-background` itself.
|
|
32
|
+
'tw:flex tw:h-full tw:flex-col'
|
|
33
|
+
}
|
|
34
|
+
>
|
|
35
|
+
<div className="tw:flex tw:items-center tw:justify-between tw:border-b tw:border-border tw:p-3">
|
|
36
|
+
{/* A heading, not a span.
|
|
37
|
+
- It labels the rail as a region.
|
|
38
|
+
- It is the h2 between the page's h1 and the empty state's h3
|
|
39
|
+
(`EmptyState.Title` is a hardcoded h3); without it the order skips a level. */}
|
|
40
|
+
{/* eslint-disable-next-line design/no-raw-heading-on-page -- not a page heading:
|
|
41
|
+
it labels the assistant rail, a complementary region, so `PageHeader.Index` /
|
|
42
|
+
`.Detail` is the wrong component. The rule sees one file's JSX and so cannot
|
|
43
|
+
tell that AppChrome renders this inside AppShell.Aside's `<aside>` landmark. */}
|
|
44
|
+
<h2 className="tw:m-0 tw:text-sm tw:font-semibold tw:text-foreground">Assistant</h2>
|
|
45
|
+
<Button variant="invisible" size="icon-sm" onClick={toggleFullscreen}>
|
|
46
|
+
{fullscreen ? (
|
|
47
|
+
<>
|
|
48
|
+
<Minimize2 className="tw:size-4" />
|
|
49
|
+
<span className="tw:sr-only">Exit fullscreen</span>
|
|
50
|
+
</>
|
|
51
|
+
) : (
|
|
52
|
+
<>
|
|
53
|
+
<Expand className="tw:size-4" />
|
|
54
|
+
<span className="tw:sr-only">Expand to fullscreen</span>
|
|
55
|
+
</>
|
|
56
|
+
)}
|
|
57
|
+
</Button>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className="tw:flex tw:flex-1 tw:flex-col tw:gap-4 tw:overflow-y-auto tw:p-4">
|
|
61
|
+
{messages.length === 0 ? (
|
|
62
|
+
// A conversation panel opens empty far more often than not, so the empty state
|
|
63
|
+
// is the first thing most users see — without it the rail reads as broken.
|
|
64
|
+
<EmptyState className="tw:my-auto">
|
|
65
|
+
<EmptyState.Icon>
|
|
66
|
+
<MessagesSquare className="tw:size-6" />
|
|
67
|
+
</EmptyState.Icon>
|
|
68
|
+
<EmptyState.Title>Ask about this page</EmptyState.Title>
|
|
69
|
+
<EmptyState.Description>
|
|
70
|
+
Select any passage in the document, or just start typing below.
|
|
71
|
+
</EmptyState.Description>
|
|
72
|
+
</EmptyState>
|
|
73
|
+
) : (
|
|
74
|
+
messages.map((message) => (
|
|
75
|
+
<MessageBubble key={message.id} variant={message.role}>
|
|
76
|
+
{/* Assistant replies are Markdown; `Markdown` renders tables, code and
|
|
77
|
+
```mermaid fences (lazy-loading the mermaid runtime itself). User
|
|
78
|
+
turns stay plain text — running someone's own input through a
|
|
79
|
+
Markdown renderer would reformat what they typed. */}
|
|
80
|
+
{message.role === 'assistant' ? <Markdown>{message.text}</Markdown> : message.text}
|
|
81
|
+
</MessageBubble>
|
|
82
|
+
))
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className="tw:p-3">
|
|
87
|
+
<PromptInput
|
|
88
|
+
value={value}
|
|
89
|
+
onValueChange={setValue}
|
|
90
|
+
onSend={handleSend}
|
|
91
|
+
placeholder="Send a message..."
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Button, useSidebar } from '@keboola/design';
|
|
2
|
+
import { MessagesSquare } from '@keboola/design/icons';
|
|
3
|
+
|
|
4
|
+
import { useAppStore } from '../store';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Opens and closes the assistant rail.
|
|
8
|
+
*
|
|
9
|
+
* Its own component because `useSidebar` reads the context that `AppChrome`
|
|
10
|
+
* itself renders — a hook call in `AppChrome`'s body would sit above the
|
|
11
|
+
* provider and throw.
|
|
12
|
+
*/
|
|
13
|
+
export function AssistantToggle() {
|
|
14
|
+
const { state, toggle } = useSidebar('right');
|
|
15
|
+
const unread = useAppStore((s) => s.messages.length);
|
|
16
|
+
const isOpen = state === 'expanded';
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Button
|
|
20
|
+
variant="invisible"
|
|
21
|
+
size="small"
|
|
22
|
+
onClick={toggle}
|
|
23
|
+
aria-pressed={isOpen}
|
|
24
|
+
aria-label={isOpen ? 'Close the assistant' : 'Open the assistant'}
|
|
25
|
+
>
|
|
26
|
+
<MessagesSquare className="tw:size-4" />
|
|
27
|
+
Assistant
|
|
28
|
+
{/* The rail is closed by default, so a reply arriving from the page's prompt
|
|
29
|
+
would otherwise land somewhere the user can't see. */}
|
|
30
|
+
{!isOpen && unread > 0 && (
|
|
31
|
+
<span className="tw:ml-1 tw:rounded-full tw:bg-primary tw:px-1.5 tw:text-xs tw:text-primary-foreground tw:tabular-nums">
|
|
32
|
+
{unread}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
</Button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ColorModeSwitch, useTheme } from '@keboola/design';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Light / dark / system control.
|
|
5
|
+
*
|
|
6
|
+
* `ColorModeSwitch` is controlled, and the scheme lives on `ThemeProvider` —
|
|
7
|
+
* `useTheme` is the seam between them. Setting `data-theme` by hand instead
|
|
8
|
+
* would flip the stylesheet's own tokens but not the brand's per-scheme values,
|
|
9
|
+
* so the page would end up half-converted.
|
|
10
|
+
*/
|
|
11
|
+
export function ColorModeToggle() {
|
|
12
|
+
const { scheme, setScheme } = useTheme();
|
|
13
|
+
|
|
14
|
+
return <ColorModeSwitch value={scheme} onValueChange={setScheme} />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { PromptInput } from '@keboola/design';
|
|
4
|
+
|
|
5
|
+
import { useAppStore } from '../store';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The page's own way into the assistant.
|
|
9
|
+
*
|
|
10
|
+
* - Writes to the transcript the rail renders, so sending here is a handoff: the answer shows
|
|
11
|
+
* up in the rail, never inline. Two entry points, one conversation.
|
|
12
|
+
* - `inline` is a single-row composer that reads as one control on the page. The `default`
|
|
13
|
+
* variant is a multi-row box sized for a dedicated chat surface — which the rail already is.
|
|
14
|
+
*/
|
|
15
|
+
export function HomePrompt() {
|
|
16
|
+
const send = useAppStore((s) => s.send);
|
|
17
|
+
const [value, setValue] = useState('');
|
|
18
|
+
|
|
19
|
+
const handleSend = (text: string) => {
|
|
20
|
+
const trimmed = text.trim();
|
|
21
|
+
if (!trimmed) return;
|
|
22
|
+
send(trimmed);
|
|
23
|
+
setValue('');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<PromptInput
|
|
28
|
+
variant="inline"
|
|
29
|
+
value={value}
|
|
30
|
+
onValueChange={setValue}
|
|
31
|
+
onSend={handleSend}
|
|
32
|
+
placeholder="Ask about this page…"
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { KpiCard } from './KpiCard';
|
|
5
|
+
|
|
6
|
+
describe('KpiCard', () => {
|
|
7
|
+
it('shows label, value and a positive signed delta', () => {
|
|
8
|
+
render(<KpiCard label="MRR" value="$12.4k" deltaPct={4.2} />);
|
|
9
|
+
|
|
10
|
+
expect(screen.getByText('MRR')).toBeInTheDocument();
|
|
11
|
+
expect(screen.getByText('$12.4k')).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText('+4.2%')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('shows a negative signed delta', () => {
|
|
16
|
+
render(<KpiCard label="Churn" value="2.1%" deltaPct={-1.3} />);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText('-1.3%')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Card } from '@keboola/design';
|
|
2
|
+
|
|
3
|
+
type KpiCardProps = {
|
|
4
|
+
label: string;
|
|
5
|
+
value: string;
|
|
6
|
+
deltaPct?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function KpiCard({ label, value, deltaPct }: KpiCardProps) {
|
|
10
|
+
const delta = deltaPct === undefined ? null : `${deltaPct >= 0 ? '+' : ''}${deltaPct}%`;
|
|
11
|
+
const deltaClass = (deltaPct ?? 0) >= 0 ? 'tw:text-success' : 'tw:text-destructive';
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Card>
|
|
15
|
+
<p className="tw:text-sm tw:text-muted-foreground">{label}</p>
|
|
16
|
+
<p className="tw:mt-1 tw:text-2xl tw:font-semibold tw:text-foreground tw:tabular-nums">
|
|
17
|
+
{value}
|
|
18
|
+
</p>
|
|
19
|
+
{delta !== null && (
|
|
20
|
+
<p className={`tw:mt-1 tw:text-sm tw:tabular-nums ${deltaClass}`}>{delta}</p>
|
|
21
|
+
)}
|
|
22
|
+
</Card>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { SelectionFloater } from './SelectionFloater';
|
|
6
|
+
|
|
7
|
+
function Harness({ onExplain }: { onExplain: (text: string) => void }) {
|
|
8
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
9
|
+
return (
|
|
10
|
+
<div>
|
|
11
|
+
<div ref={containerRef} data-testid="content">
|
|
12
|
+
Quarterly revenue rose 12%.
|
|
13
|
+
</div>
|
|
14
|
+
<div data-assistant-panel data-testid="panel">
|
|
15
|
+
Chat message text.
|
|
16
|
+
</div>
|
|
17
|
+
<SelectionFloater containerRef={containerRef} onExplain={onExplain} />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const selectText = (node: Node, start: number, end: number) => {
|
|
23
|
+
const range = document.createRange();
|
|
24
|
+
range.setStart(node, start);
|
|
25
|
+
range.setEnd(node, end);
|
|
26
|
+
const sel = window.getSelection()!;
|
|
27
|
+
sel.removeAllRanges();
|
|
28
|
+
sel.addRange(range);
|
|
29
|
+
fireEvent.mouseUp(document);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('SelectionFloater', () => {
|
|
33
|
+
it('surfaces Explain with Kai for a selection inside the container and reports the text', () => {
|
|
34
|
+
const onExplain = vi.fn();
|
|
35
|
+
render(<Harness onExplain={onExplain} />);
|
|
36
|
+
|
|
37
|
+
selectText(screen.getByTestId('content').firstChild!, 0, 17); // "Quarterly revenue"
|
|
38
|
+
|
|
39
|
+
const button = screen.getByRole('button', { name: /explain with kai/i });
|
|
40
|
+
fireEvent.click(button);
|
|
41
|
+
|
|
42
|
+
expect(onExplain).toHaveBeenCalledWith('Quarterly revenue');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('ignores a selection inside the assistant panel', () => {
|
|
46
|
+
const onExplain = vi.fn();
|
|
47
|
+
render(<Harness onExplain={onExplain} />);
|
|
48
|
+
|
|
49
|
+
selectText(screen.getByTestId('panel').firstChild!, 0, 4); // "Chat"
|
|
50
|
+
|
|
51
|
+
expect(screen.queryByRole('button', { name: /explain with kai/i })).not.toBeInTheDocument();
|
|
52
|
+
expect(onExplain).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('ignores a selection that starts in the container but ends inside the assistant panel', () => {
|
|
56
|
+
const onExplain = vi.fn();
|
|
57
|
+
render(<Harness onExplain={onExplain} />);
|
|
58
|
+
|
|
59
|
+
const contentNode = screen.getByTestId('content').firstChild!;
|
|
60
|
+
const panelNode = screen.getByTestId('panel').firstChild!;
|
|
61
|
+
|
|
62
|
+
const range = document.createRange();
|
|
63
|
+
range.setStart(contentNode, 0);
|
|
64
|
+
range.setEnd(panelNode, 4);
|
|
65
|
+
const sel = window.getSelection()!;
|
|
66
|
+
sel.removeAllRanges();
|
|
67
|
+
sel.addRange(range);
|
|
68
|
+
fireEvent.mouseUp(document);
|
|
69
|
+
|
|
70
|
+
expect(screen.queryByRole('button', { name: /explain with kai/i })).not.toBeInTheDocument();
|
|
71
|
+
expect(onExplain).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|