create-zerotal 1.0.0 → 1.0.2

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 (126) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/src/prompts.ts +6 -6
  4. package/src/scaffold.ts +9 -3
  5. package/templates/admin/README.md +41 -0
  6. package/templates/admin/_env.example +17 -0
  7. package/templates/admin/app/admin/index.ts +6 -0
  8. package/templates/admin/app/auth/passwords.ts +79 -0
  9. package/templates/admin/config/database.ts +4 -3
  10. package/templates/admin/database/migrations/0001_create_password_reset_tokens_table.ts +26 -0
  11. package/templates/admin/package.json +9 -1
  12. package/templates/admin/tsconfig.json +7 -2
  13. package/templates/api/README.md +39 -0
  14. package/templates/api/_env.example +13 -0
  15. package/templates/api/app/controllers/AuthController.ts +11 -8
  16. package/templates/api/app/controllers/WelcomeController.ts +1 -1
  17. package/templates/api/app/middleware/RequireAuth.ts +1 -1
  18. package/templates/api/app/models/User.ts +2 -2
  19. package/templates/api/bootstrap/app.ts +6 -6
  20. package/templates/api/config/app.ts +1 -1
  21. package/templates/api/config/database.ts +12 -3
  22. package/templates/api/config/notifications.ts +1 -1
  23. package/templates/api/config/queue.ts +1 -1
  24. package/templates/api/config/session.ts +2 -2
  25. package/templates/api/database/migrations/0001_create_users_table.ts +8 -8
  26. package/templates/api/package.json +9 -1
  27. package/templates/api/routes/index.ts +23 -8
  28. package/templates/api/tests/auth.test.ts +6 -3
  29. package/templates/api/tests/helpers.ts +6 -6
  30. package/templates/api/tsconfig.json +7 -3
  31. package/templates/flow/README.md +39 -0
  32. package/templates/flow/_env.example +17 -0
  33. package/templates/flow/app/auth/passwords.ts +79 -0
  34. package/templates/flow/app/flow/layouts/app.tsx +10 -1
  35. package/templates/flow/app/flow/pages/forgot-password.tsx +69 -0
  36. package/templates/flow/app/flow/pages/login.tsx +88 -0
  37. package/templates/flow/app/flow/pages/profile.tsx +194 -0
  38. package/templates/flow/app/flow/pages/register.tsx +120 -0
  39. package/templates/flow/app/flow/pages/reset-password.tsx +103 -0
  40. package/templates/flow/app/flow/ui.ts +27 -0
  41. package/templates/flow/app/models/User.ts +17 -0
  42. package/templates/flow/bootstrap/app.ts +8 -0
  43. package/templates/flow/bootstrap/providers.ts +6 -1
  44. package/templates/flow/config/auth.ts +7 -0
  45. package/templates/flow/config/database.ts +18 -0
  46. package/templates/flow/config/session.ts +14 -0
  47. package/templates/flow/database/migrations/0001_create_users_table.ts +18 -0
  48. package/templates/flow/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  49. package/templates/flow/package.json +10 -4
  50. package/templates/flow/tests/smoke.test.ts +21 -1
  51. package/templates/flow/tsconfig.json +10 -3
  52. package/templates/minimal/README.md +39 -0
  53. package/templates/minimal/_env.example +9 -0
  54. package/templates/minimal/bootstrap/app.ts +2 -2
  55. package/templates/minimal/package.json +10 -5
  56. package/templates/minimal/tests/smoke.test.ts +1 -1
  57. package/templates/minimal/tsconfig.json +13 -4
  58. package/templates/react/README.md +40 -0
  59. package/templates/react/_env.example +18 -0
  60. package/templates/react/app/auth/passwords.ts +79 -0
  61. package/templates/react/app/models/User.ts +17 -0
  62. package/templates/react/app/routes/forgot-password.ts +24 -0
  63. package/templates/react/app/routes/login.ts +32 -0
  64. package/templates/react/app/routes/logout.ts +12 -0
  65. package/templates/react/app/routes/profile/password.ts +30 -0
  66. package/templates/react/app/routes/profile.ts +39 -0
  67. package/templates/react/app/routes/register.ts +39 -0
  68. package/templates/react/app/routes/reset-password.ts +37 -0
  69. package/templates/react/bootstrap/app.ts +8 -0
  70. package/templates/react/bootstrap/providers.ts +7 -4
  71. package/templates/react/config/auth.ts +7 -0
  72. package/templates/react/config/database.ts +18 -0
  73. package/templates/react/database/migrations/0001_create_users_table.ts +18 -0
  74. package/templates/react/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  75. package/templates/react/package.json +8 -2
  76. package/templates/react/resources/js/Layouts/AppLayout.tsx +12 -2
  77. package/templates/react/resources/js/pages/forgot-password.tsx +65 -0
  78. package/templates/react/resources/js/pages/login.tsx +92 -0
  79. package/templates/react/resources/js/pages/profile.tsx +147 -0
  80. package/templates/react/resources/js/pages/register.tsx +101 -0
  81. package/templates/react/resources/js/pages/reset-password.tsx +80 -0
  82. package/templates/react/resources/js/pages.generated.ts +5 -0
  83. package/templates/react/tests/smoke.test.ts +58 -2
  84. package/templates/react/tsconfig.json +10 -3
  85. package/templates/vue/README.md +40 -0
  86. package/templates/vue/_env.example +18 -0
  87. package/templates/vue/app/auth/passwords.ts +79 -0
  88. package/templates/vue/app/exceptions/Handler.ts +55 -0
  89. package/templates/vue/app/models/User.ts +17 -0
  90. package/templates/vue/app/routes/forgot-password.ts +24 -0
  91. package/templates/vue/app/routes/login.ts +32 -0
  92. package/templates/vue/app/routes/logout.ts +12 -0
  93. package/templates/vue/app/routes/profile/password.ts +30 -0
  94. package/templates/vue/app/routes/profile.ts +39 -0
  95. package/templates/vue/app/routes/register.ts +39 -0
  96. package/templates/vue/app/routes/reset-password.ts +37 -0
  97. package/templates/vue/bootstrap/app.ts +15 -3
  98. package/templates/vue/bootstrap/providers.ts +8 -1
  99. package/templates/vue/config/auth.ts +7 -0
  100. package/templates/vue/config/database.ts +18 -0
  101. package/templates/vue/config/session.ts +11 -0
  102. package/templates/vue/database/migrations/0001_create_users_table.ts +18 -0
  103. package/templates/vue/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  104. package/templates/vue/package.json +8 -2
  105. package/templates/vue/public/zt.svg +17 -0
  106. package/templates/vue/resources/app.html +27 -1
  107. package/templates/vue/resources/css/app.css +205 -0
  108. package/templates/vue/resources/js/Components/Button.vue +49 -0
  109. package/templates/vue/resources/js/Components/Card.vue +23 -0
  110. package/templates/vue/resources/js/Components/FlashToasts.vue +81 -0
  111. package/templates/vue/resources/js/Components/Icon.vue +40 -0
  112. package/templates/vue/resources/js/Components/TextField.vue +66 -0
  113. package/templates/vue/resources/js/Components/ThemeToggle.vue +46 -0
  114. package/templates/vue/resources/js/Layouts/AppLayout.vue +140 -25
  115. package/templates/vue/resources/js/lib/cn.ts +13 -0
  116. package/templates/vue/resources/js/lib/site.ts +11 -0
  117. package/templates/vue/resources/js/pages/error.vue +47 -0
  118. package/templates/vue/resources/js/pages/forgot-password.vue +47 -0
  119. package/templates/vue/resources/js/pages/login.vue +67 -0
  120. package/templates/vue/resources/js/pages/profile.vue +119 -0
  121. package/templates/vue/resources/js/pages/register.vue +77 -0
  122. package/templates/vue/resources/js/pages/reset-password.vue +58 -0
  123. package/templates/vue/resources/js/pages.generated.ts +10 -4
  124. package/templates/vue/resources/js/types.ts +17 -0
  125. package/templates/vue/tests/smoke.test.ts +50 -1
  126. package/templates/vue/tsconfig.json +10 -3
