create-keboola-app 0.1.1 → 0.2.1

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 (49) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +16 -11
  3. package/dist/{blocks-DYU5Ke29.js → blocks-BdJaEmz1.js} +36 -20
  4. package/dist/cli.js +29 -52
  5. package/dist/extract.js +9 -14
  6. package/dist/{regions-B3r5LhXl.js → regions-BNl4QsPP.js} +12 -22
  7. package/dist/{sanitize-CKs8pAzZ.js → sanitize-DVFBkv8b.js} +14 -24
  8. package/dist/sanitize.js +1 -2
  9. package/dist/template/AGENTS.md +20 -6
  10. package/dist/template/README.md +5 -0
  11. package/dist/template/oxlint.config.ts +10 -14
  12. package/dist/template/package.json +21 -20
  13. package/dist/template/src/auth/AuthScreen.test.tsx +44 -0
  14. package/dist/template/src/auth/AuthScreen.tsx +43 -0
  15. package/dist/template/src/auth/session.test.tsx +64 -0
  16. package/dist/template/src/auth/session.ts +42 -0
  17. package/dist/template/src/auth/states.tsx +70 -0
  18. package/dist/template/src/components/AppChrome.tsx +26 -74
  19. package/dist/template/src/components/SelectionFloater.tsx +10 -3
  20. package/dist/template/src/components/SidebarModeMenu.tsx +8 -13
  21. package/dist/template/src/nav.ts +6 -11
  22. package/dist/template/src/{routes/-fixtures.ts → overview-fixtures.ts} +6 -18
  23. package/dist/template/src/{routes/-hooks.ts → overview-hooks.ts} +1 -1
  24. package/dist/template/src/overview-sections.tsx +4 -1
  25. package/dist/template/src/recipes/Chart.test.tsx +1 -3
  26. package/dist/template/src/recipes/Chart.tsx +4 -11
  27. package/dist/template/src/recipes/DataGrid.test.tsx +1 -1
  28. package/dist/template/src/recipes/DataGrid.tsx +7 -21
  29. package/dist/template/src/routes/__root.tsx +21 -0
  30. package/dist/template/src/routes/{-index.test.tsx → index.test.tsx} +1 -1
  31. package/dist/template/src/routes/index.tsx +2 -3
  32. package/dist/template/src/routes/signed-out.test.tsx +56 -0
  33. package/dist/template/src/routes/signed-out.tsx +31 -0
  34. package/dist/template/src/routes/tokens/$id.tsx +2 -2
  35. package/dist/template/src/routes/tokens/{-index.test.tsx → index.test.tsx} +2 -2
  36. package/dist/template/src/routes/tokens/index.tsx +2 -3
  37. package/dist/template/src/routes/tokens/new.tsx +2 -2
  38. package/dist/template/src/sections/kpi.tsx +3 -7
  39. package/dist/template/src/slices/assistant.ts +3 -9
  40. package/dist/template/src/slices/shell.ts +4 -11
  41. package/dist/template/src/store.ts +9 -24
  42. package/dist/template/src/{routes/tokens/-fixtures.test.ts → tokens/fixtures.test.ts} +1 -1
  43. package/dist/template/src/{routes/tokens/-fixtures.ts → tokens/fixtures.ts} +1 -1
  44. package/dist/template/src/{routes/tokens/-hooks.ts → tokens/hooks.ts} +2 -2
  45. package/dist/template/src/{routes/tokens/-nav.ts → tokens/nav.ts} +1 -1
  46. package/dist/template/vite.config.ts +5 -0
  47. package/dist/template/vitest.config.ts +2 -5
  48. package/package.json +13 -12
  49. /package/dist/template/src/{routes/tokens/-types.ts → tokens/types.ts} +0 -0
@@ -60,13 +60,14 @@ src/
60
60
  routeTree.gen.ts # Generated from src/routes/** by the Vite plugin
61
61
  nav.ts # The rail's destinations and the active-match rule
