techbro 0.0.0-stage → 0.5.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 (122) hide show
  1. package/README.md +453 -2
  2. package/assets/baseline-rules.yml +60 -0
  3. package/assets/caller-workflow.yml +26 -0
  4. package/assets/review-action.yml +27 -0
  5. package/assets/review-config.json +4 -0
  6. package/assets/review-workflow.yml +443 -0
  7. package/assets/review.mjs +721 -0
  8. package/bin/techbro.js +9 -0
  9. package/package.json +23 -4
  10. package/src/cli.js +690 -0
  11. package/src/cluster.js +134 -0
  12. package/src/config.js +35 -0
  13. package/src/doctor.js +125 -0
  14. package/src/github.js +152 -0
  15. package/src/init.js +167 -0
  16. package/src/kube-project.js +468 -0
  17. package/src/kube.js +317 -0
  18. package/src/managed.js +63 -0
  19. package/src/project.js +202 -0
  20. package/src/review.js +165 -0
  21. package/src/secret.js +80 -0
  22. package/src/setup.js +91 -0
  23. package/src/team-workflow.js +106 -0
  24. package/src/team.js +145 -0
  25. package/src/templates.js +44 -0
  26. package/src/upgrade.js +94 -0
  27. package/templates/_default/.agents/skills/README.md +36 -0
  28. package/templates/_default/.agents/skills/database-migrations/SKILL.md +72 -0
  29. package/templates/_default/.agents/skills/frontend-feature/SKILL.md +65 -0
  30. package/templates/_default/.agents/skills/release-safe-change/SKILL.md +75 -0
  31. package/templates/_default/.agents/skills/rust-backend-feature/SKILL.md +68 -0
  32. package/templates/_default/.editorconfig +15 -0
  33. package/templates/_default/.env.example +9 -0
  34. package/templates/_default/.github/workflows/ci.yml +60 -0
  35. package/templates/_default/.nvmrc +1 -0
  36. package/templates/_default/Cargo.lock +1834 -0
  37. package/templates/_default/Cargo.toml +3 -0
  38. package/templates/_default/README.md +257 -0
  39. package/templates/_default/TEMPLATE.md +29 -0
  40. package/templates/_default/_dockerignore +8 -0
  41. package/templates/_default/_gitignore +8 -0
  42. package/templates/_default/apps/backend/Cargo.toml +22 -0
  43. package/templates/_default/apps/backend/Dockerfile +34 -0
  44. package/templates/_default/apps/backend/README.md +13 -0
  45. package/templates/_default/apps/backend/src/config.rs +111 -0
  46. package/templates/_default/apps/backend/src/controllers/health.rs +36 -0
  47. package/templates/_default/apps/backend/src/controllers/items.rs +79 -0
  48. package/templates/_default/apps/backend/src/controllers/mod.rs +2 -0
  49. package/templates/_default/apps/backend/src/error.rs +115 -0
  50. package/templates/_default/apps/backend/src/lib.rs +7 -0
  51. package/templates/_default/apps/backend/src/main.rs +71 -0
  52. package/templates/_default/apps/backend/src/models/item.rs +94 -0
  53. package/templates/_default/apps/backend/src/models/mod.rs +1 -0
  54. package/templates/_default/apps/backend/src/routes.rs +94 -0
  55. package/templates/_default/apps/backend/src/state.rs +12 -0
  56. package/templates/_default/apps/backend/src/views/items.rs +18 -0
  57. package/templates/_default/apps/backend/src/views/mod.rs +1 -0
  58. package/templates/_default/apps/backend/tests/api.rs +234 -0
  59. package/templates/_default/apps/frontend/.env.example +2 -0
  60. package/templates/_default/apps/frontend/.prettierignore +3 -0
  61. package/templates/_default/apps/frontend/.prettierrc +1 -0
  62. package/templates/_default/apps/frontend/Dockerfile +15 -0
  63. package/templates/_default/apps/frontend/README.md +39 -0
  64. package/templates/_default/apps/frontend/_dockerignore +3 -0
  65. package/templates/_default/apps/frontend/_gitignore +24 -0
  66. package/templates/_default/apps/frontend/components.json +25 -0
  67. package/templates/_default/apps/frontend/eslint.config.js +37 -0
  68. package/templates/_default/apps/frontend/index.html +13 -0
  69. package/templates/_default/apps/frontend/jsconfig.json +7 -0
  70. package/templates/_default/apps/frontend/nginx.conf +30 -0
  71. package/templates/_default/apps/frontend/package-lock.json +4338 -0
  72. package/templates/_default/apps/frontend/package.json +49 -0
  73. package/templates/_default/apps/frontend/public/favicon.svg +1 -0
  74. package/templates/_default/apps/frontend/src/App.jsx +11 -0
  75. package/templates/_default/apps/frontend/src/App.test.jsx +40 -0
  76. package/templates/_default/apps/frontend/src/api/client.js +50 -0
  77. package/templates/_default/apps/frontend/src/api/client.test.js +70 -0
  78. package/templates/_default/apps/frontend/src/api/items.js +26 -0
  79. package/templates/_default/apps/frontend/src/api/system.js +11 -0
  80. package/templates/_default/apps/frontend/src/components/ItemList.jsx +20 -0
  81. package/templates/_default/apps/frontend/src/components/ItemList.test.jsx +18 -0
  82. package/templates/_default/apps/frontend/src/components/Layout.jsx +35 -0
  83. package/templates/_default/apps/frontend/src/components/StatusBadge.jsx +22 -0
  84. package/templates/_default/apps/frontend/src/components/StepList.jsx +19 -0
  85. package/templates/_default/apps/frontend/src/components/ui/button.jsx +52 -0
  86. package/templates/_default/apps/frontend/src/components/ui/card.jsx +83 -0
  87. package/templates/_default/apps/frontend/src/components/ui/input.jsx +19 -0
  88. package/templates/_default/apps/frontend/src/hooks/useBackendStatus.js +33 -0
  89. package/templates/_default/apps/frontend/src/hooks/useItems.js +47 -0
  90. package/templates/_default/apps/frontend/src/index.css +130 -0
  91. package/templates/_default/apps/frontend/src/lib/config.js +2 -0
  92. package/templates/_default/apps/frontend/src/lib/utils.js +6 -0
  93. package/templates/_default/apps/frontend/src/main.jsx +11 -0
  94. package/templates/_default/apps/frontend/src/models/items.js +24 -0
  95. package/templates/_default/apps/frontend/src/models/items.test.js +24 -0
  96. package/templates/_default/apps/frontend/src/pages/ItemsPage.jsx +41 -0
  97. package/templates/_default/apps/frontend/src/pages/WelcomePage.jsx +156 -0
  98. package/templates/_default/apps/frontend/src/pages/WelcomePage.test.jsx +55 -0
  99. package/templates/_default/apps/frontend/src/routes.jsx +17 -0
  100. package/templates/_default/apps/frontend/src/shadcn.css +655 -0
  101. package/templates/_default/apps/frontend/src/test/setup.js +1 -0
  102. package/templates/_default/apps/frontend/vite.config.js +25 -0
  103. package/templates/_default/database/Dockerfile +9 -0
  104. package/templates/_default/database/README.md +15 -0
  105. package/templates/_default/database/migrations/postgres/20261008120000_create_items.sql +5 -0
  106. package/templates/_default/database/migrations/sqlite/20261008120000_create_items.sql +5 -0
  107. package/templates/_default/docker-compose.prod.yml +53 -0
  108. package/templates/_default/docker-compose.yml +69 -0
  109. package/templates/_default/docs/api/README.md +41 -0
  110. package/templates/_default/docs/api/openapi.yaml +152 -0
  111. package/templates/_default/docs/decisions/0001-adopt-engineering-convention.md +13 -0
  112. package/templates/_default/docs/decisions/README.md +6 -0
  113. package/templates/_default/docs/deployment.md +46 -0
  114. package/templates/_default/docs/docker.md +63 -0
  115. package/templates/_default/docs/getting-started.md +214 -0
  116. package/templates/_default/package.json +15 -0
  117. package/templates/_default/rust-toolchain.toml +3 -0
  118. package/templates/_default/scripts/check +10 -0
  119. package/templates/_default/scripts/db-migrate +25 -0
  120. package/templates/_default/scripts/install-hooks +5 -0
  121. package/templates/_default/scripts/pre-push +3 -0
  122. package/templates/_default/scripts/setup +26 -0
