scale-stack 0.0.1-alpha.2 → 0.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.
- package/CHANGELOG.md +34 -0
- package/README.md +67 -5
- package/dist/index.js +4081 -106
- package/package.json +7 -2
- package/templates/ai-chat/chat-panel.tsx.ejs +70 -0
- package/templates/ai-chat/layout.tsx.ejs +35 -0
- package/templates/ai-chat/page.tsx.ejs +37 -0
- package/templates/ai-chat/route.ts.ejs +68 -0
- package/templates/ai-chat/use-chat.ts.ejs +26 -0
- package/templates/analytics/analytics-provider.tsx.ejs +32 -0
- package/templates/analytics/analytics.ts.ejs +40 -0
- package/templates/auth/auth-client.ts.ejs +7 -0
- package/templates/auth/auth.ts.ejs +45 -0
- package/templates/auth/route.ts.ejs +4 -0
- package/templates/auth/sign-in-page.tsx.ejs +122 -0
- package/templates/auth/sign-up-page.tsx.ejs +137 -0
- package/templates/auth/unauthorized.tsx.ejs +28 -0
- package/templates/core/layout.tsx.ejs +46 -0
- package/templates/core/loading.tsx.ejs +7 -0
- package/templates/core/next.config.ts.ejs +33 -0
- package/templates/error-handling/catch-all-not-found-page.tsx.ejs +5 -0
- package/templates/error-handling/error.tsx.ejs +33 -0
- package/templates/error-handling/global-error.tsx.ejs +32 -0
- package/templates/error-handling/not-found.tsx.ejs +28 -0
- package/templates/eslint-prettier/.prettierignore.ejs +29 -0
- package/templates/eslint-prettier/eslint.config.mjs.ejs +31 -0
- package/templates/form-handling/dashboard-page.tsx.ejs +39 -0
- package/templates/form-handling/example-form-action.ts.ejs +50 -0
- package/templates/form-handling/example-form-schema.ts.ejs +87 -0
- package/templates/form-handling/example-form.tsx.ejs +428 -0
- package/templates/i18n/ar.json.ejs +77 -0
- package/templates/i18n/en.json.ejs +77 -0
- package/templates/i18n/locale-layout.tsx.ejs +81 -0
- package/templates/i18n/navigation.ts.ejs +5 -0
- package/templates/i18n/next-intl.d.ts.ejs +9 -0
- package/templates/i18n/request.ts.ejs +15 -0
- package/templates/i18n/routing.ts.ejs +7 -0
- package/templates/orm/prisma.config.ts.ejs +12 -0
- package/templates/orm/prisma.ts.ejs +17 -0
- package/templates/orm/schema.prisma.ejs +8 -0
- package/templates/pre-commit/prek.toml.ejs +35 -0
- package/templates/proxy/proxy.ts.ejs +81 -0
- package/templates/server-actions/safe-action.ts.ejs +51 -0
- package/templates/ui/client-side-wrappers.tsx.ejs +19 -0
- package/templates/ui/page.tsx.ejs +117 -0
- package/templates/utility-libs/date-fns.SKILL.md.ejs +34 -0
- package/templates/utility-libs/motion.SKILL.md.ejs +234 -0
- package/templates/utility-libs/ts-pattern.SKILL.md.ejs +44 -0
- package/templates/utility-libs/usehooks.SKILL.md.ejs +38 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getRequestConfig } from "next-intl/server";
|
|
2
|
+
import { hasLocale } from "next-intl";
|
|
3
|
+
import { routing } from "./routing";
|
|
4
|
+
|
|
5
|
+
export default getRequestConfig(async ({ requestLocale }) => {
|
|
6
|
+
const requested = await requestLocale;
|
|
7
|
+
const locale = hasLocale(routing.locales, requested)
|
|
8
|
+
? requested
|
|
9
|
+
: routing.defaultLocale;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
locale,
|
|
13
|
+
messages: (await import(`../../messages/${locale}.json`)).default,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { env } from "@/env";
|
|
2
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
3
|
+
import { PrismaClient } from "../../prisma/generated/client";
|
|
4
|
+
|
|
5
|
+
const globalForPrisma = globalThis as unknown as {
|
|
6
|
+
prisma?: PrismaClient;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const prisma =
|
|
10
|
+
globalForPrisma.prisma ??
|
|
11
|
+
new PrismaClient({
|
|
12
|
+
adapter: new PrismaPg({ connectionString: env.DATABASE_URL }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (process.env.NODE_ENV !== "production") {
|
|
16
|
+
globalForPrisma.prisma = prisma;
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# --- Built-in fast hooks (offline, zero setup) ---
|
|
2
|
+
[[repos]]
|
|
3
|
+
repo = "builtin"
|
|
4
|
+
hooks = [
|
|
5
|
+
{ id = "trailing-whitespace" },
|
|
6
|
+
{ id = "end-of-file-fixer" },
|
|
7
|
+
{ id = "check-yaml" },
|
|
8
|
+
{ id = "check-json" },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
# --- Local project hooks (use package.json scripts) ---
|
|
12
|
+
[[repos]]
|
|
13
|
+
repo = "local"
|
|
14
|
+
|
|
15
|
+
[[repos.hooks]]
|
|
16
|
+
id = "eslint"
|
|
17
|
+
name = "eslint"
|
|
18
|
+
language = "system"
|
|
19
|
+
entry = "pnpm lint"
|
|
20
|
+
files = { glob = "*.{ts,tsx,js,jsx,mjs}" }
|
|
21
|
+
pass_filenames = false
|
|
22
|
+
|
|
23
|
+
[[repos.hooks]]
|
|
24
|
+
id = "prettier"
|
|
25
|
+
name = "prettier"
|
|
26
|
+
language = "system"
|
|
27
|
+
entry = "pnpm format"
|
|
28
|
+
files = { glob = "*.{ts,tsx,js,jsx,json,css,md}" }
|
|
29
|
+
|
|
30
|
+
[[repos.hooks]]
|
|
31
|
+
id = "typecheck"
|
|
32
|
+
name = "typecheck"
|
|
33
|
+
language = "system"
|
|
34
|
+
entry = "pnpm typecheck"
|
|
35
|
+
pass_filenames = false
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { NextRequest } from "next/server";
|
|
2
|
+
<% if (hasAuth || !hasI18n) { %>
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
<% } %>
|
|
5
|
+
<% if (hasI18n) { %>import createMiddleware from "next-intl/middleware";
|
|
6
|
+
<% } %><% if (hasAuth) { %>import { getSessionCookie } from "better-auth/cookies";
|
|
7
|
+
<% } %><% if (hasI18n) { %>import { routing } from "./i18n/routing";
|
|
8
|
+
<% if (hasAuth) { %>import { getPathname } from "./i18n/navigation";
|
|
9
|
+
<% } %><% } %>
|
|
10
|
+
<% if (hasI18n) { %>const intlProxy = createMiddleware(routing);
|
|
11
|
+
|
|
12
|
+
<% } %><% if (hasAuth) { %>const PROTECTED_ROUTE_PREFIXES = ["/dashboard"] as const;
|
|
13
|
+
|
|
14
|
+
function normalizePathname(pathname: string): string {
|
|
15
|
+
<% if (hasI18n) { %> const [, maybeLocale, ...rest] = pathname.split("/");
|
|
16
|
+
const normalizedPathname = routing.locales.some(
|
|
17
|
+
(locale) => locale === maybeLocale,
|
|
18
|
+
)
|
|
19
|
+
? `/${rest.join("/")}`
|
|
20
|
+
: pathname;
|
|
21
|
+
|
|
22
|
+
<% } else { %> const normalizedPathname = pathname;
|
|
23
|
+
<% } %> return normalizedPathname === "/"
|
|
24
|
+
? "/"
|
|
25
|
+
: normalizedPathname.replace(/\/$/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isProtectedRoute(pathname: string): boolean {
|
|
29
|
+
const normalizedPathname = normalizePathname(pathname);
|
|
30
|
+
|
|
31
|
+
return PROTECTED_ROUTE_PREFIXES.some(
|
|
32
|
+
(prefix) =>
|
|
33
|
+
normalizedPathname === prefix ||
|
|
34
|
+
normalizedPathname.startsWith(`${prefix}/`),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildSignInUrl(request: NextRequest): URL {
|
|
39
|
+
const url = request.nextUrl.clone();
|
|
40
|
+
<% if (hasI18n) { %> const [, maybeLocale] = request.nextUrl.pathname.split("/");
|
|
41
|
+
const locale =
|
|
42
|
+
routing.locales.find((item) => item === maybeLocale) ??
|
|
43
|
+
routing.defaultLocale;
|
|
44
|
+
|
|
45
|
+
url.pathname = getPathname({ locale, href: "/sign-in" });
|
|
46
|
+
<% } else { %> url.pathname = "/sign-in";
|
|
47
|
+
<% } %> url.searchParams.set(
|
|
48
|
+
"callbackUrl",
|
|
49
|
+
`${request.nextUrl.pathname}${request.nextUrl.search}`,
|
|
50
|
+
);
|
|
51
|
+
return url;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function authProxy(request: NextRequest): NextResponse | null {
|
|
55
|
+
if (!isProtectedRoute(request.nextUrl.pathname)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// This proxy check is only for fast redirects; validate the session again
|
|
60
|
+
// before reading protected data in pages, route handlers, and actions.
|
|
61
|
+
if (getSessionCookie(request)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return NextResponse.redirect(buildSignInUrl(request));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
<% } %>export default function proxy(<% if (hasAuth || hasI18n) { %>request<% } else { %>_request<% } %>: NextRequest) {
|
|
69
|
+
<% if (hasAuth) { %> const authResponse = authProxy(request);
|
|
70
|
+
|
|
71
|
+
if (authResponse) {
|
|
72
|
+
return authResponse;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
<% } %><% if (hasI18n) { %> return intlProxy(request);
|
|
76
|
+
<% } else { %> return NextResponse.next();
|
|
77
|
+
<% } %>}
|
|
78
|
+
|
|
79
|
+
export const config = {
|
|
80
|
+
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
|
81
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createSafeActionClient } from "next-safe-action";
|
|
2
|
+
<% if (hasAuth) { %>import { betterAuth } from "@next-safe-action/adapter-better-auth";
|
|
3
|
+
import { auth } from "@/lib/auth";
|
|
4
|
+
<% } %>import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
const actionMetadataSchema = z.object({
|
|
7
|
+
actionName: z.string().min(1),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
type ActionMetadata = z.infer<typeof actionMetadataSchema>;
|
|
11
|
+
type ActionOutcome = "success" | "error";
|
|
12
|
+
|
|
13
|
+
function logAction(
|
|
14
|
+
metadata: ActionMetadata | undefined,
|
|
15
|
+
durationMs: number,
|
|
16
|
+
outcome: ActionOutcome,
|
|
17
|
+
): void {
|
|
18
|
+
console.info("[safe-action]", {
|
|
19
|
+
actionName: metadata?.actionName ?? "unknown",
|
|
20
|
+
durationMs,
|
|
21
|
+
outcome,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export const actionClient = createSafeActionClient({
|
|
25
|
+
defaultValidationErrorsShape: "flattened",
|
|
26
|
+
defineMetadataSchema() {
|
|
27
|
+
return actionMetadataSchema;
|
|
28
|
+
},
|
|
29
|
+
handleServerError(error, { metadata }) {
|
|
30
|
+
console.error("[safe-action] server error", {
|
|
31
|
+
actionName: metadata?.actionName ?? "unknown",
|
|
32
|
+
error,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return "Something went wrong. Please try again.";
|
|
36
|
+
},
|
|
37
|
+
}).use(async ({ next, metadata }) => {
|
|
38
|
+
const startedAt = performance.now();
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const result = await next();
|
|
42
|
+
logAction(metadata, Math.round(performance.now() - startedAt), "success");
|
|
43
|
+
return result;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
logAction(metadata, Math.round(performance.now() - startedAt), "error");
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
<% if (hasAuth) { %>
|
|
50
|
+
export const authActionClient = actionClient.use(betterAuth(auth));
|
|
51
|
+
<% } %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
|
5
|
+
import { Toaster } from "@/components/ui/sonner";
|
|
6
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
7
|
+
<% if (hasAnalyticsProvider) { %>import { AnalyticsProvider } from "./analytics-provider";
|
|
8
|
+
<% } %>
|
|
9
|
+
|
|
10
|
+
export function ClientSideWrappers({ children }: { children: ReactNode }) {
|
|
11
|
+
return (
|
|
12
|
+
<NuqsAdapter>
|
|
13
|
+
<TooltipProvider>
|
|
14
|
+
<% if (hasAnalyticsProvider) { %><AnalyticsProvider>{children}</AnalyticsProvider><% } else { %>{children}<% } %>
|
|
15
|
+
<Toaster />
|
|
16
|
+
</TooltipProvider>
|
|
17
|
+
</NuqsAdapter>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
2
|
+
import type { Locale } from "next-intl";
|
|
3
|
+
import { Link } from "@/i18n/navigation";
|
|
4
|
+
<% } else { %>import Link from "next/link";
|
|
5
|
+
<% } %>import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Card,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardDescription,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle,
|
|
12
|
+
} from "@/components/ui/card";
|
|
13
|
+
|
|
14
|
+
<% if (i18n) { %>type HomeProps = Readonly<{
|
|
15
|
+
params: Promise<{ locale: Locale }>;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
<% } %>export default <% if (i18n) { %>async <% } %>function Home(<% if (i18n) { %>{ params }: HomeProps<% } %>) {
|
|
19
|
+
<% if (i18n) { %> const { locale } = await params;
|
|
20
|
+
const t = await getTranslations({ locale, namespace: "Home" });
|
|
21
|
+
|
|
22
|
+
<% } %>
|
|
23
|
+
return (
|
|
24
|
+
<main className="relative flex flex-1 flex-col overflow-hidden px-6 py-16">
|
|
25
|
+
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_top_left,var(--muted),transparent_34rem),radial-gradient(circle_at_bottom_right,var(--accent),transparent_28rem)]" />
|
|
26
|
+
<section className="mx-auto flex w-full max-w-6xl flex-col gap-10">
|
|
27
|
+
<div className="max-w-3xl space-y-6">
|
|
28
|
+
<p className="text-sm font-medium tracking-[0.2em] text-muted-foreground uppercase">
|
|
29
|
+
<% if (i18n) { %>{t("eyebrow")}<% } else { %>Scale Stack<% } %>
|
|
30
|
+
</p>
|
|
31
|
+
<div className="space-y-4">
|
|
32
|
+
<h1 className="max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-6xl">
|
|
33
|
+
<%= projectName %>
|
|
34
|
+
</h1>
|
|
35
|
+
<p className="max-w-2xl text-lg text-muted-foreground text-balance">
|
|
36
|
+
<% if (i18n) { %>{t("description")}<% } else { %>Your app is wired with shadcn/ui, Tailwind CSS 4, typed server
|
|
37
|
+
actions, form handling, and the Scale Stack guardrails agents
|
|
38
|
+
need to keep moving safely.<% } %>
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div className="space-y-3">
|
|
44
|
+
<div>
|
|
45
|
+
<h2 className="text-2xl font-semibold tracking-tight">
|
|
46
|
+
<% if (i18n) { %>{t("explorePages")}<% } else { %>Explore generated pages<% } %>
|
|
47
|
+
</h2>
|
|
48
|
+
<p className="text-muted-foreground">
|
|
49
|
+
<% if (i18n) { %>{t("explorePagesDescription")}<% } else { %>Each card links to a real route in this scaffold.<% } %>
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="grid gap-4 md:grid-cols-2<% if (hasAuth && hasAiChat) { %> xl:grid-cols-4<% } else { %> xl:grid-cols-3<% } %>">
|
|
53
|
+
<Card className="transition-colors hover:border-primary/50">
|
|
54
|
+
<CardHeader>
|
|
55
|
+
<CardTitle><% if (i18n) { %>{t("dashboard")}<% } else { %>Dashboard<% } %></CardTitle>
|
|
56
|
+
<CardDescription>
|
|
57
|
+
<% if (i18n) { %>{t("dashboardCardDescription")}<% } else { %>Try the generated React Hook Form example backed by Zod,
|
|
58
|
+
shadcn/ui fields, and next-safe-action.<% } %>
|
|
59
|
+
</CardDescription>
|
|
60
|
+
</CardHeader>
|
|
61
|
+
<CardContent>
|
|
62
|
+
<Button asChild variant="secondary">
|
|
63
|
+
<Link href="/dashboard"><% if (i18n) { %>{t("exploreForms")}<% } else { %>Explore forms<% } %></Link>
|
|
64
|
+
</Button>
|
|
65
|
+
</CardContent>
|
|
66
|
+
</Card>
|
|
67
|
+
<% if (hasAiChat) { %>
|
|
68
|
+
<Card className="transition-colors hover:border-primary/50">
|
|
69
|
+
<CardHeader>
|
|
70
|
+
<CardTitle><% if (i18n) { %>{t("aiChat")}<% } else { %>AI chat<% } %></CardTitle>
|
|
71
|
+
<CardDescription>
|
|
72
|
+
<% if (i18n) { %>{t("aiChatCardDescription")}<% } else { %>Open the provider-free AI Elements mock and see the AI SDK
|
|
73
|
+
transport contract ready for your backend.<% } %>
|
|
74
|
+
</CardDescription>
|
|
75
|
+
</CardHeader>
|
|
76
|
+
<CardContent>
|
|
77
|
+
<Button asChild variant="secondary">
|
|
78
|
+
<Link href="/chat"><% if (i18n) { %>{t("openChat")}<% } else { %>Open chat<% } %></Link>
|
|
79
|
+
</Button>
|
|
80
|
+
</CardContent>
|
|
81
|
+
</Card>
|
|
82
|
+
<% } %><% if (hasAuth) { %>
|
|
83
|
+
<Card className="transition-colors hover:border-primary/50">
|
|
84
|
+
<CardHeader>
|
|
85
|
+
<CardTitle><% if (i18n) { %>{t("signIn")}<% } else { %>Sign in<% } %></CardTitle>
|
|
86
|
+
<CardDescription>
|
|
87
|
+
<% if (i18n) { %>{t("<% if (isStatefulAuth) { %>signInStatefulCardDescription<% } else { %>signInCardDescription<% } %>")}<% } else { %>Exercise Better Auth with Microsoft Entra OAuth<% if (isStatefulAuth) { %>
|
|
88
|
+
and Prisma-backed email/password sessions<% } %>.<% } %>
|
|
89
|
+
</CardDescription>
|
|
90
|
+
</CardHeader>
|
|
91
|
+
<CardContent>
|
|
92
|
+
<Button asChild variant="secondary">
|
|
93
|
+
<Link href="/sign-in"><% if (i18n) { %>{t("signIn")}<% } else { %>Sign in<% } %></Link>
|
|
94
|
+
</Button>
|
|
95
|
+
</CardContent>
|
|
96
|
+
</Card>
|
|
97
|
+
<Card className="transition-colors hover:border-primary/50">
|
|
98
|
+
<CardHeader>
|
|
99
|
+
<CardTitle><% if (i18n) { %>{t("signUp")}<% } else { %>Sign up<% } %></CardTitle>
|
|
100
|
+
<CardDescription>
|
|
101
|
+
<% if (i18n) { %>{t("signUpCardDescription")}<% } else { %>Create an account through the same Better Auth setup and keep
|
|
102
|
+
the onboarding route ready for customization.<% } %>
|
|
103
|
+
</CardDescription>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent>
|
|
106
|
+
<Button asChild variant="secondary">
|
|
107
|
+
<Link href="/sign-up"><% if (i18n) { %>{t("signUp")}<% } else { %>Sign up<% } %></Link>
|
|
108
|
+
</Button>
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
<% } %>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</section>
|
|
115
|
+
</main>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: date-fns
|
|
3
|
+
description: Use date-fns for date parsing, formatting, comparison, and arithmetic in this project. Use when working with dates, durations, calendars, relative time, or locale-aware date formatting.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# date-fns
|
|
7
|
+
|
|
8
|
+
## Instructions
|
|
9
|
+
|
|
10
|
+
- Use focused function imports from `date-fns`, for example `import { format, parseISO } from "date-fns"`.
|
|
11
|
+
- Keep parsing explicit. Use `parseISO` for ISO strings instead of relying on ambiguous `Date` parsing.
|
|
12
|
+
- Treat `Date` values as values passed between helpers. Do not mutate dates in place.
|
|
13
|
+
- Prefer date-fns helpers for comparison and arithmetic instead of manual millisecond math.
|
|
14
|
+
- Use locale-aware formatting when user-facing dates need localization.
|
|
15
|
+
|
|
16
|
+
## Common Patterns
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { format, isAfter, parseISO } from "date-fns";
|
|
20
|
+
|
|
21
|
+
const dueDate = parseISO(task.dueAt);
|
|
22
|
+
const isOverdue = isAfter(new Date(), dueDate);
|
|
23
|
+
const label = format(dueDate, "PPP");
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For business rules, keep date operations near the domain logic and name the intent:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { addDays, startOfDay } from "date-fns";
|
|
30
|
+
|
|
31
|
+
export function trialEndsAt(startedAt: Date): Date {
|
|
32
|
+
return startOfDay(addDays(startedAt, 14));
|
|
33
|
+
}
|
|
34
|
+
```
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: motion
|
|
3
|
+
description: Use Motion for React animations in this project. Use when adding or changing React animations, gestures, layout animations, page transitions, scroll effects, or exit animations with the `motion` package.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Motion
|
|
7
|
+
|
|
8
|
+
## When To Use Motion
|
|
9
|
+
|
|
10
|
+
Use Motion when the UI needs React-aware animation:
|
|
11
|
+
|
|
12
|
+
- State-driven animations linked to component props or state.
|
|
13
|
+
- Cross-device gestures such as hover, tap, focus, drag, and in-view effects.
|
|
14
|
+
- Enter and exit animations for conditionally rendered UI.
|
|
15
|
+
- Layout animations, shared element transitions, reorderable lists, or expanding panels.
|
|
16
|
+
- Coordinated parent/child sequences, staggered reveals, or keyframes.
|
|
17
|
+
- Scroll-triggered and scroll-linked animation.
|
|
18
|
+
- SVG path, shape, or attribute animation.
|
|
19
|
+
|
|
20
|
+
Use CSS transitions instead for a simple, isolated effect like a color change on hover.
|
|
21
|
+
|
|
22
|
+
## Project Rules
|
|
23
|
+
|
|
24
|
+
- Import React APIs from `motion/react`.
|
|
25
|
+
- In the Next.js App Router, put interactive Motion usage in Client Components. Add `"use client"` when using Motion hooks, gestures, state, event handlers, or browser-only APIs.
|
|
26
|
+
- Prefer transforms and opacity for smooth animation. Use `layout` for layout changes instead of manually animating layout properties.
|
|
27
|
+
- Keep animation targets readable and typed. Extract repeated variants/transitions into local constants near the component that owns them.
|
|
28
|
+
- Respect reduced motion. Replace large transform, parallax, and autoplaying motion with opacity or static alternatives.
|
|
29
|
+
- Avoid adding another animation library unless Motion cannot solve the requirement.
|
|
30
|
+
|
|
31
|
+
## Import Guide
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import {
|
|
35
|
+
AnimatePresence,
|
|
36
|
+
MotionConfig,
|
|
37
|
+
motion,
|
|
38
|
+
stagger,
|
|
39
|
+
useAnimate,
|
|
40
|
+
useReducedMotion,
|
|
41
|
+
useScroll,
|
|
42
|
+
useTransform,
|
|
43
|
+
} from "motion/react";
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Use only what you need so the package can stay tree-shakable.
|
|
47
|
+
|
|
48
|
+
## Decision Guide
|
|
49
|
+
|
|
50
|
+
- One element changes with state: use `animate`, `initial`, and `transition`.
|
|
51
|
+
- Hover/tap/focus/drag/in-view behavior: use `whileHover`, `whileTap`, `whileFocus`, `whileDrag`, or `whileInView`.
|
|
52
|
+
- Element leaves the React tree: wrap the conditional region in `AnimatePresence` and define `exit`.
|
|
53
|
+
- Parent and children need coordination: use `variants`, `stagger`, `delayChildren`, and `when`.
|
|
54
|
+
- Element size or position changes: use `layout`; use `layoutId` for shared element transitions.
|
|
55
|
+
- Animation must run from an event or sequence outside render state: use `useAnimate`.
|
|
56
|
+
- Animation follows scroll: use `useScroll` and `useTransform`, with a reduced-motion fallback.
|
|
57
|
+
|
|
58
|
+
## Patterns
|
|
59
|
+
|
|
60
|
+
### Microinteraction
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
"use client";
|
|
64
|
+
|
|
65
|
+
import { motion } from "motion/react";
|
|
66
|
+
|
|
67
|
+
export function SaveButton() {
|
|
68
|
+
return (
|
|
69
|
+
<motion.button
|
|
70
|
+
whileHover={{ scale: 1.03 }}
|
|
71
|
+
whileTap={{ scale: 0.97 }}
|
|
72
|
+
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
|
73
|
+
>
|
|
74
|
+
Save
|
|
75
|
+
</motion.button>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Enter And Exit
|
|
81
|
+
|
|
82
|
+
Use `AnimatePresence` when elements leave the React tree. Give exiting children stable keys.
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
"use client";
|
|
86
|
+
|
|
87
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
88
|
+
|
|
89
|
+
export function Toast({ message }: { message?: string }) {
|
|
90
|
+
return (
|
|
91
|
+
<AnimatePresence>
|
|
92
|
+
{message ? (
|
|
93
|
+
<motion.div
|
|
94
|
+
key={message}
|
|
95
|
+
initial={{ opacity: 0, y: 8 }}
|
|
96
|
+
animate={{ opacity: 1, y: 0 }}
|
|
97
|
+
exit={{ opacity: 0, y: -8 }}
|
|
98
|
+
>
|
|
99
|
+
{message}
|
|
100
|
+
</motion.div>
|
|
101
|
+
) : null}
|
|
102
|
+
</AnimatePresence>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Staggered Lists
|
|
108
|
+
|
|
109
|
+
Use variants when child animations should be coordinated by the parent.
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
"use client";
|
|
113
|
+
|
|
114
|
+
import { motion, stagger } from "motion/react";
|
|
115
|
+
|
|
116
|
+
const list = {
|
|
117
|
+
hidden: { opacity: 0 },
|
|
118
|
+
visible: {
|
|
119
|
+
opacity: 1,
|
|
120
|
+
transition: { delayChildren: stagger(0.06), when: "beforeChildren" },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const item = {
|
|
125
|
+
hidden: { opacity: 0, y: 8 },
|
|
126
|
+
visible: { opacity: 1, y: 0 },
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export function ResultList({ results }: { results: string[] }) {
|
|
130
|
+
return (
|
|
131
|
+
<motion.ul initial="hidden" animate="visible" variants={list}>
|
|
132
|
+
{results.map((result) => (
|
|
133
|
+
<motion.li key={result} variants={item}>
|
|
134
|
+
{result}
|
|
135
|
+
</motion.li>
|
|
136
|
+
))}
|
|
137
|
+
</motion.ul>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Layout Animation
|
|
143
|
+
|
|
144
|
+
Use `layout` for size and position changes. Use `layoutId` only for intentional shared-element transitions.
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
"use client";
|
|
148
|
+
|
|
149
|
+
import { motion } from "motion/react";
|
|
150
|
+
|
|
151
|
+
export function ExpandingCard({ expanded }: { expanded: boolean }) {
|
|
152
|
+
return (
|
|
153
|
+
<motion.article layout className={expanded ? "col-span-2" : undefined}>
|
|
154
|
+
<motion.h2 layout="position">Details</motion.h2>
|
|
155
|
+
</motion.article>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Scroll-Linked Animation
|
|
161
|
+
|
|
162
|
+
Use Motion values for scroll-linked transforms, and disable physical movement for reduced-motion users.
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
"use client";
|
|
166
|
+
|
|
167
|
+
import {
|
|
168
|
+
motion,
|
|
169
|
+
useReducedMotion,
|
|
170
|
+
useScroll,
|
|
171
|
+
useTransform,
|
|
172
|
+
} from "motion/react";
|
|
173
|
+
|
|
174
|
+
export function ReadingProgress() {
|
|
175
|
+
const shouldReduceMotion = useReducedMotion();
|
|
176
|
+
const { scrollYProgress } = useScroll();
|
|
177
|
+
const scaleX = useTransform(scrollYProgress, [0, 1], [0, 1]);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<motion.div
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
style={{ scaleX: shouldReduceMotion ? 1 : scaleX }}
|
|
183
|
+
className="fixed inset-x-0 top-0 h-1 origin-left"
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Imperative Sequence
|
|
190
|
+
|
|
191
|
+
Reach for `useAnimate` only when declarative props or variants are not enough.
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
"use client";
|
|
195
|
+
|
|
196
|
+
import { useEffect } from "react";
|
|
197
|
+
import { useAnimate } from "motion/react";
|
|
198
|
+
|
|
199
|
+
export function IntroSequence() {
|
|
200
|
+
const [scope, animate] = useAnimate();
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const controls = animate([
|
|
204
|
+
["h1", { opacity: [0, 1], y: [12, 0] }],
|
|
205
|
+
["p", { opacity: [0, 1] }, { at: "-0.1" }],
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
return () => controls.stop();
|
|
209
|
+
}, [animate]);
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<section ref={scope}>
|
|
213
|
+
<h1>Welcome</h1>
|
|
214
|
+
<p>Everything is ready.</p>
|
|
215
|
+
</section>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Accessibility Checklist
|
|
221
|
+
|
|
222
|
+
- Use `MotionConfig reducedMotion="user"` at an app or feature boundary when broad reduced-motion handling is appropriate.
|
|
223
|
+
- Use `useReducedMotion()` for bespoke components.
|
|
224
|
+
- Replace large `x`, `y`, `scale`, rotation, and parallax effects with opacity or static states when reduced motion is enabled.
|
|
225
|
+
- Do not autoplay decorative videos or looping motion for reduced-motion users.
|
|
226
|
+
- Keep focus states visible; do not replace focus indicators with motion-only feedback.
|
|
227
|
+
|
|
228
|
+
## Performance Checklist
|
|
229
|
+
|
|
230
|
+
- Prefer `opacity` and transforms (`x`, `y`, `scale`, `rotate`) for frequent animations.
|
|
231
|
+
- Avoid animating CSS variables for high-frequency motion because it can trigger paint; prefer Motion values for dynamic transforms.
|
|
232
|
+
- Use `layout` intentionally and sparingly in large lists.
|
|
233
|
+
- Use keyframes for short expressive sequences; keep long-running or repeating animations subtle.
|
|
234
|
+
- Use `initial={false}` when an entrance animation would cause distracting first paint or hydration movement.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ts-pattern
|
|
3
|
+
description: Use ts-pattern for exhaustive pattern matching in TypeScript. Use when handling discriminated unions, complex branching, nested data shapes, or replacing fragile switch/if chains.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ts-pattern
|
|
7
|
+
|
|
8
|
+
## Instructions
|
|
9
|
+
|
|
10
|
+
- Import `match` and patterns from `ts-pattern`: `import { match, P } from "ts-pattern"`.
|
|
11
|
+
- Prefer `match(value).with(...).exhaustive()` for closed discriminated unions so TypeScript verifies every case.
|
|
12
|
+
- Keep union tags explicit and stable, such as `{ type: "success" }` or `{ status: "idle" }`.
|
|
13
|
+
- Use `P` helpers for nested structures, guards, optionals, and wildcard cases when they improve readability.
|
|
14
|
+
- Avoid using `.otherwise()` for closed unions unless there is a deliberate unknown fallback. Prefer `.exhaustive()`.
|
|
15
|
+
|
|
16
|
+
## Common Patterns
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { match } from "ts-pattern";
|
|
20
|
+
|
|
21
|
+
type Result =
|
|
22
|
+
| { type: "success"; value: string }
|
|
23
|
+
| { type: "error"; message: string }
|
|
24
|
+
| { type: "loading" };
|
|
25
|
+
|
|
26
|
+
export function labelFor(result: Result): string {
|
|
27
|
+
return match(result)
|
|
28
|
+
.with({ type: "success" }, ({ value }) => value)
|
|
29
|
+
.with({ type: "error" }, ({ message }) => message)
|
|
30
|
+
.with({ type: "loading" }, () => "Loading...")
|
|
31
|
+
.exhaustive();
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use patterns to make nested branching readable:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { match, P } from "ts-pattern";
|
|
39
|
+
|
|
40
|
+
const canEdit = match(user)
|
|
41
|
+
.with({ role: "admin" }, () => true)
|
|
42
|
+
.with({ role: "member", permissions: P.array("write") }, () => true)
|
|
43
|
+
.otherwise(() => false);
|
|
44
|
+
```
|