@@ -0,0 +1,39 @@
1
+ import type { HttpContext } from "zerotal";
2
+ import { Inertia } from "@zerotal/inertia";
3
+ import { Auth, GuestMiddleware, Hash } from "zerotal/auth";
4
+ import { validate } from "zerotal/validator";
5
+ import { User } from "@app/models/User";
6
+
7
+ export const middleware = [GuestMiddleware];
8
+
9
+ export const GET = async () => {
10
+ return Inertia.render("register", { title: "Create an account" });
11
+ };
12
+
13
+ export async function POST(http: HttpContext): Promise<void> {
14
+ const { name, email, password } = await validate(http, (r) => ({
15
+ name: r.string().trim().min(2).max(80),
16
+ email: r.string().trim().email(),
17
+ password: r.string().min(8).confirmed(),
18
+ }));
19
+
20
+ const taken = await User.query().where("email", email).first();
21
+ if (taken) {
22
+ http.flash("error", "That email is already registered.");
23
+ http.redirect("/register", 303);
24
+ return;
25
+ }
26
+
27
+ // `role` is not fillable, so it cannot arrive from the request at all — it is
28
+ // set here, in code, which is the whole point of leaving it off `fillable`.
29
+ const user = new User();
30
+ user.name = name;
31
+ user.email = email;
32
+ user.password = await Hash.make(password);
33
+ user.role = "user";
34
+ await user.save();
35
+
36
+ await Auth.attempt({ email, password });
37
+ http.flash("success", "Welcome aboard.");
38
+ http.redirect("/profile", 303);
39
+ }
@@ -0,0 +1,37 @@
1
+ import type { HttpContext } from "zerotal";
2
+ import { Inertia } from "@zerotal/inertia";
3
+ import { GuestMiddleware } from "zerotal/auth";
4
+ import { validate } from "zerotal/validator";
5
+ import { passwordReset } from "@app/auth/passwords";
6
+
7
+ export const middleware = [GuestMiddleware];
8
+
9
+ // The token and address arrive on the query string of the emailed link and are
10
+ // handed to the page, which posts them back with the new password.
11
+ export const GET = async (http: HttpContext) => {
12
+ const url = new URL(http.request.url);
13
+ return Inertia.render("reset-password", {
14
+ title: "Choose a new password",
15
+ token: url.searchParams.get("token") ?? "",
16
+ email: url.searchParams.get("email") ?? "",
17
+ });
18
+ };
19
+
20
+ export async function POST(http: HttpContext): Promise<void> {
21
+ const { email, token, password } = await validate(http, (r) => ({
22
+ email: r.string().trim().email(),
23
+ token: r.string().min(1),
24
+ password: r.string().min(8).confirmed(),
25
+ }));
26
+
27
+ const ok = await passwordReset.reset({ email, token, password });
28
+
29
+ if (!ok) {
30
+ http.flash("error", "This reset link is invalid or has expired.");
31
+ http.redirect(`/reset-password?token=${token}&email=${encodeURIComponent(email)}`, 303);
32
+ return;
33
+ }
34
+
35
+ http.flash("success", "Your password has been updated. Sign in with it below.");
36
+ http.redirect("/login", 303);
37
+ }
@@ -1,8 +1,20 @@
1
1
  import { Application, basePath } from "zerotal";
