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.
Files changed (71) hide show
  1. package/README.md +79 -0
  2. package/dist/blocks-DYU5Ke29.js +147 -0
  3. package/dist/cli.js +451 -0
  4. package/dist/extract.js +133 -0
  5. package/dist/regions-B3r5LhXl.js +95 -0
  6. package/dist/sanitize-CKs8pAzZ.js +158 -0
  7. package/dist/sanitize.js +2 -0
  8. package/dist/template/AGENTS.md +133 -0
  9. package/dist/template/README.md +56 -0
  10. package/dist/template/_env.example +6 -0
  11. package/dist/template/_gitignore +7 -0
  12. package/dist/template/_oxfmtrc.json +18 -0
  13. package/dist/template/index.html +13 -0
  14. package/dist/template/oxlint.config.ts +43 -0
  15. package/dist/template/package.json +53 -0
  16. package/dist/template/public/favicon.svg +10 -0
  17. package/dist/template/src/components/AppChrome.tsx +201 -0
  18. package/dist/template/src/components/AssistantPanel.test.tsx +65 -0
  19. package/dist/template/src/components/AssistantPanel.tsx +96 -0
  20. package/dist/template/src/components/AssistantToggle.tsx +37 -0
  21. package/dist/template/src/components/ColorModeToggle.tsx +15 -0
  22. package/dist/template/src/components/HomePrompt.tsx +35 -0
  23. package/dist/template/src/components/KpiCard.test.tsx +20 -0
  24. package/dist/template/src/components/KpiCard.tsx +24 -0
  25. package/dist/template/src/components/SelectionFloater.test.tsx +73 -0
  26. package/dist/template/src/components/SelectionFloater.tsx +88 -0
  27. package/dist/template/src/components/SidebarModeMenu.test.tsx +68 -0
  28. package/dist/template/src/components/SidebarModeMenu.tsx +54 -0
  29. package/dist/template/src/index.css +26 -0
  30. package/dist/template/src/main.tsx +57 -0
  31. package/dist/template/src/nav.test.ts +25 -0
  32. package/dist/template/src/nav.ts +38 -0
  33. package/dist/template/src/overview-sections.test.tsx +36 -0
  34. package/dist/template/src/overview-sections.tsx +40 -0
  35. package/dist/template/src/recipes/Chart.test.tsx +24 -0
  36. package/dist/template/src/recipes/Chart.tsx +56 -0
  37. package/dist/template/src/recipes/DataGrid.test.tsx +47 -0
  38. package/dist/template/src/recipes/DataGrid.tsx +151 -0
  39. package/dist/template/src/routes/-fixtures.ts +113 -0
  40. package/dist/template/src/routes/-hooks.ts +15 -0
  41. package/dist/template/src/routes/-index.test.tsx +123 -0
  42. package/dist/template/src/routes/__root.tsx +20 -0
  43. package/dist/template/src/routes/index.tsx +95 -0
  44. package/dist/template/src/routes/tokens/$id.tsx +95 -0
  45. package/dist/template/src/routes/tokens/-fixtures.test.ts +60 -0
  46. package/dist/template/src/routes/tokens/-fixtures.ts +91 -0
  47. package/dist/template/src/routes/tokens/-hooks.ts +37 -0
  48. package/dist/template/src/routes/tokens/-index.test.tsx +75 -0
  49. package/dist/template/src/routes/tokens/-nav.ts +17 -0
  50. package/dist/template/src/routes/tokens/-types.ts +22 -0
  51. package/dist/template/src/routes/tokens/index.tsx +182 -0
  52. package/dist/template/src/routes/tokens/new.tsx +163 -0
  53. package/dist/template/src/sections/chart.tsx +34 -0
  54. package/dist/template/src/sections/grid.tsx +21 -0
  55. package/dist/template/src/sections/kpi.tsx +36 -0
  56. package/dist/template/src/slices/assistant.ts +68 -0
  57. package/dist/template/src/slices/shell.ts +54 -0
  58. package/dist/template/src/store.test.tsx +92 -0
  59. package/dist/template/src/store.ts +67 -0
  60. package/dist/template/src/test-setup.ts +11 -0
  61. package/dist/template/src/vite-env.d.ts +1 -0
  62. package/dist/template/templates/agent-safety/README.md +14 -0
  63. package/dist/template/templates/agent-safety/aiexclude +25 -0
  64. package/dist/template/templates/agent-safety/claude-code.settings.json +45 -0
  65. package/dist/template/templates/agent-safety/cursorignore +25 -0
  66. package/dist/template/tsconfig.json +10 -0
  67. package/dist/template/vercel.json +4 -0
  68. package/dist/template/vite.config.ts +21 -0
  69. package/dist/template/vitest.config.ts +21 -0
  70. package/dist/template/workspace-deps.json +7 -0
  71. 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
+ });