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,133 @@
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";
2
+ import { i as renderRegions, r as blockIdOf } from "./regions-B3r5LhXl.js";
3
+ import { r as scan, t as formatFindings } from "./sanitize-CKs8pAzZ.js";
4
+ import { cpSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ //#region src/extract.ts
8
+ /** The scripts the emitted app keeps, keyed the way its `package.json` will hold them. */
9
+ const APP_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
+ const DROPPED_DEPS = /* @__PURE__ */ new Set(["@keboola/ui-gen-bench"]);
18
+ const TEMPLATE_DOCS = [
19
+ "README.md",
20
+ "AGENTS.md",
21
+ "_gitignore",
22
+ "_env.example",
23
+ "_oxfmtrc.json"
24
+ ];
25
+ /** Every workspace package's published version, by name. */
26
+ const workspaceVersions = (packagesDir) => new Map(readdirSync(packagesDir).flatMap((entry) => {
27
+ let manifest;
28
+ try {
29
+ manifest = JSON.parse(readFileSync(join(packagesDir, entry, "package.json"), "utf8"));
30
+ } catch {
31
+ return [];
32
+ }
33
+ const { name, version } = manifest;
34
+ return name !== void 0 && version !== void 0 ? [[name, version]] : [];
35
+ }));
36
+ /**
37
+ * The decision applied to every region while extracting.
38
+ *
39
+ * - `internal` is dropped: it is the marker for text that is true in the monorepo only.
40
+ * - `block:<id>` is kept **with** its markers — the CLI decides per scaffold.
41
+ * - Anything else throws, so a typo in a marker fails the build instead of shipping a region
42
+ * nobody will ever keep.
43
+ */
44
+ const decideRegion = (regionId) => {
45
+ if (regionId === "internal") return "drop";
46
+ const id = blockIdOf(regionId);
47
+ if (id === null || !isBlockId(id)) throw new Error(`unknown region id "${regionId}" — expected "internal" or "block:<blockId>"`);
48
+ return "keep-with-markers";
49
+ };
50
+ /** Per-file rewrites that turn boilerplate identity into placeholders. */
51
+ const parameterise = (file, text) => {
52
+ if (file === "index.html") return text.replace(/<title>[^<]*<\/title>/, "<title>__APP_TITLE__</title>");
53
+ if (file === "src/store.ts") return text.replaceAll(/'boilerplate\/([^']+)'/g, "'__APP_NAME__/$1'");
54
+ return text;
55
+ };
56
+ const emittedManifest = (boilerplateDir, versions) => {
57
+ const { dependencies, devDependencies } = JSON.parse(readFileSync(join(boilerplateDir, "package.json"), "utf8"));
58
+ const workspaceDeps = [];
59
+ const resolve = (group) => Object.fromEntries(Object.entries(group).filter(([name]) => !DROPPED_DEPS.has(name)).map(([name, range]) => {
60
+ if (!range.startsWith("workspace:")) return [name, range];
61
+ const version = versions.get(name);
62
+ if (version === void 0) throw new Error(`${name} is a workspace dependency with no version on disk`);
63
+ workspaceDeps.push(name);
64
+ return [name, `^${version}`];
65
+ }).sort(([a], [b]) => a.localeCompare(b)));
66
+ return {
67
+ manifest: {
68
+ name: "__APP_NAME__",
69
+ version: "0.1.0",
70
+ private: true,
71
+ type: "module",
72
+ engines: { node: ">=22.12" },
73
+ scripts: APP_SCRIPTS,
74
+ dependencies: resolve(dependencies),
75
+ devDependencies: resolve(devDependencies)
76
+ },
77
+ workspaceDeps: [...new Set(workspaceDeps)].sort()
78
+ };
79
+ };
80
+ const extractTemplate = (opts) => {
81
+ const { boilerplateDir, packagesDir, templatesDir, outDir } = opts;
82
+ const sources = [...SHELL_FILES, ...BLOCK_IDS.flatMap((id) => BLOCKS[id].files)].filter((file) => !NOT_IN_TEMPLATE.includes(file) && file !== "package.json").sort();
83
+ rmSync(outDir, {
84
+ recursive: true,
85
+ force: true
86
+ });
87
+ mkdirSync(outDir, { recursive: true });
88
+ const written = [];
89
+ for (const file of sources) {
90
+ const from = join(boilerplateDir, file);
91
+ const to = join(outDir, file);
92
+ mkdirSync(dirname(to), { recursive: true });
93
+ const buffer = readFileSync(from);
94
+ if (buffer.subarray(0, 8e3).includes(0)) cpSync(from, to);
95
+ else writeFileSync(to, parameterise(file, renderRegions(buffer.toString("utf8"), decideRegion)));
96
+ written.push(file);
97
+ }
98
+ for (const doc of TEMPLATE_DOCS) {
99
+ const text = readFileSync(join(templatesDir, doc), "utf8");
100
+ writeFileSync(join(outDir, doc), renderRegions(text, decideRegion));
101
+ written.push(doc);
102
+ }
103
+ const { manifest, workspaceDeps } = emittedManifest(boilerplateDir, workspaceVersions(packagesDir));
104
+ writeFileSync(join(outDir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
105
+ written.push("package.json");
106
+ writeFileSync(join(outDir, "workspace-deps.json"), `${JSON.stringify(workspaceDeps, null, 2)}\n`);
107
+ written.push("workspace-deps.json");
108
+ return {
109
+ files: written.sort(),
110
+ workspaceDeps
111
+ };
112
+ };
113
+ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
114
+ const main = () => {
115
+ const outDir = fileURLToPath(new URL("../dist/template/", import.meta.url));
116
+ const { files, workspaceDeps } = extractTemplate({
117
+ boilerplateDir: join(REPO_ROOT, "apps", "boilerplate"),
118
+ packagesDir: join(REPO_ROOT, "packages"),
119
+ templatesDir: fileURLToPath(new URL("../templates/", import.meta.url)),
120
+ outDir
121
+ });
122
+ const findings = scan(outDir);
123
+ if (findings.length > 0) {
124
+ console.error(`✗ ${findings.length} sanitize finding(s) in the extracted template:`);
125
+ console.error(formatFindings(findings));
126
+ console.error(" Wrap the text in an `internal` region, or replace it.");
127
+ process.exit(1);
128
+ }
129
+ console.log(`✓ extracted ${files.length} files into dist/template (${workspaceDeps.length} @keboola deps pinned to published ranges)`);
130
+ };
131
+ if (process.argv[1]?.endsWith("extract.js") === true) main();
132
+ //#endregion
133
+ export { extractTemplate };
@@ -0,0 +1,95 @@
1
+ //#region src/regions.ts
2
+ const MARKER = String.raw`#(region|endregion)\s+(\S+)`;
3
+ new RegExp(String.raw`^\s*(?://\s*|\{/\*\s*|<!--\s*)#region\s+(\S+)`);
4
+ const MARKER_LINE_RE = new RegExp(String.raw`^\s*(?://\s*|\{/\*\s*|<!--\s*)${MARKER}`);
5
+ /**
6
+ * The block name in a region id, or `null` for any other shape.
7
+ *
8
+ * - Only an exact `block:<name>` matches. `block:kpi:extra` is a typo, not the `kpi` block —
9
+ * resolving it to the block whose name it happens to start with would let a malformed
10
+ * marker decide a real region, which is how a marker leaks into an emitted app.
11
+ * - `internal` and every other id are the caller's business; this answers one question.
12
+ */
13
+ const blockIdOf = (regionId) => /^block:([^:\s]+)$/.exec(regionId)?.[1] ?? null;
14
+ const markerOf = (line) => {
15
+ const match = MARKER_LINE_RE.exec(line);
16
+ if (!match) return null;
17
+ const [, kind, id] = match;
18
+ if (kind !== "region" && kind !== "endregion") return null;
19
+ return {
20
+ kind,
21
+ id: id ?? ""
22
+ };
23
+ };
24
+ /**
25
+ * Removes the blank-line debris a dropped region leaves.
26
+ *
27
+ * - Runs of two or more blank lines collapse to one: dropping an import region between two
28
+ * groups otherwise leaves a double gap, and the emitted app's `oxfmt --check` fails on it.
29
+ * - A leading blank line is dropped for the same reason.
30
+ */
31
+ const tidy = (lines) => {
32
+ const out = [];
33
+ for (const line of lines) {
34
+ if (line.trim() === "" && (out.length === 0 || out.at(-1)?.trim() === "")) continue;
35
+ out.push(line);
36
+ }
37
+ return out;
38
+ };
39
+ const renderRegions = (source, decide) => {
40
+ const lines = source.split("\n");
41
+ const out = [];
42
+ let open = null;
43
+ for (const line of lines) {
44
+ const marker = markerOf(line);
45
+ if (marker?.kind === "region") {
46
+ if (open) throw new Error(`nested region "${marker.id}" inside "${open.id}"`);
47
+ const decision = decide(marker.id);
48
+ open = {
49
+ id: marker.id,
50
+ decision
51
+ };
52
+ if (decision === "keep-with-markers") out.push(line);
53
+ continue;
54
+ }
55
+ if (marker?.kind === "endregion") {
56
+ if (!open) throw new Error(`#endregion "${marker.id}" with no open region`);
57
+ if (open.id !== marker.id) throw new Error(`#endregion closes "${marker.id}" while "${open.id}" is open`);
58
+ if (open.decision === "keep-with-markers") out.push(line);
59
+ open = null;
60
+ continue;
61
+ }
62
+ if (open?.decision === "drop") continue;
63
+ out.push(line);
64
+ }
65
+ if (open) throw new Error(`unclosed region "${open.id}"`);
66
+ return tidy(out).join("\n");
67
+ };
68
+ /**
69
+ * Any marker, in any dialect, anywhere on a line.
70
+ *
71
+ * - Deliberately looser than `markerOf`: this is the containment check, so an *unrecognised*
72
+ * marker — misspelt dialect, indented into a continuation line, left inside a string — has to
73
+ * match too. `markerOf` would skip exactly those and let them through.
74
+ */
75
+ const MARKER_RE = /#(?:region|endregion)\b/;
76
+ /**
77
+ * Fails when rendered text still carries a marker.
78
+ *
79
+ * - The invariant: no `#region block:` may reach an emitted app. Markers are a build-time
80
+ * mechanism, and one surviving into generated source is how that mechanism fails silently —
81
+ * the app compiles, the region is dead text, and nobody finds out until a reader asks.
82
+ * - Called on every file the scaffolder writes, so a stray, mis-nested or unknown-block marker
83
+ * aborts the scaffold instead of shipping.
84
+ * - Not called on the extract's output: that keeps its markers on purpose, for the CLI to
85
+ * decide per scaffold.
86
+ */
87
+ const assertNoMarkers = (file, text) => {
88
+ const line = text.split("\n").findIndex((candidate) => MARKER_RE.test(candidate));
89
+ if (line === -1) return;
90
+ throw new Error(`${file}:${line + 1} still carries a region marker after rendering — no "#region" may reach an emitted app.`);
91
+ };
92
+ /** Replaces `__KEY__` with `vars[KEY]`. An unlisted placeholder is left in place. */
93
+ const substitute = (source, vars) => Object.entries(vars).reduce((acc, [key, value]) => acc.replaceAll(`__${key}__`, value), source);
94
+ //#endregion
95
+ export { substitute as a, renderRegions as i, assertNoMarkers as n, blockIdOf as r, MARKER_RE as t };
@@ -0,0 +1,158 @@
1
+ import { t as MARKER_RE } from "./regions-B3r5LhXl.js";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join, relative, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ //#region src/sanitize.ts
6
+ const PATTERNS_PATH = fileURLToPath(new URL("../sanitize-patterns.json", import.meta.url));
7
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
8
+ "node_modules",
9
+ ".turbo",
10
+ ".git"
11
+ ]);
12
+ /**
13
+ * The pattern table, compiled.
14
+ *
15
+ * - Read from JSON on purpose: the table is not in `files[]`, so no published copy of this
16
+ * scanner carries the patterns it looks for.
17
+ * - Absent means the caller is running from an installed package, which the gates never do.
18
+ */
19
+ const loadPatterns = () => {
20
+ let raw;
21
+ try {
22
+ raw = readFileSync(PATTERNS_PATH, "utf8");
23
+ } catch (error) {
24
+ throw new Error(`sanitize-patterns.json not found at ${PATTERNS_PATH} — the sanitize scan runs only inside the repo checkout (${String(error)})`);
25
+ }
26
+ return JSON.parse(raw).patterns.map((row) => ({
27
+ name: row.name,
28
+ regex: new RegExp(row.pattern, row.flags),
29
+ why: row.why,
30
+ hits: row.hits,
31
+ misses: row.misses
32
+ }));
33
+ };
34
+ /** Every pattern's first match on every line of `text`. */
35
+ const scanText = (patterns, file, text) => {
36
+ const findings = [];
37
+ text.split("\n").forEach((line, index) => {
38
+ for (const pattern of patterns) {
39
+ const match = pattern.regex.exec(line);
40
+ if (!match) continue;
41
+ findings.push({
42
+ file,
43
+ line: index + 1,
44
+ pattern: pattern.name,
45
+ match: match[0],
46
+ why: pattern.why
47
+ });
48
+ }
49
+ });
50
+ return findings;
51
+ };
52
+ const isBinary = (buffer) => buffer.subarray(0, 8e3).includes(0);
53
+ const walk = (root, dir, out) => {
54
+ for (const entry of readdirSync(dir)) {
55
+ if (SKIP_DIRS.has(entry)) continue;
56
+ const path = join(dir, entry);
57
+ if (statSync(path).isDirectory()) walk(root, path, out);
58
+ else out.push(relative(root, path).split(sep).join("/"));
59
+ }
60
+ };
61
+ /** Scans every text file under `root`, reporting paths relative to it. */
62
+ const scanTree = (root, patterns = loadPatterns()) => {
63
+ const files = [];
64
+ walk(root, root, files);
65
+ return files.flatMap((file) => {
66
+ const buffer = readFileSync(join(root, file));
67
+ if (isBinary(buffer)) return [];
68
+ return scanText(patterns, file, buffer.toString("utf8"));
69
+ });
70
+ };
71
+ /**
72
+ * Structural check on every `package.json` under `root`: no dependency may keep pnpm's
73
+ * `workspace:` protocol.
74
+ *
75
+ * - Parsed rather than grepped, so the rule is about specifiers and not about the string
76
+ * appearing anywhere in a file. The CLI legitimately holds that literal for `--workspace`.
77
+ */
78
+ const scanManifests = (root) => {
79
+ const files = [];
80
+ walk(root, root, files);
81
+ const groups = [
82
+ "dependencies",
83
+ "devDependencies",
84
+ "peerDependencies",
85
+ "optionalDependencies"
86
+ ];
87
+ return files.filter((file) => file === "package.json" || file.endsWith("/package.json")).flatMap((file) => {
88
+ const manifest = JSON.parse(readFileSync(join(root, file), "utf8"));
89
+ return groups.flatMap((group) => {
90
+ const deps = Reflect.get(manifest, group);
91
+ if (typeof deps !== "object" || deps === null) return [];
92
+ return Object.entries(deps).filter(([, range]) => typeof range === "string" && range.startsWith("workspace:")).map(([name, range]) => ({
93
+ file,
94
+ line: 1,
95
+ pattern: "workspace-specifier",
96
+ match: `${name}: ${String(range)}`,
97
+ why: "A workspace: specifier cannot resolve outside the monorepo — the emitted manifest must carry a published range."
98
+ }));
99
+ });
100
+ });
101
+ };
102
+ const scan = (root) => [...scanTree(root), ...scanManifests(root)];
103
+ /**
104
+ * Region markers surviving into emitted source.
105
+ *
106
+ * - Separate from `scan`, and never part of it: the tarball's own `dist/template/` carries
107
+ * markers on purpose, so a pattern-table rule for them would fail every run.
108
+ * - Run against an **emitted app** instead — the one place the invariant applies. A marker
109
+ * there is dead text that compiles, which is how the mechanism fails without telling anyone.
110
+ * - `assertNoMarkers` already aborts a scaffold in-process; this is the same rule applied to
111
+ * output on disk, so the gates can prove it about a real `pnpm pack` → scaffold run rather
112
+ * than trusting the code path.
113
+ */
114
+ const scanMarkers = (root) => {
115
+ const files = [];
116
+ walk(root, root, files);
117
+ return files.flatMap((file) => {
118
+ const buffer = readFileSync(join(root, file));
119
+ if (isBinary(buffer)) return [];
120
+ return buffer.toString("utf8").split("\n").flatMap((line, index) => {
121
+ return MARKER_RE.exec(line) ? [{
122
+ file,
123
+ line: index + 1,
124
+ pattern: "region-marker",
125
+ match: line.trim(),
126
+ why: "No \"#region\" may reach an emitted app — a surviving marker is dead text that still compiles."
127
+ }] : [];
128
+ });
129
+ });
130
+ };
131
+ const formatFindings = (findings) => findings.map((f) => ` ${f.file}:${f.line} [${f.pattern}] ${f.match}\n ${f.why}`).join("\n");
132
+ /**
133
+ * Two modes, because the two things being proved are opposites.
134
+ *
135
+ * 1. Default — internal references and credential shapes under a directory. Run on `pnpm pack`
136
+ * output, which still holds the marked-up template.
137
+ * 2. `--markers` — region markers under a directory. Run on an **emitted app**, where a marker
138
+ * must never appear.
139
+ */
140
+ const main = () => {
141
+ const args = process.argv.slice(2);
142
+ const markersOnly = args[0] === "--markers";
143
+ const root = markersOnly ? args[1] : args[0];
144
+ if (root === void 0) {
145
+ console.error("usage: node dist/sanitize.js [--markers] <directory>");
146
+ process.exit(1);
147
+ }
148
+ const findings = markersOnly ? scanMarkers(root) : scan(root);
149
+ if (findings.length > 0) {
150
+ console.error(`✗ ${findings.length} ${markersOnly ? "marker" : "sanitize"} finding(s) under ${root}:`);
151
+ console.error(formatFindings(findings));
152
+ process.exit(1);
153
+ }
154
+ console.log(markersOnly ? `✓ no region markers under ${root}` : `✓ no internal references or credential shapes under ${root}`);
155
+ };
156
+ if (process.argv[1]?.endsWith("sanitize.js") === true) main();
157
+ //#endregion
158
+ export { scanMarkers as a, scanManifests as i, loadPatterns as n, scanText as o, scan as r, scanTree as s, formatFindings as t };
@@ -0,0 +1,2 @@
1
+ import { a as scanMarkers, i as scanManifests, n as loadPatterns, o as scanText, r as scan, s as scanTree, t as formatFindings } from "./sanitize-CKs8pAzZ.js";
2
+ export { formatFindings, loadPatterns, scan, scanManifests, scanMarkers, scanText, scanTree };
@@ -0,0 +1,133 @@
1
+ # __APP_NAME__
2
+
3
+ Generated by `create-keboola-app`. This file is yours to edit — nothing regenerates it.
4
+
5
+ ## Read the installed package's contract first
6
+
7
+ Before writing UI, read the usage contract that ships with the version you actually have:
8
+
9
+ - `node_modules/@keboola/design/AGENTS.md` — the component decision tree and prop reference.
10
+ - `node_modules/@keboola/brand-registry/AGENTS.md` — brands, tokens and `BrandLogo`.
11
+
12
+ Both are pinned by your lockfile, so they match the components in your `node_modules` rather
13
+ than whatever the published docs track. Any `@keboola/*` package you add: check for its
14
+ `AGENTS.md` the same way.
15
+
16
+ ## Conventions
17
+
18
+ - TypeScript only, `type` over `interface`, named exports only, no `any`.
19
+ - Import order: side effects → React/external → `@keboola/*` → relative.
20
+ - Tailwind v4 with the `tw:` variant prefix. Colours come from semantic tokens
21
+ (`tw:bg-background`, `tw:text-muted-foreground`), never literals and never a primitive
22
+ scale — that is what lets a customer brand re-theme the app.
23
+ - Dark mode is `[data-theme='dark']` on the root, written by `ThemeProvider`. A portalled
24
+ surface must set its own `tw:text-foreground` beside its `tw:bg-*`.
25
+ - Layout uses Tailwind classes on real elements. There is no `Stack`/`Flex`/`Box` wrapper.
26
+ - Per-module locality: everything about a feature lives under its route directory. A `-`
27
+ prefix keeps a file out of the generated route tree.
28
+ - `.oxfmtrc.json` is the formatter's config, and it excludes this file and `README.md`. Both
29
+ were generated for the block selection you picked, and `oxfmt` pads Markdown table columns to
30
+ the widest cell — so their canonical form differs per selection and no generated text can
31
+ satisfy it. Everything under `src/` is format-checked normally.
32
+
33
+ ## Stack
34
+
35
+ | Concern | Tool |
36
+ | --- | --- |
37
+ | Build | Vite 8 + `@vitejs/plugin-react` |
38
+ | Routing | TanStack Router, file-based via `@tanstack/router-plugin/vite` |
39
+ | Server state | TanStack Query v5 |
40
+ | Client state | Zustand v5 with `devtools` + `persist` |
41
+ | UI | `@keboola/design` |
42
+ | Styling | Tailwind v4 + `@keboola/design/theme` tokens |
43
+ | Tests | Vitest + jsdom + `@testing-library/react` |
44
+ <!-- #region block:crud -->
45
+ | Forms | TanStack Form v1 + zod v4 |
46
+ <!-- #endregion block:crud -->
47
+ <!-- #region block:grid -->
48
+ | Data grid | TanStack Table v8 rendered through design's `Table` |
49
+ <!-- #endregion block:grid -->
50
+ <!-- #region block:chart -->
51
+ | Charts | `@keboola/design/charts` |
52
+ <!-- #endregion block:chart -->
53
+
54
+ ## File map
55
+
56
+ ```
57
+ src/
58
+ main.tsx # Mounts ThemeProvider + QueryClientProvider + RouterProvider
59
+ index.css # Tailwind v4 layer order + @keboola/design theme and styles
60
+ routeTree.gen.ts # Generated from src/routes/** by the Vite plugin
61
+ nav.ts # The rail's destinations and the active-match rule
62
+ overview-sections.tsx # The ordered registry the overview renders
63
+ store.ts # The Zustand store: storage key, partialize, merge guard
64
+ slices/shell.ts # Sidebar mode + help visibility
65
+ components/AppChrome.tsx # AppShell + Navbar + Sidebar
66
+ routes/__root.tsx # Root layout
67
+ 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
+ ```
71
+ <!-- #region block:kpi -->
72
+ - `components/KpiCard.tsx`, `sections/kpi.tsx` — the KPI row.
73
+ <!-- #endregion block:kpi -->
74
+ <!-- #region block:chart -->
75
+ - `recipes/Chart.tsx`, `sections/chart.tsx` — the line chart card.
76
+ <!-- #endregion block:chart -->
77
+ <!-- #region block:grid -->
78
+ - `recipes/DataGrid.tsx`, `sections/grid.tsx` — the editable grid.
79
+ <!-- #endregion block:grid -->
80
+ <!-- #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.
84
+ <!-- #endregion block:crud -->
85
+ <!-- #region block:assistant -->
86
+ - `components/AssistantPanel.tsx`, `AssistantToggle.tsx`, `HomePrompt.tsx`,
87
+ `SelectionFloater.tsx`, `slices/assistant.ts` — the assistant. `slices/assistant.ts`
88
+ answers with a canned reply; swap that branch for a real request.
89
+ <!-- #endregion block:assistant -->
90
+
91
+ ## Adding a route
92
+
93
+ Drop a file in `src/routes/`. The Vite plugin regenerates `routeTree.gen.ts` on the next
94
+ `pnpm dev` or `pnpm build`; nothing needs registering by hand.
95
+
96
+ ```tsx
97
+ import { createFileRoute } from '@tanstack/react-router';
98
+
99
+ export const Route = createFileRoute('/reports/')({
100
+ component: ReportsPage,
101
+ });
102
+
103
+ function ReportsPage() {
104
+ return null;
105
+ }
106
+ ```
107
+
108
+ Add it to the rail by appending to `NAV` in `src/nav.ts`, and to the overview by appending a
109
+ section to `OVERVIEW_SECTIONS` in `src/overview-sections.tsx`.
110
+
111
+ ## Fetching data
112
+
113
+ One hook per query, in the route's `-hooks.ts`, always threading the signal:
114
+
115
+ ```ts
116
+ export const useReports = () =>
117
+ useQuery({
118
+ queryKey: ['reports'],
119
+ queryFn: ({ signal }) => api.reports.list(signal),
120
+ });
121
+ ```
122
+
123
+ TanStack Query aborts that signal on unmount and on refetch. A `queryFn` that ignores it
124
+ leaks a request per navigation.
125
+
126
+ ## Secrets
127
+
128
+ `.env` is gitignored; `.env.example` is the checked-in list of what the app expects. Vite only
129
+ exposes `VITE_`-prefixed variables to the client bundle, so anything not prefixed stays out of
130
+ it — and nothing secret belongs in a client bundle at all.
131
+
132
+ `templates/agent-safety/` holds opt-in, per-runtime read-blocking stubs (Claude Code, Cursor,
133
+ Gemini CLI). Copy the one for your runtime as its README describes.
@@ -0,0 +1,56 @@
1
+ # __APP_TITLE__
2
+
3
+ A Keboola-shaped frontend app: Vite, React 19, TanStack Router/Query/Form, Zustand,
4
+ `@keboola/design` with `@keboola/brand-registry`.
5
+
6
+ ## Quick start
7
+
8
+ ```bash
9
+ pnpm install
10
+ pnpm dev
11
+ ```
12
+
13
+ Open `http://localhost:5173`.
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ | --- | --- |
19
+ | `pnpm dev` | Vite dev server. |
20
+ | `pnpm build` | Production build into `dist/`. |
21
+ | `pnpm test` | Vitest + jsdom. |
22
+ | `pnpm type-check` | `tsc`, no emit. |
23
+ | `pnpm lint` | `oxfmt --check` then `oxlint`. |
24
+ | `pnpm lint:fix` | `oxlint --fix` then `oxfmt`. |
25
+ | `pnpm gate:ui` | Scores the built app 0-100 in a headless browser. Needs `ANTHROPIC_API_KEY` and a `pnpm build` first — see below. |
26
+
27
+ `gate:ui` is deliberately not part of `pnpm test`: it needs a model key, and a first run that
28
+ fails for a missing credential is a worse welcome than an opt-in gate.
29
+
30
+ ## What is here
31
+
32
+ - **`/`** — the overview route.
33
+ <!-- #region block:kpi -->
34
+ - KPI cards, reflowing on the content column's own width.
35
+ <!-- #endregion block:kpi -->
36
+ <!-- #region block:chart -->
37
+ - A credits used-vs-budgeted line chart, coloured from `--chart-N` palette slots.
38
+ <!-- #endregion block:chart -->
39
+ <!-- #region block:grid -->
40
+ - An editable per-component budget grid.
41
+ <!-- #endregion block:grid -->
42
+ <!-- #region block:crud -->
43
+ - **`/tokens`**, **`/tokens/:id`**, **`/tokens/new`** — a list → detail → form slice with zod
44
+ validation and toast feedback.
45
+ <!-- #endregion block:crud -->
46
+ <!-- #region block:assistant -->
47
+ - A docked assistant rail on every route, a composer on the overview, and
48
+ explain-on-selection. All three share one transcript.
49
+ <!-- #endregion block:assistant -->
50
+
51
+ Every data call is an in-memory fixture that honours an `AbortSignal`. Replacing a fixture with
52
+ a real `@keboola/api-client` call is a drop-in — the query hooks already thread `signal`.
53
+
54
+ ## Next
55
+
56
+ Read [AGENTS.md](./AGENTS.md), then `node_modules/@keboola/design/AGENTS.md`.
@@ -0,0 +1,6 @@
1
+ # What this app reads from the environment. `.env` is gitignored; this file is the list.
2
+ # Vite exposes only VITE_-prefixed variables to the client bundle.
3
+
4
+ # The Keboola stack this app talks to. Left empty on purpose: a default here would pin the
5
+ # app to one region.
6
+ # VITE_KBC_STACK_URL=
@@ -0,0 +1,7 @@
1
+ node_modules
2
+ dist
3
+ *.local
4
+
5
+ .env
6
+ .env.*
7
+ !.env.example
@@ -0,0 +1,18 @@
1
+ {
2
+ "printWidth": 100,
3
+ "tabWidth": 2,
4
+ "singleQuote": true,
5
+ "arrowParens": "always",
6
+ "jsxSingleQuote": false,
7
+ "bracketSameLine": false,
8
+ "sortPackageJson": false,
9
+ "ignorePatterns": [
10
+ "dist",
11
+ "public",
12
+ "src/routeTree.gen.ts",
13
+ "pnpm-lock.yaml",
14
+ "pnpm-workspace.yaml",
15
+ "README.md",
16
+ "AGENTS.md"
17
+ ]
18
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>__APP_TITLE__</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,43 @@
1
+ import { defineConfig } from 'oxlint';
2
+
3
+ import keboolaShared from '@keboola/oxlint-config';
4
+ import keboolaTailwind from '@keboola/oxlint-config/tailwind';
5
+
6
+ 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],
12
+ env: {
13
+ node: true,
14
+ },
15
+ settings: {
16
+ 'better-tailwindcss': {
17
+ // This app's Tailwind v4 CSS entry (the file that `@import`s "tailwindcss").
18
+ entryPoint: 'src/index.css',
19
+ },
20
+ },
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.
26
+ { name: 'design', specifier: '@keboola/eslint-plugin-design' },
27
+ ],
28
+ rules: {
29
+ // Shipped at 'warn' for the first pass, matching the plugin convention —
30
+ // promote to 'error' once generated apps are consistently clean. See
31
+ // packages/eslint-plugin-design/README.md.
32
+ 'design/no-layout-primitives': 'warn',
33
+ 'design/no-inline-color': 'warn',
34
+ 'design/no-primitive-tw-color': 'warn',
35
+ 'design/no-native-form-controls': 'warn',
36
+ 'design/no-raw-heading-on-page': 'warn',
37
+ 'design/prefer-design-modal': 'warn',
38
+
39
+ // 'error' from the start: this app is the scaffold template, so a
40
+ // locale-implicit format here is copied into every generated app.
41
+ 'design/no-implicit-locale': 'error',
42
+ },
43
+ });