2
2
  import providers from "./providers";
3
+ import { Handler } from "../app/exceptions/Handler";
4
+ import type { User } from "../app/models/User.ts";
3
5
 
4
- const app = Application.create({ providers }).fileBasedRouting({
5
- web: basePath("app/routes"),
6
- });
6
+ // Tell @zerotal/auth which model `Auth.user()` returns. The empty body is the
7
+ // point: this augmentation exists to bind the type, not to add members.
8
+ declare module "zerotal/auth" {
9
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
10
+ interface UserModel extends User {}
11
+ }
12
+
13
+ const app = Application.create({ providers })
14
+ .fileBasedRouting({
15
+ web: basePath("app/routes"),
16
+ })
17
+ // Renders 404s and other HTTP errors through resources/js/pages/error.vue.
18
+ .withExceptionHandler(Handler);
7
19
 
8
20
  export default app;
@@ -1,6 +1,13 @@
1
1
  import { LogProvider } from "zerotal/logger";
2
+ import { DatabaseProvider } from "zerotal/orm";
3
+ import { SessionProvider } from "zerotal/session";
4
+ import { AuthProvider } from "zerotal/auth";
2
5
  import { InertiaProvider } from "@zerotal/inertia";
3
6
 
4
- const providers = [LogProvider, InertiaProvider];
7
+ // Order matters: the database backs the user model and the session underpins
8
+ // auth, so both are registered before AuthProvider boots. AuthProvider installs
9
+ // the middleware that puts the signed-in user on the request — which is what
10
+ // makes `auth.user` appear in every Inertia page's props.
11
+ const providers = [LogProvider, DatabaseProvider, SessionProvider, AuthProvider, InertiaProvider];
5
12
 