@@ -0,0 +1,130 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "./shadcn.css"; /* vendored from the shadcn package so the CLI is not a runtime dependency */
4
+ @import "@fontsource-variable/geist";
5
+
6
+ @custom-variant dark (&:is(.dark *));
7
+
8
+ @theme inline {
9
+ --font-heading: var(--font-sans);
10
+ --font-sans: "Geist Variable", sans-serif;
11
+ --color-sidebar-ring: var(--sidebar-ring);
12
+ --color-sidebar-border: var(--sidebar-border);
13
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14
+ --color-sidebar-accent: var(--sidebar-accent);
15
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16
+ --color-sidebar-primary: var(--sidebar-primary);
17
+ --color-sidebar-foreground: var(--sidebar-foreground);
18
+ --color-sidebar: var(--sidebar);
19
+ --color-chart-5: var(--chart-5);
20
+ --color-chart-4: var(--chart-4);
21
+ --color-chart-3: var(--chart-3);
22
+ --color-chart-2: var(--chart-2);
23
+ --color-chart-1: var(--chart-1);
24
+ --color-ring: var(--ring);
25
+ --color-input: var(--input);
26
+ --color-border: var(--border);
27
+ --color-destructive: var(--destructive);
28
+ --color-accent-foreground: var(--accent-foreground);
29
+ --color-accent: var(--accent);
30
+ --color-muted-foreground: var(--muted-foreground);
31
+ --color-muted: var(--muted);
32
+ --color-secondary-foreground: var(--secondary-foreground);
33
+ --color-secondary: var(--secondary);
34
+ --color-primary-foreground: var(--primary-foreground);
35
+ --color-primary: var(--primary);
36
+ --color-popover-foreground: var(--popover-foreground);
37
+ --color-popover: var(--popover);
38
+ --color-card-foreground: var(--card-foreground);
39
+ --color-card: var(--card);
40
+ --color-foreground: var(--foreground);
41
+ --color-background: var(--background);
42
+ --radius-sm: calc(var(--radius) * 0.6);
43
+ --radius-md: calc(var(--radius) * 0.8);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) * 1.4);
46
+ --radius-2xl: calc(var(--radius) * 1.8);
47
+ --radius-3xl: calc(var(--radius) * 2.2);
48
+ --radius-4xl: calc(var(--radius) * 2.6);
49
+ }
50
+
51
+ :root {
52
+ --background: oklch(1 0 0);
53
+ --foreground: oklch(0.145 0 0);
54
+ --card: oklch(1 0 0);
55
+ --card-foreground: oklch(0.145 0 0);
56
+ --popover: oklch(1 0 0);
57
+ --popover-foreground: oklch(0.145 0 0);
58
+ --primary: oklch(0.205 0 0);
59
+ --primary-foreground: oklch(0.985 0 0);
60
+ --secondary: oklch(0.97 0 0);
61
+ --secondary-foreground: oklch(0.205 0 0);
62
+ --muted: oklch(0.97 0 0);
63
+ --muted-foreground: oklch(0.556 0 0);
64
+ --accent: oklch(0.97 0 0);
65
+ --accent-foreground: oklch(0.205 0 0);
66
+ --destructive: oklch(0.577 0.245 27.325);
67
+ --border: oklch(0.922 0 0);
68
+ --input: oklch(0.922 0 0);
69
+ --ring: oklch(0.708 0 0);
70
+ --chart-1: oklch(0.87 0 0);
71
+ --chart-2: oklch(0.556 0 0);
72
+ --chart-3: oklch(0.439 0 0);
73
+ --chart-4: oklch(0.371 0 0);
74
+ --chart-5: oklch(0.269 0 0);
75
+ --radius: 0.625rem;
76
+ --sidebar: oklch(0.985 0 0);
77
+ --sidebar-foreground: oklch(0.145 0 0);
78
+ --sidebar-primary: oklch(0.205 0 0);
79
+ --sidebar-primary-foreground: oklch(0.985 0 0);
80
+ --sidebar-accent: oklch(0.97 0 0);
81
+ --sidebar-accent-foreground: oklch(0.205 0 0);
82
+ --sidebar-border: oklch(0.922 0 0);
83
+ --sidebar-ring: oklch(0.708 0 0);
84
+ }
85
+
86
+ .dark {
87
+ --background: oklch(0.145 0 0);
88
+ --foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.205 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
91
+ --popover: oklch(0.205 0 0);
92
+ --popover-foreground: oklch(0.985 0 0);
93
+ --primary: oklch(0.922 0 0);
94
+ --primary-foreground: oklch(0.205 0 0);
95
+ --secondary: oklch(0.269 0 0);
96
+ --secondary-foreground: oklch(0.985 0 0);
97
+ --muted: oklch(0.269 0 0);
98
+ --muted-foreground: oklch(0.708 0 0);
99
+ --accent: oklch(0.269 0 0);
100
+ --accent-foreground: oklch(0.985 0 0);
101
+ --destructive: oklch(0.704 0.191 22.216);
102
+ --border: oklch(1 0 0 / 10%);
103
+ --input: oklch(1 0 0 / 15%);
104
+ --ring: oklch(0.556 0 0);
105
+ --chart-1: oklch(0.87 0 0);
106
+ --chart-2: oklch(0.556 0 0);
107
+ --chart-3: oklch(0.439 0 0);
108
+ --chart-4: oklch(0.371 0 0);
109
+ --chart-5: oklch(0.269 0 0);
110
+ --sidebar: oklch(0.205 0 0);
111
+ --sidebar-foreground: oklch(0.985 0 0);
112
+ --sidebar-primary: oklch(0.488 0.243 264.376);
113
+ --sidebar-primary-foreground: oklch(0.985 0 0);
114
+ --sidebar-accent: oklch(0.269 0 0);
115
+ --sidebar-accent-foreground: oklch(0.985 0 0);
116
+ --sidebar-border: oklch(1 0 0 / 10%);
117
+ --sidebar-ring: oklch(0.556 0 0);
118
+ }
119
+
120
+ @layer base {
121
+ * {
122
+ @apply border-border outline-ring/50;
123
+ }
124
+ body {
125
+ @apply bg-background text-foreground;
126
+ }
127
+ html {
128
+ @apply font-sans;
129
+ }
130
+ }
@@ -0,0 +1,2 @@
1
+ /** Display name; set VITE_APP_NAME in .env to rename the project in the UI. */
2
+ export const appName = import.meta.env.VITE_APP_NAME || "Project Template";
@@ -0,0 +1,6 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,11 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { RouterProvider } from "react-router";
4
+ import "./index.css";
5
+ import { router } from "./routes.jsx";
6
+
7
+ createRoot(document.getElementById("root")).render(
8
+ <StrictMode>
9
+ <RouterProvider router={router} />
10
+ </StrictMode>,
11
+ );
@@ -0,0 +1,24 @@
1
+ export const MAX_ITEM_NAME_LENGTH = 200;
2
+
3
+ /**
4
+ * Client-side validation for usability; the backend remains authoritative.
5
+ * @param {string} name
6
+ * @returns {{ok: boolean, message: string}}
7
+ */
8
+ export function validateItemName(name) {
9
+ const trimmed = (name ?? "").trim();
10
+ if (!trimmed) return { ok: false, message: "Name is required." };
11
+ if (trimmed.length > MAX_ITEM_NAME_LENGTH) {
12
+ return { ok: false, message: `Name must be ${MAX_ITEM_NAME_LENGTH} characters or fewer.` };
13
+ }
14
+ return { ok: true, message: "" };
15
+ }
16
+
17
+ /**
18
+ * @param {string} iso UTC ISO 8601 timestamp
19
+ * @returns {string} Localized date/time, or the input if unparseable
20
+ */
21
+ export function formatCreatedAt(iso) {
22
+ const date = new Date(iso);
23
+ return Number.isNaN(date.getTime()) ? String(iso) : date.toLocaleString();
24
+ }
@@ -0,0 +1,24 @@
1
+ import { formatCreatedAt, validateItemName } from "./items";
2
+
3
+ describe("validateItemName", () => {
4
+ it("rejects empty and whitespace-only names", () => {
5
+ expect(validateItemName("").ok).toBe(false);
6
+ expect(validateItemName(" ").ok).toBe(false);
7
+ });
8
+ it("accepts a normal name and exactly 200 chars", () => {
9
+ expect(validateItemName("Widget").ok).toBe(true);
10
+ expect(validateItemName("a".repeat(200)).ok).toBe(true);
11
+ });
12
+ it("rejects names over 200 chars", () => {
13
+ expect(validateItemName("a".repeat(201)).ok).toBe(false);
14
+ });
15
+ });
16
+
17
+ describe("formatCreatedAt", () => {
18
+ it("returns the input for invalid dates", () => {
19
+ expect(formatCreatedAt("nope")).toBe("nope");
20
+ });
21
+ it("formats valid dates", () => {
22
+ expect(formatCreatedAt("2026-10-08T12:00:00Z")).not.toBe("2026-10-08T12:00:00Z");
23
+ });
24
+ });
@@ -0,0 +1,41 @@
1
+ import { useState } from "react";
2
+ import ItemList from "@/components/ItemList";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Input } from "@/components/ui/input";
6
+ import { useItems } from "@/hooks/useItems";
7
+
8
+ export default function ItemsPage() {
9
+ const { items, loading, error, create } = useItems();
10
+ const [name, setName] = useState("");
11
+
12
+ async function handleSubmit(event) {
13
+ event.preventDefault();
14
+ if (await create(name)) setName("");
15
+ }
16
+
17
+ return (
18
+ <Card>
19
+ <CardHeader>
20
+ <CardTitle>Items</CardTitle>
21
+ </CardHeader>
22
+ <CardContent className="space-y-4">
23
+ <form onSubmit={handleSubmit} className="flex gap-2">
24
+ <Input
25
+ value={name}
26
+ onChange={(event) => setName(event.target.value)}
27
+ placeholder="New item name"
28
+ aria-label="Item name"
29
+ />
30
+ <Button type="submit">Add</Button>
31
+ </form>
32
+ {error && (
33
+ <p role="alert" className="text-sm text-destructive">
34
+ {error}
35
+ </p>
36
+ )}
37
+ {loading ? <p className="text-sm">Loading...</p> : <ItemList items={items} />}
38
+ </CardContent>
39
+ </Card>
40
+ );
41
+ }
@@ -0,0 +1,156 @@
1
+ import { Link } from "react-router";
2
+ import StatusBadge from "@/components/StatusBadge";
3
+ import StepList from "@/components/StepList";
4
+ import { buttonVariants } from "@/components/ui/button";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { useBackendStatus } from "@/hooks/useBackendStatus";
7
+ import { appName } from "@/lib/config";
8
+
9
+ const STEPS = [
10
+ {
11
+ label: "Create the table",
12
+ path: "database/migrations/{sqlite,postgres}/20261008120000_create_items.sql",
13
+ description: "One migration per database engine defines the items table.",
14
+ },
15
+ {
16
+ label: "Model the data",
17
+ path: "apps/backend/src/models/item.rs",
18
+ description: "The Item struct and the SQL queries that read and write it.",
19
+ },
20
+ {
21
+ label: "Handle requests",
22
+ path: "apps/backend/src/controllers/items.rs + apps/backend/src/routes.rs",
23
+ description: "Controllers validate input and call the model; routes.rs maps URLs to them.",
24
+ },
25
+ {
26
+ label: "Call the API",
27
+ path: "apps/frontend/src/api/items.js",
28
+ description: "Small functions that wrap fetch for the items endpoints.",
29
+ },
30
+ {
31
+ label: "Coordinate state",
32
+ path: "apps/frontend/src/hooks/useItems.js",
33
+ description: "A hook that loads items, creates them, and tracks loading and errors.",
34
+ },
35
+ {
36
+ label: "Show it",
37
+ path: "apps/frontend/src/pages/ItemsPage.jsx + src/components/ItemList.jsx",
38
+ description: "The page uses the hook; ItemList only renders what it is given.",
39
+ },
40
+ ];
41
+
42
+ const STACK = [
43
+ "React + Vite",
44
+ "Tailwind + shadcn/ui",
45
+ "Rust Axum + SQLx",
46
+ "SQLite / PostgreSQL",
47
+ "Vitest",
48
+ "ESLint / Prettier",
49
+ ];
50
+
51
+ const NEXT_STEPS = [
52
+ ["README.md", "the engineering convention this project follows"],
53
+ ["docs/getting-started.md", "setup and first run"],
54
+ ["scripts/check", "runs the full set of checks before you open a PR"],
55
+ ["docs/api/README.md", "the REST API reference"],
56
+ ];
57
+
58
+ function StatusRow({ label, result }) {
59
+ return (
60
+ <div className="space-y-1">
61
+ <div className="flex items-center justify-between text-sm">
62
+ <code>{label}</code>
63
+ <StatusBadge state={result.state} />
64
+ </div>
65
+ {result.state !== "pending" && (
66
+ <pre className="overflow-x-auto rounded-md bg-muted p-2 text-xs">
67
+ {result.state === "ok" ? JSON.stringify(result.body, null, 2) : result.message}
68
+ </pre>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export default function WelcomePage() {
75
+ const { health, ready, unreachable } = useBackendStatus();
76
+
77
+ return (
78
+ <div className="space-y-6">
79
+ <header className="space-y-1">
80
+ <h1 className="text-2xl font-semibold">{appName}</h1>
81
+ <p className="text-muted-foreground">
82
+ A full-stack starter with a working example feature to copy from.
83
+ </p>
84
+ </header>
85
+
86
+ <Card>
87
+ <CardHeader>
88
+ <CardTitle>Backend status</CardTitle>
89
+ </CardHeader>
90
+ <CardContent className="space-y-4">
91
+ <StatusRow label="GET /health" result={health} />
92
+ <StatusRow label="GET /ready" result={ready} />
93
+ {unreachable && (
94
+ <p className="text-sm text-muted-foreground">
95
+ Start the backend with <code>cargo run -p backend</code> in another terminal.
96
+ </p>
97
+ )}
98
+ </CardContent>
99
+ </Card>
100
+
101
+ <Card>
102
+ <CardHeader>
103
+ <CardTitle>Follow the sample feature</CardTitle>
104
+ <CardDescription>
105
+ Items are traced from the database to the screen. Read the files in this order.
106
+ </CardDescription>
107
+ </CardHeader>
108
+ <CardContent>
109
+ <StepList steps={STEPS} />
110
+ </CardContent>
111
+ </Card>
112
+
113
+ <Card>
114
+ <CardHeader>
115
+ <CardTitle>Stack</CardTitle>
116
+ </CardHeader>
117
+ <CardContent>
118
+ <ul className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-3">
119
+ {STACK.map((name) => (
120
+ <li key={name} className="rounded-md border px-3 py-2">
121
+ {name}
122
+ </li>
123
+ ))}
124
+ </ul>
125
+ </CardContent>
126
+ </Card>
127
+
128
+ <Card>
129
+ <CardHeader>
130
+ <CardTitle>Try it</CardTitle>
131
+ <CardDescription>Add an item and see it saved through the API.</CardDescription>
132
+ </CardHeader>
133
+ <CardContent>
134
+ <Link to="/items" className={buttonVariants()}>
135
+ Open the Items page
136
+ </Link>
137
+ </CardContent>
138
+ </Card>
139
+
140
+ <Card>
141
+ <CardHeader>
142
+ <CardTitle>Next steps</CardTitle>
143
+ </CardHeader>
144
+ <CardContent>
145
+ <ul className="space-y-2 text-sm">
146
+ {NEXT_STEPS.map(([file, text]) => (
147
+ <li key={file}>
148
+ <code>{file}</code> <span className="text-muted-foreground">: {text}</span>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </CardContent>
153
+ </Card>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,55 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { MemoryRouter } from "react-router";
3
+ import WelcomePage from "./WelcomePage";
4
+
5
+ function json(body, status = 200) {
6
+ return new Response(JSON.stringify(body), { status });
7
+ }
8
+
9
+ afterEach(() => vi.unstubAllGlobals());
10
+
11
+ describe("WelcomePage", () => {
12
+ it("renders the sample feature steps", () => {
13
+ vi.stubGlobal(
14
+ "fetch",
15
+ vi.fn(() => new Promise(() => {})),
16
+ );
17
+ render(
18
+ <MemoryRouter>
19
+ <WelcomePage />
20
+ </MemoryRouter>,
21
+ );
22
+ expect(screen.getByText("Follow the sample feature")).toBeInTheDocument();
23
+ expect(screen.getByText("apps/backend/src/models/item.rs")).toBeInTheDocument();
24
+ expect(screen.getAllByRole("listitem").length).toBeGreaterThanOrEqual(6);
25
+ });
26
+
27
+ it("shows ok status with the raw JSON", async () => {
28
+ vi.stubGlobal(
29
+ "fetch",
30
+ vi.fn((url) => Promise.resolve(json(url === "/health" ? { status: "ok" } : { db: "up" }))),
31
+ );
32
+ render(
33
+ <MemoryRouter>
34
+ <WelcomePage />
35
+ </MemoryRouter>,
36
+ );
37
+ expect(await screen.findAllByText("OK")).toHaveLength(2);
38
+ expect(screen.getByText(/"status": "ok"/)).toBeInTheDocument();
39
+ expect(screen.queryByText(/cargo run -p backend/)).not.toBeInTheDocument();
40
+ });
41
+
42
+ it("shows a hint when the backend is unreachable", async () => {
43
+ vi.stubGlobal(
44
+ "fetch",
45
+ vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))),
46
+ );
47
+ render(
48
+ <MemoryRouter>
49
+ <WelcomePage />
50
+ </MemoryRouter>,
51
+ );
52
+ expect(await screen.findAllByText("Failed")).toHaveLength(2);
53
+ expect(screen.getByText("cargo run -p backend")).toBeInTheDocument();
54
+ });
55
+ });
@@ -0,0 +1,17 @@
1
+ import { createBrowserRouter } from "react-router";
2
+ import App from "@/App";
3
+ import ItemsPage from "@/pages/ItemsPage";
4
+ import WelcomePage from "@/pages/WelcomePage";
5
+
6
+ export const routes = [
7
+ {
8
+ path: "/",
9
+ element: <App />,
10
+ children: [
11
+ { index: true, element: <WelcomePage /> },
12
+ { path: "items", element: <ItemsPage /> },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ export const router = createBrowserRouter(routes);