create-keboola-app 0.1.0 → 0.2.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 (37) hide show
  1. package/CHANGELOG.md +64 -2
  2. package/README.md +16 -11
  3. package/dist/{blocks-DYU5Ke29.js → blocks-D_-NWhwg.js} +31 -12
  4. package/dist/cli.js +34 -4
  5. package/dist/extract.js +13 -2
  6. package/dist/template/AGENTS.md +20 -6
  7. package/dist/template/README.md +5 -0
  8. package/dist/template/oxlint.config.ts +2 -4
  9. package/dist/template/package.json +7 -7
  10. package/dist/template/src/auth/AuthScreen.test.tsx +44 -0
  11. package/dist/template/src/auth/AuthScreen.tsx +63 -0
  12. package/dist/template/src/auth/session.test.tsx +64 -0
  13. package/dist/template/src/auth/session.ts +58 -0
  14. package/dist/template/src/auth/states.tsx +88 -0
  15. package/dist/template/src/components/AppChrome.tsx +1 -1
  16. package/dist/template/src/nav.ts +1 -1
  17. package/dist/template/src/{routes/-fixtures.ts → overview-fixtures.ts} +1 -1
  18. package/dist/template/src/{routes/-hooks.ts → overview-hooks.ts} +1 -1
  19. package/dist/template/src/overview-sections.tsx +4 -1
  20. package/dist/template/src/recipes/DataGrid.test.tsx +1 -1
  21. package/dist/template/src/recipes/DataGrid.tsx +2 -2
  22. package/dist/template/src/routes/__root.tsx +28 -0
  23. package/dist/template/src/routes/{-index.test.tsx → index.test.tsx} +1 -1
  24. package/dist/template/src/routes/index.tsx +2 -3
  25. package/dist/template/src/routes/signed-out.test.tsx +56 -0
  26. package/dist/template/src/routes/signed-out.tsx +35 -0
  27. package/dist/template/src/routes/tokens/$id.tsx +2 -2
  28. package/dist/template/src/routes/tokens/{-index.test.tsx → index.test.tsx} +2 -2
  29. package/dist/template/src/routes/tokens/index.tsx +2 -3
  30. package/dist/template/src/routes/tokens/new.tsx +2 -2
  31. package/dist/template/src/{routes/tokens/-fixtures.test.ts → tokens/fixtures.test.ts} +1 -1
  32. package/dist/template/src/{routes/tokens/-fixtures.ts → tokens/fixtures.ts} +1 -1
  33. package/dist/template/src/{routes/tokens/-hooks.ts → tokens/hooks.ts} +2 -2
  34. package/dist/template/src/{routes/tokens/-nav.ts → tokens/nav.ts} +1 -1
  35. package/dist/template/vite.config.ts +8 -0
  36. package/package.json +21 -19
  37. /package/dist/template/src/{routes/tokens/-types.ts → tokens/types.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,10 +1,72 @@
1
1
  # create-keboola-app
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New `auth` block: a generated app can now arrive with its signed-out screen already built.
8
+
9
+ Four apps in the platform hand-build the same pre-auth screen, and a Storybook pattern does not
10
+ stop a fifth from doing it again — new apps come from this generator, so that is where the screen
11
+ has to arrive. Pick `auth` and the app gets `/signed-out`, rendered without the app chrome: brand
12
+ lockup from `BrandLogo`, one centred card, a colour-scheme control, correct landmarks and heading
13
+ level, and the four pre-project states — signed out, restoring, expired, service unreachable —
14
+ under one piece of chrome, so the common path does not jump when one replaces another. The
15
+ emitted `gate:ui` scores that route as well as `/`.
16
+
17
+ The block is the screen and nothing behind it: no dependency, no network. Only the app knows what
18
+ it authenticates against, so wiring it up is two edits — the form's `onSubmit`, and where the
19
+ state comes from instead of the `?state=` search param. `--blocks none` still yields an app that
20
+ builds, type-checks and passes its own gate.
21
+
22
+ A routed block now brings the whole brief for its own route rather than a shared suffix. The
23
+ suffix described a searchable table, which graded a signed-out screen against qualities it is
24
+ supposed not to have.
25
+
26
+ - A generated app keeps `src/routes/` for route files. Everything else — hooks, fixtures, types, a
27
+ module's rail entry — arrives in a domain directory beside it.
28
+
29
+ TanStack Router excludes a file from the route tree when its name starts with `-`, and the
30
+ emitted layout leaned on that to keep a module's code inside its route folder. It does not scale:
31
+ one app grew to six route files and fifty-four dashed ones, and route folders that own domain code
32
+ end up importing across each other, which is the coupling the co-location was meant to prevent. A
33
+ marker on nine files out of ten marks nothing.
34
+
35
+ So the crud block now emits `routes/<kebab>/{index,new,$id}.tsx` and `<kebab>/{hooks,fixtures,
36
+ types,nav}.ts`, and the overview's hooks and fixtures sit at `src/overview-*.ts`. Nothing emitted
37
+ carries the `-` prefix.
38
+
39
+ A route's test is the one thing that stays with its route, because that is the file it exercises.
40
+ It exports no `Route`, so the emitted `vite.config.ts` declares
41
+ `routeFileIgnorePattern: '\\.test\\.tsx?$'`. Without it the generator prints a
42
+ `does not export a Route` warning for every such file on every build, which is what makes the
43
+ prefix feel obligatory in the first place. The CLI passes the same pattern when it writes the
44
+ first `routeTree.gen.ts`, which happens before the app has a Vite build to read the config from.
45
+
46
+ ## 0.1.1
47
+
48
+ ### Patch Changes
49
+
50
+ - A generated app pins `nwsapi`, so a userEvent test does not hang.
51
+
52
+ nwsapi 2.2.26 — jsdom's selector engine — answers `:modal`, `:fullscreen` and `:open` through a helper that falls back to the element's own `matches`. Under jsdom that is nwsapi itself, so the two recurse, and `:modal` doubles the work per level through its `|| isFullscreen` branch. `@floating-ui/dom` probes `:modal` on every popper placement, which is once per dropdown or tooltip interaction: measured at 9s where 2.2.25 takes 26ms, so a test driving a menu with `userEvent` hits its timeout instead of asserting.
53
+
54
+ Generated apps now ship a `pnpm-workspace.yaml` holding `nwsapi` at 2.2.25 — the only file pnpm reads `overrides` from. It carries the reason and the condition for removing it.
55
+
56
+ - Name the generated app in its own chrome. The breadcrumb was hard-coded to the template's own
57
+ name, so every scaffold shipped labelled after the template rather than the name the user passed
58
+ — the first thing on screen, and the one place the app names itself back to the reader. The
59
+ extract step now parameterises it alongside the page title and the store's storage keys.
60
+
61
+ A `boilerplate-identity` sanitize pattern makes this class of leak a red build instead of a
62
+ silent default: any un-templated mention of the source app's name fails extraction, so a rewrite
63
+ that stops matching is caught rather than shipped.
64
+
3
65
  ## 0.1.0
4
66
 
5
67
  ### Minor Changes
6
68
 
7
- - [#8229](https://github.com/keboola/ui/pull/8229) [`67848b3`](https://github.com/keboola/ui/commit/67848b3b89cc39b40f7c47edca838dcc9c0411dc) Thanks [@Mystypix](https://github.com/Mystypix)! - Initial release. `npm create keboola-app` scaffolds a Keboola-shaped frontend app — Vite,
69
+ - Initial release. `npm create keboola-app` scaffolds a Keboola-shaped frontend app — Vite,
8
70
  React 19, TanStack Router/Query/Form, Zustand, `@keboola/design` with
9
71
  `@keboola/brand-registry`, oxlint/oxfmt and vitest — from a minimal shell plus the blocks you
10
72
  pick: `kpi`, `chart`, `grid`, `crud`, `assistant`. Blocks are independent, so any combination
@@ -19,6 +81,6 @@
19
81
 
20
82
  ### Patch Changes
21
83
 
22
- - [#8233](https://github.com/keboola/ui/pull/8233) [`6c449f9`](https://github.com/keboola/ui/commit/6c449f9ed8024551aaaf2a8b3a24ed109f7d263d) Thanks [@Mystypix](https://github.com/Mystypix)! - In `--workspace` mode the generated app inherits the host repo's `engines`, and the monorepo entry point is `pnpm scaffold:app`.
84
+ - In `--workspace` mode the generated app inherits the host repo's `engines`, and the monorepo entry point is `pnpm scaffold:app`.
23
85
 
24
86
  The template carries a standalone `engines.node` floor for apps generated outside a monorepo. Inside one that floor is not a default but a disagreement: it was the lowest value across every workspace, so syncpack wanted to rewrite the other 21 manifests and `pnpm deps:check` went red on a freshly scaffolded app. Workspace mode now reads the root manifest's `engines` instead, and the gate asserts it.
package/README.md CHANGED
@@ -25,22 +25,27 @@ npm create keboola-app -- --name my-app --blocks kpi,grid --yes --json
25
25
 
26
26
  ## Blocks
27
27
 
28
- | Block | What you get |
29
- | ----------- | -------------------------------------------------------------------------------------- |
30
- | `kpi` | A KPI card row on the overview. |
31
- | `chart` | A token-themed line chart card on the overview. |
32
- | `grid` | An editable per-row budget grid (TanStack Table + design `Table`). |
33
- | `crud` | A list → detail → form slice over `/tokens`, exercising router params, Query and Form. |
34
- | `assistant` | A docked assistant rail, a page composer and an explain-on-selection floater. |
28
+ | Block | What you get |
29
+ | ----------- | ----------------------------------------------------------------------------------------- |
30
+ | `kpi` | A KPI card row on the overview. |
31
+ | `chart` | A token-themed line chart card on the overview. |
32
+ | `grid` | An editable per-row budget grid (TanStack Table + design `Table`). |
33
+ | `crud` | A list → detail → form slice over `/tokens`, exercising router params, Query and Form. |
34
+ | `assistant` | A docked assistant rail, a page composer and an explain-on-selection floater. |
35
+ | `auth` | A signed-out screen at `/signed-out`, with the restoring, expired and unreachable states. |
35
36
 
36
37
  Every block is independent — none imports another, so any combination compiles. Blocks are
37
38
  additive: pick what you need rather than taking everything and deleting, since an unpicked
38
39
  block costs no files and pulls none of its npm dependencies.
39
40
 
40
- Blocks join the shell through three files the generator writes rather than patches:
41
- `src/nav.ts` (the nav tuple), `src/overview-sections.tsx` (the ordered section registry) and
42
- `src/store.ts` (the shell slice plus the selected block slices). Add a section to a generated
43
- app by editing those, not the app chrome.
41
+ Blocks join the shell through four files the generator writes rather than patches: `src/nav.ts`
42
+ (the nav tuple), `src/overview-sections.tsx` (the ordered section registry), `src/store.ts` (the
43
+ shell slice plus the selected block slices) and `src/routes/__root.tsx` (whether a route gets the
44
+ app chrome). Add a section to a generated app by editing those, not the app chrome.
45
+
46
+ `auth` is the screen and nothing behind it. It renders with no dependency and reaches no network,
47
+ because only your app knows what it authenticates against — swap the form's `onSubmit` and pick
48
+ the state from your session instead of from the `?state=` search param.
44
49
 
45
50
  ## Naming
46
51
 
@@ -4,7 +4,8 @@ const BLOCK_IDS = [
4
4
  "chart",
5
5
  "grid",
6
6
  "crud",
7
- "assistant"
7
+ "assistant",
8
+ "auth"
8
9
  ];
9
10
  const isBlockId = (value) => BLOCK_IDS.includes(value);
10
11
  const BLOCKS = {
@@ -55,19 +56,19 @@ const BLOCKS = {
55
56
  label: "CRUD slice",
56
57
  summary: "List → detail → form over `/tokens`: router params, Query and Form in one flow.",
57
58
  files: [
58
- "src/routes/tokens/-fixtures.test.ts",
59
- "src/routes/tokens/-fixtures.ts",
60
- "src/routes/tokens/-hooks.ts",
61
- "src/routes/tokens/-index.test.tsx",
62
- "src/routes/tokens/-nav.ts",
63
- "src/routes/tokens/-types.ts",
64
59
  "src/routes/tokens/$id.tsx",
60
+ "src/routes/tokens/index.test.tsx",
65
61
  "src/routes/tokens/index.tsx",
66
- "src/routes/tokens/new.tsx"
62
+ "src/routes/tokens/new.tsx",
63
+ "src/tokens/fixtures.test.ts",
64
+ "src/tokens/fixtures.ts",
65
+ "src/tokens/hooks.ts",
66
+ "src/tokens/nav.ts",
67
+ "src/tokens/types.ts"
67
68
  ],
68
69
  deps: ["@tanstack/react-form", "zod"],
69
70
  glue: ["src/nav.ts"],
70
- brief: "a list page reachable from the rail",
71
+ brief: "tokens list page: on-brand @keboola/design shell, a searchable table of API tokens, no runtime or a11y errors",
71
72
  route: "/tokens"
72
73
  },
73
74
  assistant: {
@@ -87,6 +88,24 @@ const BLOCKS = {
87
88
  glue: ["src/store.ts"],
88
89
  brief: "a prompt input and a docked assistant rail",
89
90
  route: null
91
+ },
92
+ auth: {
93
+ id: "auth",
94
+ label: "Signed-out screen",
95
+ summary: "The pre-project screen: brand lockup, one centred card, and the signed-out, restoring, expired and unreachable states.",
96
+ files: [
97
+ "src/auth/AuthScreen.test.tsx",
98
+ "src/auth/AuthScreen.tsx",
99
+ "src/auth/session.test.tsx",
100
+ "src/auth/session.ts",
101
+ "src/auth/states.tsx",
102
+ "src/routes/signed-out.test.tsx",
103
+ "src/routes/signed-out.tsx"
104
+ ],
105
+ deps: [],
106
+ glue: ["src/routes/__root.tsx"],
107
+ brief: "signed-out screen: a brand lockup over one centred sign-in card on a muted page, no navigation rail and no breadcrumb, a colour-scheme control in the corner, no runtime or a11y errors",
108
+ route: "/signed-out"
90
109
  }
91
110
  };
92
111
  /** Files every emitted app carries, whatever the selection. */
@@ -103,12 +122,12 @@ const SHELL_FILES = [
103
122
  "src/main.tsx",
104
123
  "src/nav.test.ts",
105
124
  "src/nav.ts",
125
+ "src/overview-fixtures.ts",
126
+ "src/overview-hooks.ts",
106
127
  "src/overview-sections.test.tsx",
107
128
  "src/overview-sections.tsx",
108
- "src/routes/-fixtures.ts",
109
- "src/routes/-hooks.ts",
110
- "src/routes/-index.test.tsx",
111
129
  "src/routes/__root.tsx",
130
+ "src/routes/index.test.tsx",
112
131
  "src/routes/index.tsx",
113
132
  "src/slices/shell.ts",
114
133
  "src/store.test.tsx",
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as isBlockId, n as BLOCK_IDS, o as ownerOf, t as BLOCKS } from "./blocks-DYU5Ke29.js";
2
+ import { a as isBlockId, n as BLOCK_IDS, o as ownerOf, t as BLOCKS } from "./blocks-D_-NWhwg.js";
3
3
  import { a as substitute, i as renderRegions, n as assertNoMarkers, r as blockIdOf } from "./regions-B3r5LhXl.js";
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
@@ -132,7 +132,9 @@ const evalCommand = (route, brief) => `npx --yes @keboola/ui-gen-bench eval --se
132
132
  *
133
133
  * - The overview's brief names the blocks that are actually on the page, because the score's
134
134
  * brief-conformance axis marks down a page for missing what the brief promises.
135
- * - A block with a `route` adds a second invocation for it.
135
+ * - A block with a `route` adds an invocation for it, briefed entirely by the block. The
136
+ * conformance axis is graded against this text, so a shared suffix here would grade every
137
+ * routed block against one page's qualities.
136
138
  * - Not wired into `test`: it needs a model key, and a first run that fails on a missing
137
139
  * credential is a worse welcome than an opt-in gate.
138
140
  */
@@ -143,7 +145,7 @@ const gateUiScript = (appTitle, blocks) => {
143
145
  ...clauses,
144
146
  "no runtime or a11y errors"
145
147
  ].join(", ");
146
- const extras = blocks.filter((id) => BLOCKS[id].route !== null).map((id) => evalCommand(BLOCKS[id].route ?? "/", `${appTitle} ${BLOCKS[id].brief}: on-brand @keboola/design shell, searchable table, no runtime or a11y errors`));
148
+ const extras = blocks.filter((id) => BLOCKS[id].route !== null).map((id) => evalCommand(BLOCKS[id].route ?? "/", `${appTitle} ${BLOCKS[id].brief}`));
147
149
  return [evalCommand("/", overview), ...extras].join(" && ");
148
150
  };
149
151
  const walk = (root, dir, out) => {
@@ -196,6 +198,29 @@ const writeManifest = (opts) => {
196
198
  };
197
199
  writeFileSync(join(targetDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
198
200
  };
201
+ /**
202
+ * `pnpm-workspace.yaml` for a standalone app.
203
+ *
204
+ * - It is the only file pnpm 11 reads `overrides` from; the `pnpm` field in `package.json` is
205
+ * ignored with a warning.
206
+ * - Written only outside a monorepo. Under `--workspace` the app inherits the root's file, and a
207
+ * nested one would declare a second workspace root inside the first.
208
+ */
209
+ const writeWorkspaceConfig = (targetDir) => {
210
+ writeFileSync(join(targetDir, "pnpm-workspace.yaml"), `# pnpm reads \`overrides\` from this file only — the \`pnpm\` field in package.json is ignored.
211
+ overrides:
212
+ # jsdom's selector engine, held at the last release that does not re-enter itself.
213
+ #
214
+ # - 2.2.26 answers \`:modal\`, \`:fullscreen\` and \`:open\` through a \`matchesNative\` helper
215
+ # that falls back to the element's own \`matches\` — under jsdom that is nwsapi again, so the
216
+ # two recurse, and \`:modal\` doubles the work per level via its \`|| isFullscreen\` branch.
217
+ # - \`@floating-ui/dom\` probes \`:modal\` on every popper placement, so one dropdown or tooltip
218
+ # interaction costs ~9s instead of ~30ms and a userEvent test hits its timeout.
219
+ # - Remove the pin once nwsapi stops resolving its own \`matches\` back into itself:
220
+ # https://github.com/dperini/nwsapi
221
+ nwsapi: 2.2.25
222
+ `);
223
+ };
199
224
  const scaffold = (opts) => {
200
225
  const { templateDir, targetDir, appName, blocks, workspace } = opts;
201
226
  if (existsSync(targetDir)) throw new Error(`${targetDir} already exists. Choose another name or remove the directory first.`);
@@ -231,6 +256,10 @@ const scaffold = (opts) => {
231
256
  packageName
232
257
  });
233
258
  written.push("package.json");
259
+ if (!workspace) {
260
+ writeWorkspaceConfig(targetDir);
261
+ written.push("pnpm-workspace.yaml");
262
+ }
234
263
  return {
235
264
  path: targetDir,
236
265
  appName,
@@ -287,7 +316,8 @@ const generateRouteTree = async (appDir) => {
287
316
  await new Generator({
288
317
  config: getConfig({
289
318
  quoteStyle: "single",
290
- semicolons: true
319
+ semicolons: true,
320
+ routeFileIgnorePattern: "\\.test\\.tsx?$"
291
321
  }, appDir),
292
322
  root: appDir
293
323
  }).run();
package/dist/extract.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as isBlockId, i as SHELL_FILES, n as BLOCK_IDS, r as NOT_IN_TEMPLATE, t as BLOCKS } from "./blocks-DYU5Ke29.js";
1
+ import { a as isBlockId, i as SHELL_FILES, n as BLOCK_IDS, r as NOT_IN_TEMPLATE, t as BLOCKS } from "./blocks-D_-NWhwg.js";
2
2
  import { i as renderRegions, r as blockIdOf } from "./regions-B3r5LhXl.js";
3
3
  import { r as scan, t as formatFindings } from "./sanitize-CKs8pAzZ.js";
4
4
  import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
@@ -47,9 +47,20 @@ const decideRegion = (regionId) => {
47
47
  if (id === null || !isBlockId(id)) throw new Error(`unknown region id "${regionId}" — expected "internal" or "block:<blockId>"`);
48
48
  return "keep-with-markers";
49
49
  };
50
- /** Per-file rewrites that turn boilerplate identity into placeholders. */
50
+ /**
51
+ * Per-file rewrites that turn this app's identity into placeholders.
52
+ *
53
+ * - Every rewrite has a matching assertion in `extract.test.ts`, and the sanitize scan fails
54
+ * the build on any surviving mention of this app's name — a rewrite that stops matching is
55
+ * a red build, not a scaffold that quietly names itself after the template.
56
+ * - Match structurally, not by the literal name. The name must not appear here: the packed
57
+ * tarball is scanned too, and a rewrite that spells its own needle makes the scanner match
58
+ * its own implementation.
59
+ */
51
60
  const parameterise = (file, text) => {
52
61
  if (file === "index.html") return text.replace(/<title>[^<]*<\/title>/, "<title>__APP_TITLE__</title>");
62
+ if (file === "src/components/AppChrome.tsx") return text.replace(/(<Link to="\/">)[^<]*(<\/Link>)/, "$1__APP_TITLE__$2");
63
+ if (file === "src/auth/AuthScreen.tsx") return text.replace(/^const PRODUCT = '[^']*';$/m, "const PRODUCT = '__APP_TITLE__';");
53
64
  if (file === "src/store.ts") return text.replaceAll(/'boilerplate\/([^']+)'/g, "'__APP_NAME__/$1'");
54
65
  return text;
55
66
  };
@@ -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`.
@@ -19,10 +19,8 @@ export default defineConfig({
19
19
  },
20
20
  },
21
21
  jsPlugins: [
22
- // Local design-system + brand-safety lint rules — see
23
- // packages/eslint-plugin-design. Aliased to `design` so rule IDs read
24
- // `design/<rule>` in lint output. Boilerplate is the canonical scaffold
25
- // cloned by `pnpm scaffold:app`, so every generated app inherits these.
22
+ // Local design-system + brand-safety lint rules. Aliased to `design` so rule
23
+ // IDs read `design/<rule>` in lint output.
26
24
  { name: 'design', specifier: '@keboola/eslint-plugin-design' },
27
25
  ],
28
26
  rules: {
@@ -15,8 +15,8 @@
15
15
  "type-check": "tsc"
16
16
  },
17
17
  "dependencies": {
18
- "@keboola/brand-registry": "^1.9.2",
19
- "@keboola/design": "^11.0.1",
18
+ "@keboola/brand-registry": "^1.9.4",
19
+ "@keboola/design": "^11.5.0",
20
20
  "@tanstack/react-form": "^1.29.1",
21
21
  "@tanstack/react-query": "^5.96.2",
22
22
  "@tanstack/react-router": "^1.120.4",
@@ -28,9 +28,9 @@
28
28
  "zustand": "^5.0.8"
29
29
  },
30
30
  "devDependencies": {
31
- "@keboola/eslint-plugin-design": "^0.4.2",
32
- "@keboola/oxlint-config": "^10.0.1",
33
- "@keboola/tsconfig": "^0.2.0",
31
+ "@keboola/eslint-plugin-design": "^0.4.3",
32
+ "@keboola/oxlint-config": "^10.0.2",
33
+ "@keboola/tsconfig": "^0.2.1",
34
34
  "@tailwindcss/vite": "^4.3.3",
35
35
  "@tanstack/react-query-devtools": "^5.96.2",
36
36
  "@testing-library/jest-dom": "^6.8.0",
@@ -43,8 +43,8 @@
43
43
  "@vitejs/plugin-react": "^6.0.0",
44
44
  "eslint-plugin-better-tailwindcss": "^4.7.0",
45
45
  "jsdom": "^26.1.0",
46
- "oxfmt": "^0.57.0",
47
- "oxlint": "^1.72.0",
46
+ "oxfmt": "^0.66.0",
47
+ "oxlint": "^1.81.0",
48
48
  "tailwindcss": "^4.3.3",
49
49
  "typescript": "npm:@typescript/typescript6@^6.0.2",
50
50
  "vite": "^8.1.5",
@@ -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,63 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { BrandLogo, Card } from '@keboola/design';
4
+
5
+ import { ColorModeToggle } from '../components/ColorModeToggle';
6
+
7
+ // The product name beside the brand mark.
8
+ //
9
+ // - `create-keboola-app` rewrites this literal to the generated app's title while extracting
10
+ // the template (`parameterise` in the package's `extract.ts`), so it has to stay a top-level
11
+ // single-quoted `const`.
12
+ const PRODUCT = '__APP_TITLE__';
13
+
14
+ /**
15
+ * The chrome every pre-project screen shares: brand lockup, one centred card, no navigation.
16
+ *
17
+ * - Renders *instead of* `AppChrome`, not inside it — `routes/__root.tsx` picks one from
18
+ * `useIsAuthRoute`. A visitor with no session gets no rail, no breadcrumb and no history.
19
+ * - `tailwindcss-preflight` and `text-foreground` are set here rather than inherited. A real app
20
+ * moves this screen ahead of the router, because the session decides whether the router mounts
21
+ * at all, and then nothing above it sets either: without the preflight scope the card's
22
+ * controls pick up UA chrome, and without the ink `BrandLogo`'s inline SVG inherits
23
+ * `currentColor` and goes near-black on the dark page.
24
+ * - `header` and `main` are landmarks, not decoration. Outside one, axe reports these controls as
25
+ * unreachable by landmark navigation — which is why the colour-scheme control sits inside
26
+ * `header` even though it is positioned away from the lockup.
27
+ * - `Card.Title as="h1"` — the card carries the page's only heading, and the default `h3` opens
28
+ * the outline three levels down.
29
+ * - `variant="mark"` beside the product name as text. The wordmark already spells the brand, so
30
+ * pairing it with a product name states the brand twice; the mark reads as one lockup. It also
31
+ * degrades in the right order — `BrandLogo` falls back to the wordmark for a brand that ships
32
+ * no mark and returns `null` for one that ships neither, which is why the name is text and not
33
+ * part of the image.
34
+ * - `h-8`, not the navbar's `h-6`: with no chrome around it the lockup is the only thing naming
35
+ * the product, and at bar size it reads as chrome that lost its bar.
36
+ * - Every pre-project state reuses this chrome — see `AUTH_STATES` in `states.tsx` — so the
37
+ * common path does not jump when one state replaces another.
38
+ */
39
+ export function AuthScreen({ title, children }: { title: string; children: ReactNode }) {
40
+ return (
41
+ <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">
42
+ <header>
43
+ <div className="tw:flex tw:items-center tw:gap-3">
44
+ <BrandLogo variant="mark" className="tw:h-8" />
45
+ <span className="tw:text-lg tw:font-semibold">{PRODUCT}</span>
46
+ </div>
47
+
48
+ <div className="tw:absolute tw:top-4 tw:right-4">
49
+ <ColorModeToggle />
50
+ </div>
51
+ </header>
52
+
53
+ <main className="tw:w-full tw:max-w-md">
54
+ <Card>
55
+ <Card.Header layout="stack">
56
+ <Card.Title as="h1">{title}</Card.Title>
57
+ </Card.Header>
58
+ <Card.Content className="tw:flex tw:flex-col tw:gap-4">{children}</Card.Content>
59
+ </Card>
60
+ </main>
61
+ </div>
62
+ );
63
+ }
@@ -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,58 @@
1
+ import type { ComponentType } from 'react';
2
+ import { useRouterState } from '@tanstack/react-router';
3
+
4
+ import { Expired, Restoring, SignInForm, Unavailable } from './states';
5
+
6
+ /**
7
+ * The signed-out screen's path.
8
+ *
9
+ * - Spelled again as a literal in `createFileRoute`. TanStack's generator reads that argument as
10
+ * source text, so it cannot be a reference.
11
+ */
12
+ export const AUTH_ROUTE = '/signed-out';
13
+
14
+ /**
15
+ * Whether the router is on the signed-out screen.
16
+ *
17
+ * - The root layout asks, because it is the only component the chromed and chromeless branches
18
+ * share.
19
+ * - `select` returns the boolean, not the pathname, so navigating between two chromed routes does
20
+ * not re-render the root.
21
+ * - A real app asks its session instead. This screen belongs to *having no session*, not to a path
22
+ * a visitor can type — the path exists so the screen is reachable without a backend.
23
+ */
24
+ export const useIsAuthRoute = (): boolean =>
25
+ useRouterState({ select: (state) => state.location.pathname === AUTH_ROUTE });
26
+
27
+ /** One pre-project state: the card's heading, and the body that goes under it. */
28
+ type AuthStateView = {
29
+ title: string;
30
+ /** Handed `onRetry`, which returns the screen to the sign-in form. States that offer no retry
31
+ * simply ignore it. */
32
+ body: ComponentType<{ onRetry: () => void }>;
33
+ };
34
+
35
+ /**
36
+ * The pre-project states, keyed by the session status that produces each.
37
+ *
38
+ * - Four, not one: a signed-out screen that models only "signed out" leaves the other three to be
39
+ * invented per app, and they are the ones that get a11y and copy wrong.
40
+ * - Every state renders inside the same `AuthScreen`, so the common path does not jump when one
41
+ * replaces another.
42
+ * - The keys are the contract — `AuthState` is derived from them, so adding a state here is all it
43
+ * takes for the route to accept and render it.
44
+ * - Components, not render functions, so this module holds no JSX and stays the one place the
45
+ * route's search param is validated.
46
+ */
47
+ export const AUTH_STATES = {
48
+ 'signed-out': { title: 'Sign in', body: SignInForm },
49
+ restoring: { title: 'Restoring your session', body: Restoring },
50
+ expired: { title: 'Session expired', body: Expired },
51
+ unavailable: { title: 'Cannot reach the service', body: Unavailable },
52
+ } as const satisfies Readonly<Record<string, AuthStateView>>;
53
+
54
+ export type AuthState = keyof typeof AUTH_STATES;
55
+
56
+ /** Narrows an unvalidated search param. Anything else is not a state, including `undefined`. */
57
+ export const isAuthState = (value: unknown): value is AuthState =>
58
+ typeof value === 'string' && Object.hasOwn(AUTH_STATES, value);
@@ -0,0 +1,88 @@
1
+ import { Alert, Button, DotsLoader, useAppForm } from '@keboola/design';
2
+
3
+ /** What every state's body is handed. `onRetry` returns the screen to the sign-in form. */
4
+ type AuthStateProps = { onRetry: () => void };
5
+
6
+ /**
7
+ * The credential form — the one part that legitimately differs per app.
8
+ *
9
+ * - Presentational on purpose: `onSubmit` reaches no network, because only the app knows what it
10
+ * authenticates against (password, PAT, OAuth) and what to do with the answer. Replace the body
11
+ * of `onSubmit`; nothing else in this directory has to move.
12
+ * - `useAppForm` rather than a bare `useForm`: the connectors carry the label, the required marker
13
+ * and the error slot, so the field markup does not restate them.
14
+ * - `w-full` on the submit: it is the card's only action, so a content-width button reads as one
15
+ * option among several.
16
+ */
17
+ export const SignInForm = () => {
18
+ const form = useAppForm({
19
+ defaultValues: { email: '', password: '' },
20
+ onSubmit: async ({ value }) => {
21
+ await Promise.resolve(value);
22
+ },
23
+ });
24
+
25
+ return (
26
+ <form.AppForm>
27
+ <form.FormBody>
28
+ <form.AppField name="email">
29
+ {(field) => <field.TextField label="Email" type="email" required />}
30
+ </form.AppField>
31
+ <form.AppField name="password">
32
+ {(field) => <field.PasswordField label="Password" required />}
33
+ </form.AppField>
34
+ <form.SubmitButton className="tw:w-full">Sign in</form.SubmitButton>
35
+ </form.FormBody>
36
+ </form.AppForm>
37
+ );
38
+ };
39
+
40
+ /**
41
+ * A persisted session being rehydrated.
42
+ *
43
+ * `aria-live="polite"`: the wait resolves without the reader acting, so the outcome has to be
44
+ * announced rather than found.
45
+ */
46
+ export const Restoring = () => (
47
+ <div
48
+ className="tw:flex tw:items-center tw:gap-3 tw:text-sm tw:text-muted-foreground"
49
+ aria-live="polite"
50
+ >
51
+ <DotsLoader />
52
+ Checking whether you are still signed in.
53
+ </div>
54
+ );
55
+
56
+ /**
57
+ * A credential that was accepted once and is not any more.
58
+ *
59
+ * `warning`, not `error`: nothing malfunctioned and there is nothing to report — the session ran
60
+ * out, and the fix is one click away.
61
+ */
62
+ export const Expired = ({ onRetry }: AuthStateProps) => (
63
+ <>
64
+ <Alert variant="warning">
65
+ Your session ended. Sign in again to pick up where you left off.
66
+ </Alert>
67
+ <Button variant="primary" className="tw:w-full" onClick={onRetry}>
68
+ Sign in again
69
+ </Button>
70
+ </>
71
+ );
72
+
73
+ /**
74
+ * The service could not be reached, so the credential was never judged.
75
+ *
76
+ * Distinct from `expired` on purpose: telling someone their session expired while the network is
77
+ * down sends them to re-enter a password that would have worked.
78
+ */
79
+ export const Unavailable = ({ onRetry }: AuthStateProps) => (
80
+ <>
81
+ <Alert variant="error">
82
+ We could not reach the service. Your credentials are fine — the request never got an answer.
83
+ </Alert>
84
+ <Button variant="outline" className="tw:w-full" onClick={onRetry}>
85
+ Try again
86
+ </Button>
87
+ </>
88
+ );
@@ -89,7 +89,7 @@ export function AppChrome({ children }: { children: ReactNode }) {
89
89
  <Navbar.Menu marker="forward-slash">
90
90
  <Navbar.MenuItem>
91
91
  <Navbar.MenuButton asChild>
92
- <Link to="/">Boilerplate</Link>
92
+ <Link to="/">__APP_TITLE__</Link>
93
93
  </Navbar.MenuButton>
94
94
  </Navbar.MenuItem>
95
95
  </Navbar.Menu>
@@ -3,7 +3,7 @@ import type { ComponentType } from 'react';
3
3
  import { LayoutDashboard } from '@keboola/design/icons';
4
4
 
5
5
  // #region block:crud
6
- import { tokensNavItem } from './routes/tokens/-nav';
6
+ import { tokensNavItem } from './tokens/nav';
7
7
  // #endregion block:crud
8
8
 
9
9
  export type NavItem = { to: string; label: string; icon: ComponentType };
@@ -1,7 +1,7 @@
1
1
  // In-memory stand-in for a real overview endpoint. Swap `fetchOverview` for the
2
2
  // api-client call (e.g. `api.metrics.overview(signal)`) and delete this file.
3
3
  // The call takes an AbortSignal and honors it, so the query hook threads
4
- // cancellation exactly as it would against a real client — see -hooks.ts.
4
+ // cancellation exactly as it would against a real client — see overview-hooks.ts.
5
5
 
6
6
  export type Kpi = {
7
7
  label: string;
@@ -1,6 +1,6 @@
1
1
  import { useQuery } from '@tanstack/react-query';
2
2
 
3
- import { fetchOverview } from './-fixtures';
3
+ import { fetchOverview } from './overview-fixtures';
4
4
 
5
5
  const queryKeys = {
6
6
  overview: ['overview'] as const,
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
- import type { Overview } from './routes/-fixtures';
4
3
  // #region block:chart
5
4
  import { chartSection } from './sections/chart';
6
5
  // #endregion block:chart
@@ -10,6 +9,10 @@ import { gridSection } from './sections/grid';
10
9
  // #region block:kpi
11
10
  import { kpiSection } from './sections/kpi';
12
11
  // #endregion block:kpi
12
+ // `simple-import-sort` orders a sibling path after every nested one, so this import belongs
13
+ // past the final section region rather than inside it — the shell needs it whether or not a
14
+ // scaffold keeps that block.
15
+ import type { Overview } from './overview-fixtures';
13
16
 
14
17
  /**
15
18
  * One block's contribution to the overview.
@@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { expect, it } from 'vitest';
4
4
 
5
- import type { BudgetRow } from '../routes/-fixtures';
5
+ import type { BudgetRow } from '../overview-fixtures';
6
6
 
7
7
  import { CreditBudgetGrid } from './DataGrid';
8
8
 
@@ -8,8 +8,8 @@ import {
8
8
 
9
9
  import { NumberInput, Table } from '@keboola/design';
10
10
 
11
- import type { BudgetRow } from '../routes/-fixtures';
12
- import { BUDGET_MONTHS } from '../routes/-fixtures';
11
+ import type { BudgetRow } from '../overview-fixtures';
12
+ import { BUDGET_MONTHS } from '../overview-fixtures';
13
13
 
14
14
  // Instantiates the DS editable-grid recipe (`Table.DataGridRecipe` story,
15
15
  // `EditableDataGrid`): TanStack Table v8 (headless) for the column/row model,
@@ -2,6 +2,9 @@ import { createRootRoute, Outlet } from '@tanstack/react-router';
2
2
 
3
3
  import { Toaster } from '@keboola/design';
4
4
 
5
+ // #region block:auth
6
+ import { useIsAuthRoute } from '../auth/session';
7
+ // #endregion block:auth
5
8
  import { AppChrome } from '../components/AppChrome';
6
9
 
7
10
  export const Route = createRootRoute({
@@ -9,6 +12,31 @@ export const Route = createRootRoute({
9
12
  });
10
13
 
11
14
  function RootLayout() {
15
+ // #region block:auth
16
+ // The signed-out screen replaces the chrome instead of rendering inside it.
17
+ //
18
+ // - A visitor with no session must see no rail, no breadcrumb and no history.
19
+ // - Nesting it would also give the page a second `header`/`main` pair inside AppChrome's, which
20
+ // axe reports as duplicate landmarks.
21
+ // - Decided here because the root layout is the only component both branches share, and
22
+ // TanStack nests every file route under it.
23
+ // - The preflight scope is restated even though `AuthScreen` carries its own: that scope is a
24
+ // class on an element, so it does not reach `Toaster` beside it, and design's preflight is
25
+ // opt-in. Measured outside it, a bare `button` gets a 2px outset border and Arial 13.3px.
26
+ // - The blank line above the region's closing marker is inside the region on purpose. Left
27
+ // outside, it becomes the whole body of an app generated without this block, and that app's
28
+ // `oxfmt --check` fails on a file nobody wrote.
29
+ const isAuthRoute = useIsAuthRoute();
30
+ if (isAuthRoute) {
31
+ return (
32
+ <div className="tailwindcss-preflight">
33
+ <Outlet />
34
+ <Toaster />
35
+ </div>
36
+ );
37
+ }
38
+
39
+ // #endregion block:auth
12
40
  return (
13
41
  <div className="tailwindcss-preflight">
14
42
  <AppChrome>
@@ -6,9 +6,9 @@ import userEvent from '@testing-library/user-event';
6
6
  // #endregion block:assistant
7
7
  import { beforeEach, describe, expect, it, vi } from 'vitest';
8
8
 
9
+ import * as fixtures from '../overview-fixtures';
9
10
  import { useAppStore } from '../store';
10
11
 
11
- import * as fixtures from './-fixtures';
12
12
  import { HomePage } from './index';
13
13
 
14
14
  const renderPage = () => {
@@ -7,12 +7,11 @@ import { ErrorState, PageHeader, PageLayout } from '@keboola/design';
7
7
  import { HomePrompt } from '../components/HomePrompt';
8
8
  import { SelectionFloater } from '../components/SelectionFloater';
9
9
  // #endregion block:assistant
10
+ import type { Overview } from '../overview-fixtures';
11
+ import { useOverview } from '../overview-hooks';
10
12
  import { OVERVIEW_SECTIONS } from '../overview-sections';
11
13
  import { useAppStore } from '../store';
12
14
 
13
- import type { Overview } from './-fixtures';
14
- import { useOverview } from './-hooks';
15
-
16
15
  export const Route = createFileRoute('/')({
17
16
  component: HomePage,
18
17
  });
@@ -0,0 +1,56 @@
1
+ import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { keboola } from '@keboola/brand-registry';
6
+ import { ThemeProvider } from '@keboola/design';
7
+
8
+ import { routeTree } from '../routeTree.gen';
9
+
10
+ // jsdom implements no scrolling, and Router's scroll restoration calls this on every navigation —
11
+ // unstubbed it floods the run with `Not implemented: window.scrollTo`.
12
+ beforeEach(() => {
13
+ vi.stubGlobal('scrollTo', vi.fn());
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ // The real tree, not a stub: the behaviour under test is the root layout choosing between
21
+ // AppChrome and the bare outlet, and a hand-built tree would not exercise that choice.
22
+ const renderAt = (path: string) => {
23
+ const router = createRouter({
24
+ routeTree,
25
+ history: createMemoryHistory({ initialEntries: [path] }),
26
+ });
27
+ return render(
28
+ <ThemeProvider brands={[keboola]} defaultBrandId={keboola.id} defaultScheme="light">
29
+ <RouterProvider router={router} />
30
+ </ThemeProvider>,
31
+ );
32
+ };
33
+
34
+ describe('/signed-out', () => {
35
+ it('renders without the app chrome', async () => {
36
+ renderAt('/signed-out');
37
+
38
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
39
+ // The rail is the tell: a signed-out visitor must reach no destination in the app.
40
+ expect(screen.queryByRole('navigation', { name: 'Sections' })).not.toBeInTheDocument();
41
+ });
42
+
43
+ it('picks the state named by the search param', async () => {
44
+ renderAt('/signed-out?state=expired');
45
+
46
+ expect(
47
+ await screen.findByRole('heading', { level: 1, name: 'Session expired' }),
48
+ ).toBeInTheDocument();
49
+ });
50
+
51
+ it('falls back to the sign-in form for a state it does not know', async () => {
52
+ renderAt('/signed-out?state=nonsense');
53
+
54
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument();
55
+ });
56
+ });
@@ -0,0 +1,35 @@
1
+ import { createFileRoute, useNavigate } from '@tanstack/react-router';
2
+
3
+ import { AuthScreen } from '../auth/AuthScreen';
4
+ import type { AuthState } from '../auth/session';
5
+ import { AUTH_ROUTE, AUTH_STATES, isAuthState } from '../auth/session';
6
+
7
+ /**
8
+ * `/signed-out` — the pre-project screen.
9
+ *
10
+ * - `?state=` stands in for the app's session status, so all four states are reachable in a
11
+ * browser and in the UI gate without a backend. A real app drops the search param and picks the
12
+ * state from its session hook; nothing in `auth/` changes when it does.
13
+ * - `validateSearch` narrows by hand rather than through a schema. One optional enum does not
14
+ * need a validation library, and the block deliberately carries no dependency of its own.
15
+ * - An unknown or missing `state` falls back to the sign-in form rather than throwing: this is the
16
+ * screen a visitor lands on with no session, so it has to render whatever the URL says.
17
+ */
18
+ export const Route = createFileRoute('/signed-out')({
19
+ validateSearch: (search: Record<string, unknown>): { state: AuthState } => ({
20
+ state: isAuthState(search.state) ? search.state : 'signed-out',
21
+ }),
22
+ component: SignedOutPage,
23
+ });
24
+
25
+ function SignedOutPage() {
26
+ const { state } = Route.useSearch();
27
+ const navigate = useNavigate();
28
+ const { title, body: Body } = AUTH_STATES[state];
29
+
30
+ return (
31
+ <AuthScreen title={title}>
32
+ <Body onRetry={() => void navigate({ to: AUTH_ROUTE, search: { state: 'signed-out' } })} />
33
+ </AuthScreen>
34
+ );
35
+ }
@@ -2,8 +2,8 @@ import { createFileRoute, Link } from '@tanstack/react-router';
2
2
 
3
3
  import { Breadcrumb, Button, ErrorState, PageHeader, PageLayout, Skeleton } from '@keboola/design';
4
4
 
5
- import { useToken } from './-hooks';
6
- import type { Token } from './-types';
5
+ import { useToken } from '../../tokens/hooks';
6
+ import type { Token } from '../../tokens/types';
7
7
 
8
8
  export const Route = createFileRoute('/tokens/$id')({
9
9
  component: TokenDetailPage,
@@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
- import type { Token } from './-types';
6
+ import type { Token } from '../../tokens/types';
7
7
 
8
8
  // Render the page without a RouterProvider: mock the two router exports it uses
9
9
  // (`createFileRoute` returns the Route object; `Link` renders a plain anchor).
@@ -14,7 +14,7 @@ vi.mock('@tanstack/react-router', () => ({
14
14
 
15
15
  // Drive each async branch deterministically by mocking the query hook boundary.
16
16
  const { useTokensMock } = vi.hoisted(() => ({ useTokensMock: vi.fn() }));
17
- vi.mock('./-hooks', () => ({ useTokens: () => useTokensMock() }));
17
+ vi.mock('../../tokens/hooks', () => ({ useTokens: () => useTokensMock() }));
18
18
 
19
19
  import { Route } from './index';
20
20
 
@@ -14,9 +14,8 @@ import {
14
14
  } from '@keboola/design';
15
15
 
16
16
  import { useAppStore } from '../../store';
17
-
18
- import { useTokens } from './-hooks';
19
- import type { Token } from './-types';
17
+ import { useTokens } from '../../tokens/hooks';
18
+ import type { Token } from '../../tokens/types';
20
19
 
21
20
  export const Route = createFileRoute('/tokens/')({
22
21
  component: TokensListPage,
@@ -13,8 +13,8 @@ import {
13
13
  toast,
14
14
  } from '@keboola/design';
15
15
 
16
- import { useCreateToken } from './-hooks';
17
- import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from './-types';
16
+ import { useCreateToken } from '../../tokens/hooks';
17
+ import { type CreateTokenInput, EXPIRY_OPTIONS, type TokenExpiry } from '../../tokens/types';
18
18
 
19
19
  export const Route = createFileRoute('/tokens/new')({
20
20
  component: NewTokenPage,
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest';
2
2
 
3
- import { createToken, fetchToken, fetchTokens, resetFixtures } from './-fixtures';
3
+ import { createToken, fetchToken, fetchTokens, resetFixtures } from './fixtures';
4
4
 
5
5
  describe('fixtures', () => {
6
6
  beforeEach(() => {
@@ -1,4 +1,4 @@
1
- import type { CreateTokenInput, Token } from './-types';
1
+ import type { CreateTokenInput, Token } from './types';
2
2
 
3
3
  // Hand-rolled in-memory store — replaces a real API until @keboola/api-client
4
4
  // gains a typed Tokens namespace. Swap fetchTokens / createToken for real API
@@ -1,7 +1,7 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
2
 
3
- import { createToken, fetchToken, fetchTokens } from './-fixtures';
4
- import type { CreateTokenInput } from './-types';
3
+ import { createToken, fetchToken, fetchTokens } from './fixtures';
4
+ import type { CreateTokenInput } from './types';
5
5
 
6
6
  const queryKeys = {
7
7
  all: ['tokens'] as const,
@@ -1,6 +1,6 @@
1
1
  import { KeyRound } from '@keboola/design/icons';
2
2
 
3
- import type { NavItem } from '../../nav';
3
+ import type { NavItem } from '../nav';
4
4
 
5
5
  /**
6
6
  * This module's entry in the rail.
@@ -9,6 +9,14 @@ export default defineConfig({
9
9
  TanStackRouterVite({
10
10
  routesDirectory: './src/routes',
11
11
  generatedRouteTree: './src/routeTree.gen.ts',
12
+ /**
13
+ * `routes/` holds route files and their tests, nothing else.
14
+ *
15
+ * - A test file exports no `Route`, so unmatched it costs a generator warning per build.
16
+ * - Matched against the file name, not the path (`routeFileIgnorePrefix` still defaults
17
+ * to `-`; nothing here relies on it).
18
+ */
19
+ routeFileIgnorePattern: '\\.test\\.tsx?$',
12
20
  }),
13
21
  react(),
14
22
  tailwindcss(),
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "create-keboola-app",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a Keboola-shaped frontend app — Vite + React 19 + TanStack Router/Query/Form + Zustand + @keboola/design — from a minimal shell plus the blocks you pick",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "bin": "./dist/cli.js",
8
7
  "files": [
9
8
  "dist",
10
9
  "README.md",
@@ -16,17 +15,6 @@
16
15
  "engines": {
17
16
  "node": ">=20"
18
17
  },
19
- "scripts": {
20
- "build": "tsdown && node dist/extract.js",
21
- "lint": "pnpm lint:format && pnpm lint:oxlint",
22
- "lint:fix": "oxlint --fix . && oxfmt",
23
- "lint:format": "oxfmt --check",
24
- "lint:oxlint": "oxlint . --quiet",
25
- "knip:check": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app",
26
- "knip:fix": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app --fix-type types,exports,files",
27
- "test": "vitest run --passWithNoTests --reporter=dot",
28
- "type-check": "tsc"
29
- },
30
18
  "repository": {
31
19
  "type": "git",
32
20
  "url": "https://github.com/keboola/ui.git",
@@ -50,15 +38,29 @@
50
38
  "@tanstack/router-generator": "^1.120.4"
51
39
  },
52
40
  "devDependencies": {
53
- "@keboola/oxlint-config": "workspace:^",
54
- "@keboola/tsconfig": "workspace:^",
55
41
  "@types/node": "24.12.0",
56
42
  "@typescript/native": "npm:typescript@^7.0.2",
57
- "oxfmt": "^0.57.0",
58
- "oxlint": "^1.72.0",
43
+ "oxfmt": "^0.66.0",
44
+ "oxlint": "^1.81.0",
59
45
  "tsdown": "^0.22.14",
60
46
  "typescript": "npm:@typescript/typescript6@^6.0.2",
61
47
  "vite": "^8.1.5",
62
- "vitest": "^4.1.10"
48
+ "vitest": "^4.1.10",
49
+ "@keboola/tsconfig": "^0.2.1",
50
+ "@keboola/oxlint-config": "^10.0.2"
51
+ },
52
+ "scripts": {
53
+ "build": "tsdown && node dist/extract.js",
54
+ "lint": "pnpm lint:format && pnpm lint:oxlint",
55
+ "lint:fix": "oxlint --fix . && oxfmt",
56
+ "lint:format": "oxfmt --check",
57
+ "lint:oxlint": "oxlint . --quiet",
58
+ "knip:check": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app",
59
+ "knip:fix": "pnpm --dir ../.. exec knip --workspace packages/create-keboola-app --fix-type types,exports,files",
60
+ "test": "vitest run --passWithNoTests --reporter=dot",
61
+ "type-check": "tsc"
62
+ },
63
+ "bin": {
64
+ "create-keboola-app": "./dist/cli.js"
63
65
  }
64
- }
66
+ }