6
13
  export default providers;
@@ -0,0 +1,7 @@
1
+ import { AuthConfig } from "zerotal/auth";
2
+
3
+ export default AuthConfig({
4
+ // argon2id is the default and what `Hash.make` uses unless told otherwise.
5
+ // Switch to 'bcrypt' only to stay compatible with an existing password table.
6
+ algorithm: "argon2id",
7
+ });
@@ -0,0 +1,18 @@
1
+ import { env } from "zerotal";
2
+ import { DatabaseConfig } from "zerotal/orm";
3
+
4
+ export default DatabaseConfig({
5
+ // The driver has to agree with the URL's protocol — boot validation rejects a
6
+ // sqlite driver pointed at postgres:// or mysql://. Change both together.
7
+ driver: "sqlite",
8
+
9
+ // ZT_DB_URL first: `bun zt test` sets it (defaults to :memory:) and that
10
+ // override has to beat the DATABASE_URL in .env, or the suite would run
11
+ // against your development database and leave its rows behind.
12
+ url: env("ZT_DB_URL", env("DATABASE_URL", "./database/db.sqlite")),
13
+
14
+ // Builds the schema from the models at boot, so `bun run dev` works with no
15
+ // migration step. Hard-off in production, where you run the migrations in
16
+ // database/migrations instead.
17
+ synchronize: env("APP_ENV", "development") !== "production",
18
+ });
@@ -0,0 +1,11 @@
1
+ import { env } from "zerotal";
2
+ import { SessionConfig } from "zerotal/session";
3
+
4
+ // Cookie-driver sessions, signed with APP_KEY. This is what carries flash
5
+ // messages and a failed validation's errors and old input from one request to
6
+ // the next. Switch `driver` to "redis" for a shared, server-side store.
7
+ export default SessionConfig({
8
+ secret: env("APP_KEY", ""),
9
+ cookie: "{{name}}_session",
10
+ lifetime: 60 * 60 * 24 * 7, // 7 days
11
+ });
@@ -0,0 +1,18 @@
1
+ import { Migration, Schema } from "zerotal/orm";
2
+
3
+ export default class CreateUsersTable extends Migration {
4
+ async up(): Promise<void> {
5
+ await Schema.create("users", (table) => {
6
+ table.increments("id");
7
+ table.string("name");
8
+ table.string("email").unique();
9
+ table.string("password");
10
+ table.string("role").nullable().default("user");
11
+ table.timestamps();
12
+ });
13
+ }
14
+
15
+ async down(): Promise<void> {
16
+ await Schema.drop("users");
17
+ }
18
+ }
@@ -0,0 +1,23 @@
1
+ import { Migration, Schema } from "zerotal/orm";
2
+
3
+ /**
4
+ * One live reset token per address, keyed by email rather than user id so a
5
+ * request for an unknown address does the same work as a known one — the
6
+ * response must not reveal which addresses have accounts.
7
+ *
8
+ * `token` holds a SHA-256 hash, never the value that was sent: a leaked
9
+ * database should not hand out working reset links.
10
+ */
11
+ export default class CreatePasswordResetTokensTable extends Migration {
12
+ async up(): Promise<void> {
13
+ await Schema.create("password_reset_tokens", (table) => {
14
+ table.string("email").primary();
15
+ table.string("token");
16
+ table.timestamp("created_at");
17
+ });
18
+ }
19
+
20
+ async down(): Promise<void> {
21
+ await Schema.drop("password_reset_tokens");
22
+ }
23
+ }
@@ -8,7 +8,8 @@
8
8
  "dev": "bun zt.ts serve --dev",
9
9
  "start": "bun zt.ts serve",
10
10
  "build": "bun zt.ts inertia:build --production",
11
- "test": "bun zt.ts test"
11
+ "test": "bun zt.ts test",
12
+ "typecheck": "tsc --noEmit"
12
13
  },