62
62
  overview-sections.tsx # The ordered registry the overview renders
63
+ overview-hooks.ts # useOverview, AbortSignal threaded
64
+ overview-fixtures.ts # In-memory overview stub — swap for a real client call
63
65
  store.ts # The Zustand store: storage key, partialize, merge guard
64
66
  slices/shell.ts # Sidebar mode + help visibility
65
67
  components/AppChrome.tsx # AppShell + Navbar + Sidebar
66
- routes/__root.tsx # Root layout
68
+ routes/__root.tsx # Root layout — and which routes get the app chrome
67
69
  routes/index.tsx # / — the overview
68
- routes/-hooks.ts # useOverview, AbortSignal threaded
69
- routes/-fixtures.ts # In-memory overview stub — swap for a real client call
70
+ routes/index.test.tsx # The overview's branches — a route's test sits with the route
70
71
  ```
71
72
  <!-- #region block:kpi -->
72
73
  - `components/KpiCard.tsx`, `sections/kpi.tsx` — the KPI row.
@@ -78,21 +79,34 @@ src/
78
79
  - `recipes/DataGrid.tsx`, `sections/grid.tsx` — the editable grid.
79
80
  <!-- #endregion block:grid -->
80
81
  <!-- #region block:crud -->
81
- - `routes/tokens/**` — the CRUD slice: `index.tsx`, `$id.tsx`, `new.tsx`, `-hooks.ts`,
82
- `-fixtures.ts`, `-types.ts`, `-nav.ts`. **The entity is a placeholder.** Rename it to yours;
83
- the fixture rows are labels only.
82
+ - `routes/tokens/**` (`index.tsx`, `$id.tsx`, `new.tsx`) and `tokens/**` (`hooks.ts`,
83
+ `fixtures.ts`, `types.ts`, `nav.ts`) — the CRUD slice. **The entity is a placeholder.**
84
+ Rename it to yours; the fixture rows are labels only.
84
85
  <!-- #endregion block:crud -->
85
86
  <!-- #region block:assistant -->
86
87
  - `components/AssistantPanel.tsx`, `AssistantToggle.tsx`, `HomePrompt.tsx`,
87
88
  `SelectionFloater.tsx`, `slices/assistant.ts` — the assistant. `slices/assistant.ts`
88
89
  answers with a canned reply; swap that branch for a real request.
89
90
  <!-- #endregion block:assistant -->
91
+ <!-- #region block:auth -->
92
+ - `auth/AuthScreen.tsx`, `auth/states.tsx`, `auth/session.ts`, `routes/signed-out.tsx` — the
93
+ signed-out screen. **Presentational: it reaches no network.** Wiring it up is two edits —
94
+ the form's `onSubmit` in `states.tsx`, and where the state comes from. `routes/__root.tsx`
95
+ drops the app chrome for this route, so a visitor with no session sees no rail; a real app
96
+ usually goes further and renders the screen ahead of the router, which `AuthScreen` supports
97
+ by carrying its own `tailwindcss-preflight` scope.
98
+ <!-- #endregion block:auth -->
90
99
 
91
100
  ## Adding a route
92
101
 
93
102
  Drop a file in `src/routes/`. The Vite plugin regenerates `routeTree.gen.ts` on the next
94
103
  `pnpm dev` or `pnpm build`; nothing needs registering by hand.
95
104
 
105
+ `src/routes/` holds route files and their tests, nothing else. A route's hooks, fixtures,
106
+ types and components live in a domain directory beside it (`src/reports/`), the way
107
+ `src/auth/` and `src/tokens/` do — a non-route module under `src/routes/` is a route the
108
+ generator has to be told to ignore.
109
+
96
110
  ```tsx
97
111
  import { createFileRoute } from '@tanstack/react-router';
98
112
 
@@ -47,6 +47,11 @@ fails for a missing credential is a worse welcome than an opt-in gate.
47
47
  - A docked assistant rail on every route, a composer on the overview, and
48
48
  explain-on-selection. All three share one transcript.
49
49
  <!-- #endregion block:assistant -->
50
+ <!-- #region block:auth -->
51
+ - **`/signed-out`** — the pre-project screen, rendered without the app chrome: brand lockup,
52
+ one centred card, a colour-scheme control, and the restoring, expired and unreachable
53
+ states. Presentational: the form submits nowhere, and `?state=` stands in for your session.
54
+ <!-- #endregion block:auth -->
50
55
 
51
56
  Every data call is an in-memory fixture that honours an `AbortSignal`. Replacing a fixture with
52
57
  a real `@keboola/api-client` call is a drop-in — the query hooks already thread `signal`.
@@ -1,41 +1,37 @@
1
1
  import { defineConfig } from 'oxlint';
2
2
 
3
3
  import keboolaShared from '@keboola/oxlint-config';
4
+ import keboolaShadcn, { shadcnSettings } from '@keboola/oxlint-config/shadcn';
4
5
  import keboolaTailwind from '@keboola/oxlint-config/tailwind';
5
6
 
6
7
  export default defineConfig({
7
- // `keboolaTailwind` opts into the v4-native Tailwind lint layer
8
- // (eslint-plugin-better-tailwindcss). It carries the plugin + rules; the
9
- // `entryPoint` below is required — oxlint only reads `settings` from the
10
- // root config, so it can't live inside the shared fragment.
11
- extends: [keboolaShared, keboolaTailwind],
8
+ extends: [keboolaShared, keboolaTailwind, keboolaShadcn],
12
9
  env: {
13
10
  node: true,
14
11
  },
15
12
  settings: {
16
13
  'better-tailwindcss': {
17
- // This app's Tailwind v4 CSS entry (the file that `@import`s "tailwindcss").
14
+ // The file that `@import`s "tailwindcss".
18
15
  entryPoint: 'src/index.css',
19
16
  },
17
+ shadcn: shadcnSettings,
20
18
  },
21
19
  jsPlugins: [
22
- // Local design-system + brand-safety lint rules. Aliased to `design` so rule
23
- // IDs read `design/<rule>` in lint output.
20
+ // Aliased to `design`, so rule IDs read `design/<rule>`.
24
21
  { name: 'design', specifier: '@keboola/eslint-plugin-design' },
25
22
  ],
26
23
  rules: {
27
- // Shipped at 'warn' for the first pass, matching the plugin convention —
28
- // promote to 'error' once generated apps are consistently clean. See
29
- // packages/eslint-plugin-design/README.md.
24
+ 'design/max-comment-lines': 'error',
25
+ // 'warn' until generated apps are consistently clean.
30
26
  'design/no-layout-primitives': 'warn',
31
- 'design/no-inline-color': 'warn',
27
+ // `design/no-inline-color` off: `shadcn/no-inline-styles` and `no-arbitrary-values` cover it.
32
28
  'design/no-primitive-tw-color': 'warn',
33
29
  'design/no-native-form-controls': 'warn',
34
30
  'design/no-raw-heading-on-page': 'warn',
35
31
  'design/prefer-design-modal': 'warn',
32
+ 'design/htmlfor-points-at-nothing': 'error',
36
33
 
37
- // 'error' from the start: this app is the scaffold template, so a
38
- // locale-implicit format here is copied into every generated app.
34
+ // 'error': the scaffold copies any locale-implicit format into every generated app.
39
35
  'design/no-implicit-locale': 'error',
40
36
  },
41
37
  });
@@ -15,39 +15,40 @@
15
15
  "type-check": "tsc"
16
16
  },
17
17
  "dependencies": {
18
- "@keboola/brand-registry": "^1.9.4",
19
- "@keboola/design": "^11.3.0",
18
+ "@keboola/brand-registry": "^3.1.0",
19
+ "@keboola/design": "^17.2.0",
20
20
  "@tanstack/react-form": "^1.29.1",
21
- "@tanstack/react-query": "^5.96.2",
22
- "@tanstack/react-router": "^1.120.4",
21
+ "@tanstack/react-query": "^5.104.5",
22
+ "@tanstack/react-router": "^1.170.46",
23
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",
24
+ "@tanstack/router-plugin": "^1.168.48",
25
+ "react": "^19.3.1",
26
+ "react-dom": "^19.3.1",
27
27
  "zod": "^4.3.6",
28
28
  "zustand": "^5.0.8"
29
29
  },
30
30
  "devDependencies": {
31
- "@keboola/eslint-plugin-design": "^0.4.3",
32
- "@keboola/oxlint-config": "^10.0.2",
31
+ "@keboola/eslint-plugin-design": "^0.8.0",
32
+ "@keboola/oxlint-config": "^10.3.0",
33
33
  "@keboola/tsconfig": "^0.2.1",
34
+ "@shadcn/lint": "^0.1.5",
34
35
  "@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",
36
+ "@tanstack/react-query-devtools": "^5.104.5",
37
+ "@testing-library/jest-dom": "^7.0.1",
38
+ "@testing-library/react": "^16.3.3",
39
+ "@testing-library/user-event": "^14.6.8",
39
40
  "@types/node": "24.12.0",
40
- "@types/react": "^19.2.15",
41
- "@types/react-dom": "^19.2.3",
41
+ "@types/react": "^19.3.0",
42
+ "@types/react-dom": "^19.3.0",
42
43
  "@typescript/native": "npm:typescript@^7.0.2",
43
- "@vitejs/plugin-react": "^6.0.0",
44
+ "@vitejs/plugin-react": "^6.1.2",
44
45
  "eslint-plugin-better-tailwindcss": "^4.7.0",
45
46
  "jsdom": "^26.1.0",
46
- "oxfmt": "^0.57.0",
47
- "oxlint": "^1.72.0",
47
+ "oxfmt": "^0.66.0",
48
+ "oxlint": "^1.89.0",
48
49
  "tailwindcss": "^4.3.3",
49
50
  "typescript": "npm:@typescript/typescript6@^6.0.2",
50
- "vite": "^8.1.5",
51
- "vitest": "^4.1.10"
51
+ "vite": "^8.3.5",
52
+ "vitest": "^5.0.5"
52
53
  }
53
54
  }
@@ -0,0 +1,44 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { keboola } from '@keboola/brand-registry';
5
+ import { Button, ThemeProvider } from '@keboola/design';
6
+
7
+ import { AuthScreen } from './AuthScreen';
8
+
9
+ // The provider is required, not scenery: the header's `ColorModeToggle` reads the scheme through
10
+ // `useTheme`, and `BrandLogo` renders nothing without a brand registered.
11
+ const renderScreen = () =>
12
+ render(
13
+ <ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="light">
14
+ <AuthScreen title="Sign in">
15
+ <Button>Continue</Button>
16
+ </AuthScreen>
17
+ </ThemeProvider>,
18
+ );
19
+
20
+ describe('<AuthScreen />', () => {
21
+ it('carries the page heading at h1', () => {
22
+ renderScreen();
23
+
24
+ expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
25
+ });
26
+
27
+ it('keeps the colour-scheme control inside the header landmark', () => {
28
+ // The control is positioned away from the lockup, so its landmark is what can silently
29
+ // regress — outside one, axe reports it as unreachable by landmark navigation.
30
+ renderScreen();
31
+
32
+ expect(screen.getByRole('banner')).toContainElement(
33
+ screen.getByRole('button', { name: /^Color mode:/ }),
34
+ );
35
+ });
36
+
37
+ it('renders the card body inside the main landmark', () => {
38
+ renderScreen();
39
+
40
+ expect(screen.getByRole('main')).toContainElement(
41
+ screen.getByRole('button', { name: 'Continue' }),
42
+ );
43
+ });
44
+ });
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { BrandLogo, Card } from '@keboola/design';
4
+
5
+ import { ColorModeToggle } from '../components/ColorModeToggle';
6
+
7
+ // Must stay a top-level single-quoted `const`: `create-keboola-app` (`parameterise` in
8
+ // `extract.ts`) rewrites it to the generated app's title.
9
+ const PRODUCT = '__APP_TITLE__';
10
+
11
+ /**
12
+ * The chrome every pre-project screen shares: brand lockup, one centred card, no navigation.
13
+ *
14
+ * - Renders instead of `AppChrome`; `routes/__root.tsx` picks via `useIsAuthRoute`.
15
+ * - Sets `tailwindcss-preflight` and `text-foreground` itself: a real app mounts it above the router.
16
+ * - The toggle sits in `header` so axe finds it inside a landmark.
17
+ * - `variant="mark"` + text name: the wordmark would repeat the brand; the name survives a `null` logo.
18
+ */
19
+ export function AuthScreen({ title, children }: { title: string; children: ReactNode }) {
20
+ return (
21
+ <div className="tailwindcss-preflight tw:relative tw:flex tw:min-h-screen tw:flex-col tw:items-center tw:justify-center tw:gap-6 tw:bg-muted tw:px-4 tw:py-12 tw:text-foreground">
22
+ <header>
23
+ <div className="tw:flex tw:items-center tw:gap-3">
24
+ <BrandLogo variant="mark" className="tw:h-8" />
25
+ <span className="tw:text-lg tw:font-semibold">{PRODUCT}</span>
26
+ </div>
27
+
28
+ <div className="tw:absolute tw:top-4 tw:right-4">
29
+ <ColorModeToggle />
30
+ </div>
31
+ </header>
32
+
33
+ <main className="tw:w-full tw:max-w-md">
34
+ <Card>
35
+ <Card.Header layout="stack">
36
+ <Card.Title as="h1">{title}</Card.Title>
37
+ </Card.Header>
38
+ <Card.Content className="tw:flex tw:flex-col tw:gap-4">{children}</Card.Content>
39
+ </Card>
40
+ </main>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,64 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import type { AuthState } from './session';
6
+ import { AUTH_STATES, isAuthState } from './session';
7
+
8
+ const renderState = (state: AuthState, onRetry = () => {}) => {
9
+ const { body: Body } = AUTH_STATES[state];
10
+ return render(<Body onRetry={onRetry} />);
11
+ };
12
+
13
+ describe('AUTH_STATES', () => {
14
+ it('asks for a credential when signed out', () => {
15
+ renderState('signed-out');
16
+
17
+ expect(screen.getByLabelText(/Email/)).toBeInTheDocument();
18
+ expect(screen.getByLabelText(/Password/)).toBeInTheDocument();
19
+ expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
20
+ });
21
+
22
+ it('announces the wait while restoring', () => {
23
+ renderState('restoring');
24
+
25
+ // The wait resolves without the reader acting, so the region has to be live.
26
+ const message = screen.getByText(/still signed in/);
27
+ expect(message.closest('[aria-live="polite"]')).not.toBeNull();
28
+ });
29
+
30
+ it.each([
31
+ ['expired', 'Sign in again'],
32
+ ['unavailable', 'Try again'],
33
+ ] as const)('lets the reader retry from %s', async (state, action) => {
34
+ const user = userEvent.setup();
35
+ const onRetry = vi.fn();
36
+ renderState(state, onRetry);
37
+
38
+ await user.click(screen.getByRole('button', { name: action }));
39
+
40
+ expect(onRetry).toHaveBeenCalledOnce();
41
+ });
42
+
43
+ it('distinguishes an expired session from an unreachable service', () => {
44
+ // Telling someone their session expired while the network is down sends them to re-enter a
45
+ // password that would have worked, so the two copies must not converge.
46
+ const { unmount } = renderState('expired');
47
+ expect(screen.getByText(/session ended/)).toBeInTheDocument();
48
+ unmount();
49
+
50
+ renderState('unavailable');
51
+ expect(screen.getByText(/could not reach the service/)).toBeInTheDocument();
52
+ });
53
+ });
54
+
55
+ describe('isAuthState', () => {
56
+ it('accepts every declared state and nothing else', () => {
57
+ for (const state of Object.keys(AUTH_STATES)) expect(isAuthState(state)).toBe(true);
58
+
59
+ expect(isAuthState('signedout')).toBe(false);
60
+ expect(isAuthState(undefined)).toBe(false);
61
+ // `Object.hasOwn` rather than `in`, so a prototype key is not a state.
62
+ expect(isAuthState('toString')).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,42 @@
1
+ import type { ComponentType } from 'react';
2
+ import { useRouterState } from '@tanstack/react-router';
3
+
4
+ import { Expired, Restoring, SignInForm, Unavailable } from './states';
5
+
6
+ /** Keep in sync with the literal in `createFileRoute` — TanStack's generator needs source text. */
7
+ export const AUTH_ROUTE = '/signed-out';
8
+
9
+ /**
10
+ * Whether the router is on the signed-out screen.
11
+ *
12
+ * - Selects the boolean, so moving between chromed routes does not re-render the root.
13
+ * - A real app asks its session instead; the path only makes the screen reachable without a backend.
14
+ */
15
+ export const useIsAuthRoute = (): boolean =>
16
+ useRouterState({ select: (state) => state.location.pathname === AUTH_ROUTE });
17
+
18
+ /** One pre-project state: the card's heading, and the body that goes under it. */
19
+ type AuthStateView = {
20
+ title: string;
21
+ /** `onRetry` returns the screen to the sign-in form. */
22
+ body: ComponentType<{ onRetry: () => void }>;
23
+ };
24
+
25
+ /**
26
+ * The pre-project states, keyed by the session status that produces each.
27
+ *
28
+ * - `AuthState` derives from the keys: a state added here is accepted and rendered by the route.
29
+ * - Components, not render functions, so this module holds no JSX.
30
+ */
31
+ export const AUTH_STATES = {
32
+ 'signed-out': { title: 'Sign in', body: SignInForm },
33
+ restoring: { title: 'Restoring your session', body: Restoring },
34
+ expired: { title: 'Session expired', body: Expired },
35
+ unavailable: { title: 'Cannot reach the service', body: Unavailable },
36
+ } as const satisfies Readonly<Record<string, AuthStateView>>;
37
+
38
+ export type AuthState = keyof typeof AUTH_STATES;
39
+
40
+ /** Narrows an unvalidated search param. */
41
+ export const isAuthState = (value: unknown): value is AuthState =>
42
+ typeof value === 'string' && Object.hasOwn(AUTH_STATES, value);
@@ -0,0 +1,70 @@
1
+ import { Alert, Button, DotsLoader, useAppForm } from '@keboola/design';
2
+
3
+ type AuthStateProps = { onRetry: () => void };
4
+
5
+ /**
6
+ * The credential form — the one part that differs per app.
7
+ *
8
+ * - `onSubmit` reaches no network; each app replaces its body with its own auth.
9
+ */
10
+ export const SignInForm = () => {
11
+ const form = useAppForm({
12
+ defaultValues: { email: '', password: '' },
13
+ onSubmit: async ({ value }) => {
14
+ await Promise.resolve(value);
15
+ },
16
+ });
17
+
18
+ return (
19
+ <form.AppForm>
20
+ <form.FormBody>
21
+ <form.AppField name="email">
22
+ {(field) => <field.TextField label="Email" type="email" required />}
23
+ </form.AppField>
24
+ <form.AppField name="password">
25
+ {(field) => <field.PasswordField label="Password" required />}
26
+ </form.AppField>
27
+ <form.SubmitButton className="tw:w-full">Sign in</form.SubmitButton>
28
+ </form.FormBody>
29
+ </form.AppForm>
30
+ );
31
+ };
32
+
33
+ /** A persisted session being rehydrated. */
34
+ export const Restoring = () => (
35
+ <div
36
+ className="tw:flex tw:items-center tw:gap-3 tw:text-sm tw:text-muted-foreground"
37
+ aria-live="polite"
38
+ >
39
+ <DotsLoader />
40
+ Checking whether you are still signed in.
41
+ </div>
42
+ );
43
+
44
+ /** A credential that was accepted once and is not any more. */
45
+ export const Expired = ({ onRetry }: AuthStateProps) => (
46
+ <>
47
+ <Alert variant="warning">
48
+ Your session ended. Sign in again to pick up where you left off.
49
+ </Alert>
50
+ <Button variant="primary" className="tw:w-full" onClick={onRetry}>
51
+ Sign in again
52
+ </Button>
53
+ </>
54
+ );
55
+
56
+ /**
57
+ * The service could not be reached, so the credential was never judged.
58
+ *
59
+ * - Not `Expired`: that would send the user to re-enter a password that works.
60
+ */
61
+ export const Unavailable = ({ onRetry }: AuthStateProps) => (
62
+ <>
63
+ <Alert variant="error">
64
+ We could not reach the service. Your credentials are fine — the request never got an answer.
65
+ </Alert>
66
+ <Button variant="outline" className="tw:w-full" onClick={onRetry}>
67
+ Try again
68
+ </Button>
69
+ </>
70
+ );
@@ -13,10 +13,8 @@ import { AssistantToggle } from './AssistantToggle';
13
13
  import { ColorModeToggle } from './ColorModeToggle';
14
14
  import { SidebarModeMenu } from './SidebarModeMenu';
15
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`.
16
+ // Composed inline so the AppShell/Navbar/Sidebar wiring stays legible.
17
+ // Navbar follows kbc-ui's AppSiteHeader: Header (brand) / Content (nav) / Footer (actions).
20
18
  export function AppChrome({ children }: { children: ReactNode }) {
21
19
  const pathname = useRouterState({ select: (s) => s.location.pathname });
22
20
  const helpVisible = useAppStore((s) => s.helpVisible);
@@ -29,62 +27,39 @@ export function AppChrome({ children }: { children: ReactNode }) {
29
27
 
30
28
  return (
31
29
  <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.
30
+ // Controlled: the store owns the persisted mode; `SidebarModeMenu` writes it.
35
31
  leftMode={sidebarMode}
36
32
  onLeftModeChange={setSidebarMode}
37
33
  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.
34
+ // Keeps every destination reachable; also what `expand-on-hover` collapses to.
40
35
  leftCollapsible="icon"
41
36
  // #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.
37
+ // Required: variant `none` (the default) renders nothing, silently.
46
38
  rightVariant="sided-floating"
47
39
  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).
40
+ // Closed until the Navbar's AssistantToggle opens it.
52
41
  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.
42
+ // The provider default is too narrow for chat; matches kbc-ui's Kai rail.
55
43
  rightWidth={384}
56
44
  // #endregion block:assistant
57
45
  >
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. */}
46
+ {/* Header above both rails, so the brand mark aligns to the viewport edge. */}
61
47
  <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. */}
48
+ {/* `flush` drops padding, background and bottom border; the border is restated:
49
+ - It closes the frame the rails' hairlines start.
50
+ - `sidebar-border`, not `border`: they meet at the corners, `border` is lighter. */}
68
51
  <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. */}
52
+ {/* As a flex item the bar would otherwise shrink to its content. */}
71
53
  <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. */}
54
+ {/* Gutter here, not on `Navbar`: the bar's `pl-2` comes from a cva, which doesn't
55
+ tailwind-merge. With it the logo sits at 18px, aligned with the rail's icons. */}
80
56
  <Navbar.Header className="tw:pl-2.5">
81
57
  <Link to="/" aria-label="Home" className="tw:flex tw:items-center">
82
58
  <BrandLogo className="tw:h-6" />
83
59
  </Link>
84
60
  </Navbar.Header>
85
61
 
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. */}
62
+ {/* Carries `flex-1`, pushing Navbar.Footer to the right edge. */}
88
63
  <Navbar.Content>
89
64
  <Navbar.Menu marker="forward-slash">
90
65
  <Navbar.MenuItem>
@@ -113,31 +88,20 @@ export function AppChrome({ children }: { children: ReactNode }) {
113
88
  </AppShell.Header>
114
89
 
115
90
  <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. */}
91
+ {/* Elevation while `expand-on-hover` is open: the rail overlays the page.
92
+ - App-scoped, not a `Sidebar` variant: kbc-ui paints `::before`/`::after` wedges on
93
+ that edge. Promote once reviewed there. */}
125
94
  <Sidebar
126
95
  side="left"
127
96
  className="tw:group-data-[mode=expand-on-hover]/sidebar:group-data-[state=expanded]/sidebar:[&>[data-slot=sidebar]]:shadow-lift"
128
97
  >
129
98
  <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. */}
99
+ {/* The rail's landmark (axe); labelled apart from the Navbar's "Main navigation". */}
134
100
  <nav aria-label="Sections">
135
101
  <Sidebar.Menu>
136
102
  {NAV.map((item) => (
137
103
  <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. */}
104
+ {/* A link takes `aria-current`, not `aria-selected`. */}
141
105
  <Sidebar.MenuButton
142
106
  asChild
143
107
  tooltip={item.label}
@@ -156,8 +120,7 @@ export function AppChrome({ children }: { children: ReactNode }) {
156
120
  </nav>
157
121
  </Sidebar.Content>
158
122
 
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. */}
123
+ {/* Footer, not Content: `Sidebar.Content` scrolls, the footer is pinned. */}
161
124
  <Sidebar.Footer>
162
125
  <Sidebar.Menu>
163
126
  <SidebarModeMenu />
@@ -168,23 +131,12 @@ export function AppChrome({ children }: { children: ReactNode }) {
168
131
 
169
132
  <AppShell.Main>{children}</AppShell.Main>
170
133
  {/* #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. */}
134
+ {/* The assistant rides every route, one instance per mode:
135
+ - Normal: the right rail.
136
+ - Fullscreen: a fixed overlay after AppShell, no rail; exit returns to the page. */}
177
137
  {!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.
138
+ // `Sidebar` brings width, border, scroll and resize; `flush` avoids a second surface.
139
+ // `styles.css` clips this slot while collapsed; without that rule, add `overflow-x-clip`.
188
140
  <AppShell.Aside flush>
189
141
  <Sidebar side="right" container resizable>
190
142
  <AssistantPanel />
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
- import type { RefObject } from 'react';
2
+ import type { CSSProperties, RefObject } from 'react';
3
3
 
4
4
  import { Button } from '@keboola/design';
5
5
 
@@ -78,8 +78,15 @@ export function SelectionFloater({
78
78
  <Button
79
79
  variant="secondary"
80
80
  size="small"
81
- className="tw:fixed tw:z-50"
82
- style={{ top: selection.top - 40, left: selection.left }}
81
+ className="tw:fixed tw:top-(--floater-top) tw:left-(--floater-left) tw:z-50"
82
+ // Custom properties, not inline `top`/`left`: the utilities above read them,
83
+ // and the position is measured from the live selection at runtime.
84
+ style={
85
+ {
86
+ '--floater-top': `${selection.top - 40}px`,
87
+ '--floater-left': `${selection.left}px`,
88
+ } as CSSProperties
89
+ }
83
90
  onClick={handleClick}
84
91
  >
85
92
  Explain with Kai