13
14
  "dependencies": {
14
15
  "@inertiajs/vue3": "^2.0.0",
@@ -19,6 +20,11 @@
19
20
  "devDependencies": {
20
21
  "@vue/compiler-sfc": "^3.5.0",
21
22
  "bun-plugin-tailwind": "latest",
22
- "tailwindcss": "^4.0.0"
23
+ "tailwindcss": "^4.0.0",
24
+ "typescript": "^5.8.0",
25
+ "@types/bun": "^1.3.14"
26
+ },
27
+ "engines": {
28
+ "bun": ">=1.3.14"
23
29
  }
24
30
  }
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Zerotal">
2
+ <defs>
3
+ <linearGradient id="zt-grad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#818cf8" />
5
+ <stop offset="0.5" stop-color="#6366f1" />
6
+ <stop offset="1" stop-color="#7c3aed" />
7
+ </linearGradient>
8
+ <linearGradient id="zt-shine" x1="0" y1="0" x2="0" y2="64" gradientUnits="userSpaceOnUse">
9
+ <stop stop-color="#ffffff" stop-opacity="0.35" />
10
+ <stop offset="0.55" stop-color="#ffffff" stop-opacity="0" />
11
+ </linearGradient>
12
+ </defs>
13
+ <rect width="64" height="64" rx="18" fill="url(#zt-grad)" />
14
+ <rect width="64" height="64" rx="18" fill="url(#zt-shine)" />
15
+ <rect x="0.75" y="0.75" width="62.5" height="62.5" rx="17.25" stroke="#ffffff" stroke-opacity="0.18" stroke-width="1.5" />
16
+ <text x="32" y="41" text-anchor="middle" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="20" font-weight="800" letter-spacing="0.5" fill="#ffffff">ZT</text>
17
+ </svg>
@@ -3,7 +3,33 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Zerotal Example</title>
6
+ <title>{{name}}</title>
7
+
8
+ <link rel="icon" href="/zt.svg" type="image/svg+xml" />
9
+ <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
10
+ <meta name="theme-color" content="#191922" media="(prefers-color-scheme: dark)" />
11
+
12
+ <!--
13
+ Resolve the theme before the first paint. Without this the page renders in
14
+ light mode for a frame and then flips, which is very visible on a dark
15
+ background. Kept inline and synchronous on purpose — a deferred script is
16
+ already too late. The storage key matches the one @zerotal/flow-ui uses, so
17
+ an app and its admin panel stay on the same theme.
18
+ -->
19
+ <script>
20
+ (function () {
21
+ try {
22
+ var stored = localStorage.getItem("zerotal-theme");
23
+ var dark = stored
24
+ ? stored === "dark"
25
+ : window.matchMedia("(prefers-color-scheme: dark)").matches;
26
+ document.documentElement.classList.toggle("dark", dark);
27
+ } catch (e) {
28
+ /* private mode / storage disabled — fall through to light */
29
+ }
30
+ })();
31
+ </script>
32
+
7
33
  <link rel="stylesheet" href="/assets/app.css" />
8
34
  <script type="module" src="/assets/app.js"></script>
9
35
  </head>
@@ -1 +1,206 @@
1
1
  @import "tailwindcss";
2
+
3
+ /* Page components live outside this file's directory — name them explicitly so
4
+ * their utility classes survive Tailwind's content scan. */
5
+ @source "../js";
6
+
7
+ /* Dark mode is driven by a `.dark` class on <html>, not the OS preference, so the
8
+ * in-app toggle can override it. The no-flash script in resources/app.html sets
9
+ * the class before first paint. */
10
+ @custom-variant dark (&:where(.dark, .dark *));
11
+
12
+ /* ── Design tokens ───────────────────────────────────────────────────────────
13
+ *
14
+ * Every colour in this app is one of the semantic tokens below — `bg-background`,
15
+ * `text-muted-foreground`, `border-border`, `bg-primary`. Nothing references a raw
16
+ * palette shade like `indigo-600`, which means re-branding is a single edit here
17
+ * rather than a find-and-replace across every component.
18
+ *
19
+ * The names match the tokens `@zerotal/flow-ui` defines, so an app, its admin
20
+ * panel and any copied-in flow-ui component all read from the same vocabulary.
21
+ *
22
+ * Colours are oklch: perceptually uniform, so lightening `--primary` by hand
23
+ * keeps its hue and saturation intact.
24
+ */
25
+
26
+ :root {
27
+ color-scheme: light;
28
+
29
+ --radius: 0.75rem;
30
+
31
+ --background: oklch(0.994 0.003 286);
32
+ --foreground: oklch(0.185 0.015 286);
33
+
34
+ --card: oklch(1 0 0);
35
+ --card-foreground: oklch(0.185 0.015 286);
36
+
37
+ --muted: oklch(0.968 0.005 286);
38
+ --muted-foreground: oklch(0.53 0.016 286);
39
+
40
+ --primary: oklch(0.53 0.223 286);
41
+ --primary-foreground: oklch(0.99 0.004 286);
42
+ /* Explicit hover shade rather than an opacity fade, so a solid button stays
43
+ * solid over the grid background. */
44
+ --primary-hover: oklch(0.47 0.223 286);
45
+
46
+ --accent: oklch(0.958 0.018 286);
47
+ --accent-foreground: oklch(0.36 0.14 286);
48
+
49
+ --success: oklch(0.6 0.14 158);
50
+ --success-foreground: oklch(0.99 0.01 158);
51
+
52
+ --destructive: oklch(0.577 0.245 27.3);
53
+ --destructive-foreground: oklch(0.99 0.01 27.3);
54
+
55
+ --border: oklch(0.912 0.006 286);
56
+ --input: oklch(0.912 0.006 286);
57
+ --ring: oklch(0.53 0.223 286);
58
+
59
+ /* Decorative — the hero grid and the glow behind it. */
60
+ --grid-line: oklch(0.185 0.015 286 / 6%);
61
+ --glow-a: oklch(0.7 0.19 286 / 30%);
62
+ --glow-b: oklch(0.75 0.16 220 / 22%);
63
+ }
64
+
65
+ .dark {
66
+ color-scheme: dark;
67
+
68
+ --background: oklch(0.163 0.014 286);
69
+ --foreground: oklch(0.968 0.005 286);
70
+
71
+ --card: oklch(0.204 0.016 286);
72
+ --card-foreground: oklch(0.968 0.005 286);
73
+
74
+ --muted: oklch(0.253 0.016 286);
75
+ --muted-foreground: oklch(0.706 0.015 286);
76
+
77
+ --primary: oklch(0.71 0.17 288);
78
+ --primary-foreground: oklch(0.17 0.03 286);
79
+ --primary-hover: oklch(0.78 0.15 288);
80
+
81
+ --accent: oklch(0.28 0.04 286);
82
+ --accent-foreground: oklch(0.9 0.06 288);
83
+
84
+ --success: oklch(0.72 0.15 158);
85
+ --success-foreground: oklch(0.17 0.03 158);
86
+
87
+ --destructive: oklch(0.704 0.191 22.2);
88
+ --destructive-foreground: oklch(0.16 0.02 22.2);
89
+
90
+ --border: oklch(1 0 0 / 11%);
91
+ --input: oklch(1 0 0 / 15%);
92
+ --ring: oklch(0.71 0.17 288);
93
+
94
+ --grid-line: oklch(1 0 0 / 7%);
95
+ --glow-a: oklch(0.6 0.22 288 / 35%);
96
+ --glow-b: oklch(0.6 0.19 220 / 25%);
97
+ }
98
+
99
+ /* Bind the tokens to Tailwind utilities. `inline` keeps the utilities pointing at
100
+ * `var(--token)` rather than a snapshot, which is what lets `.dark` re-theme the
101
+ * whole page by redefining the variables. */
102
+ @theme inline {
103
+ --color-background: var(--background);
104
+ --color-foreground: var(--foreground);
105
+ --color-card: var(--card);
106
+ --color-card-foreground: var(--card-foreground);
107
+ --color-muted: var(--muted);
108
+ --color-muted-foreground: var(--muted-foreground);
109
+ --color-primary: var(--primary);
110
+ --color-primary-foreground: var(--primary-foreground);
111
+ --color-primary-hover: var(--primary-hover);
112
+ --color-accent: var(--accent);
113
+ --color-accent-foreground: var(--accent-foreground);
114
+ --color-success: var(--success);
115
+ --color-success-foreground: var(--success-foreground);
116
+ --color-destructive: var(--destructive);
117
+ --color-destructive-foreground: var(--destructive-foreground);
118
+ --color-border: var(--border);
119
+ --color-input: var(--input);
120
+ --color-ring: var(--ring);
121
+
122
+ --radius-sm: calc(var(--radius) - 4px);
123
+ --radius-md: calc(var(--radius) - 2px);
124
+ --radius-lg: var(--radius);
125
+ --radius-xl: calc(var(--radius) + 4px);
126
+ --radius-2xl: calc(var(--radius) + 10px);
127
+
128
+ --font-sans:
129
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
130
+ --font-mono:
131
+ ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
132
+ }
133
+
134
+ @layer base {
135
+ html {
136
+ scroll-behavior: smooth;
137
+ }
138
+
139
+ body {
140
+ background-color: var(--background);
141
+ color: var(--foreground);
142
+ -webkit-font-smoothing: antialiased;
143
+ text-rendering: optimizeLegibility;
144
+ }
145
+
146
+ /* One focus treatment for every interactive element, so a custom-styled button
147
+ * is as keyboard-navigable as a bare one. */
148
+ :focus-visible {
149
+ outline: 2px solid var(--ring);
150
+ outline-offset: 2px;
151
+ }
152
+
153
+ ::selection {
154
+ background-color: color-mix(in oklch, var(--primary) 24%, transparent);
155
+ }
156
+
157
+ @media (prefers-reduced-motion: reduce) {
158
+ html {
159
+ scroll-behavior: auto;
160
+ }
161
+ *,
162
+ *::before,
163
+ *::after {
164
+ animation-duration: 0.01ms !important;
165
+ animation-iteration-count: 1 !important;
166
+ transition-duration: 0.01ms !important;
167
+ }
168
+ }
169
+ }
170
+
171
+ /* ── Decorative utilities ────────────────────────────────────────────────────
172
+ * Both are purely cosmetic and token-driven, so they follow the theme without a
173
+ * `dark:` variant anywhere. Delete them and the layout still holds. */
174
+
175
+ /** Faint graph-paper grid, faded out towards the bottom by a radial mask. */
176
+ @utility bg-grid {
177
+ background-image:
178
+ linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
179
+ linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
180
+ background-size: 64px 64px;
181
+ mask-image: radial-gradient(ellipse 75% 55% at 50% 0%, #000 30%, transparent 100%);
182
+ }
183
+
184
+ /** Soft two-tone aurora, drifting slowly. Sits behind the hero. */
185
+ @utility bg-aurora {
186
+ background-image:
187
+ radial-gradient(38rem 22rem at 22% 0%, var(--glow-a), transparent 70%),
188
+ radial-gradient(32rem 20rem at 78% 12%, var(--glow-b), transparent 70%);
189
+ animation: aurora-drift 22s ease-in-out infinite alternate;
190
+ }
191
+
192
+ @keyframes aurora-drift {
193
+ from {
194
+ transform: translate3d(-1.5%, 0, 0) scale(1);
195
+ }
196
+ to {
197
+ transform: translate3d(1.5%, 1.5%, 0) scale(1.06);
198
+ }
199
+ }
200
+
201
+ /** Hairline card edge with an inner highlight — reads as depth without a shadow. */
202
+ @utility surface {
203
+ background-color: var(--card);
204
+ border: 1px solid var(--border);
205
+ box-shadow: inset 0 1px 0 0 oklch(1 0 0 / 6%);
206
+ }
@@ -0,0 +1,49 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The app's button styles, in one place — the Vue twin of the React template's
4
+ * Button, down to the class strings, so the two templates render the same UI.
5
+ */
6
+ import { computed } from "vue";
7
+ import { cn } from "../lib/cn";
8
+
9
+ type ButtonVariant = "primary" | "secondary" | "ghost";
10
+ type ButtonSize = "sm" | "md" | "lg";
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ variant?: ButtonVariant;
15
+ size?: ButtonSize;
16
+ class?: string;
17
+ type?: "button" | "submit" | "reset";
18
+ disabled?: boolean;
19
+ }>(),
20
+ { variant: "primary", size: "md", type: "button", disabled: false },
21
+ );
22
+
23
+ const BASE =
24
+ "inline-flex items-center justify-center gap-2 rounded-lg font-medium whitespace-nowrap " +
25
+ "transition-[background-color,border-color,color,transform] duration-150 " +
26
+ "active:translate-y-px disabled:pointer-events-none disabled:opacity-55";
27
+
28
+ const VARIANTS: Record<ButtonVariant, string> = {
29
+ primary: "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover",
30
+ secondary: "border border-border bg-card text-foreground hover:bg-muted",
31
+ ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
32
+ };
33
+
34
+ const SIZES: Record<ButtonSize, string> = {
35
+ sm: "h-8 px-3 text-sm",
36
+ md: "h-10 px-4 text-sm",
37
+ lg: "h-11 px-5 text-[0.95rem]",
38
+ };
39
+
40
+ const classes = computed(() =>
41
+ cn(BASE, VARIANTS[props.variant], SIZES[props.size], props.class),
42
+ );
43
+ </script>
44
+
45
+ <template>
46
+ <button :type="type" :disabled="disabled" :class="classes">
47
+ <slot />
48
+ </button>
49
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ /** The app's surface primitive — matches the React template's Card. */
3
+ import { computed } from "vue";
4
+ import { cn } from "../lib/cn";
5
+
6
+ const props = withDefaults(
7
+ defineProps<{ class?: string; interactive?: boolean }>(),
8
+ { interactive: false },
9
+ );
10
+
11
+ const classes = computed(() =>
12
+ cn(
13
+ "surface rounded-xl",
14
+ props.interactive &&
15
+ "transition-[transform,border-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-primary/40",
16
+ props.class,
17
+ ),
18
+ );
19
+ </script>
20
+
21
+ <template>
22
+ <div :class="classes"><slot /></div>
23
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Auto-dismissing toasts driven by the shared `flash` prop.
4
+ *
5
+ * A route calls `http.flash("success", "…")` before redirecting; the flash
6
+ * survives exactly one request, @zerotal/inertia merges it into every page's
7
+ * props, and this turns it into a toast. Rendered once in AppLayout, so no page
8
+ * has to think about it.
9
+ */
10
+ import { ref, watch } from "vue";
11
+ import { usePage } from "@inertiajs/vue3";
12
+ import { cn } from "../lib/cn";
13
+ import Icon from "./Icon.vue";
14
+ import type { SharedProps } from "../types";
15
+
16
+ interface Toast {
17
+ id: number;
18
+ kind: "success" | "error";
19
+ message: string;
20
+ }
21
+
22
+ const TONE: Record<Toast["kind"], string> = {
23
+ success: "border-success/40 text-success",
24
+ error: "border-destructive/40 text-destructive",
25
+ };
26
+
27
+ const page = usePage<SharedProps>();
28
+ const toasts = ref<Toast[]>([]);
29
+
30
+ watch(
31
+ () => [page.props.flash?.success, page.props.flash?.error] as const,
32
+ ([success, error]) => {
33
+ const next: Toast[] = [];
34
+ if (success) next.push({ id: Date.now(), kind: "success", message: success });
35
+ if (error) next.push({ id: Date.now() + 1, kind: "error", message: error });
36
+ if (next.length === 0) return;
37
+
38
+ toasts.value = [...toasts.value, ...next];
39
+ setTimeout(() => {
40
+ toasts.value = toasts.value.filter((t) => !next.some((n) => n.id === t.id));
41
+ }, 5000);
42
+ },
43
+ { immediate: true },
44
+ );
45
+
46
+ function dismiss(id: number): void {
47
+ toasts.value = toasts.value.filter((t) => t.id !== id);
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <div
53
+ v-if="toasts.length"
54
+ aria-live="polite"
55
+ class="pointer-events-none fixed inset-x-4 bottom-4 z-50 flex flex-col items-end gap-2 sm:inset-x-auto sm:right-6"
56
+ >
57
+ <div
58
+ v-for="toast in toasts"
59
+ :key="toast.id"
60
+ :class="
61
+ cn(
62
+ 'pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border bg-card p-3.5 shadow-lg',
63
+ TONE[toast.kind],
64
+ )
65
+ "
66
+ >
67
+ <span class="mt-0.5 shrink-0">
68
+ <Icon :name="toast.kind === 'success' ? 'check' : 'close'" class="size-4.5" />
69
+ </span>
70
+ <p class="flex-1 text-sm text-card-foreground">{{ toast.message }}</p>
71
+ <button
72
+ type="button"
73
+ aria-label="Dismiss"
74
+ class="shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground"
75
+ @click="dismiss(toast.id)"
76
+ >
77
+ <Icon name="close" class="size-4" />
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </template>