scale-stack 0.0.1 → 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 +17 -0
- package/README.md +34 -66
- package/dist/index.js +438 -197
- package/package.json +1 -1
- package/templates/ai-chat/layout.tsx.ejs +2 -6
- package/templates/ai-chat/page.tsx.ejs +8 -2
- package/templates/core/layout.tsx.ejs +10 -0
- package/templates/form-handling/dashboard-page.tsx.ejs +8 -2
- package/templates/i18n/locale-layout.tsx.ejs +44 -8
- package/templates/ui/page.tsx.ejs +8 -2
- 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
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
3
|
-
import {
|
|
4
|
-
import { routing } from "@/i18n/routing";
|
|
3
|
+
import type { Locale } from "next-intl";
|
|
5
4
|
<% } %>
|
|
6
5
|
|
|
7
6
|
<% if (i18n) { %>type ChatLayoutProps = Readonly<{
|
|
@@ -12,10 +11,7 @@ import { routing } from "@/i18n/routing";
|
|
|
12
11
|
export async function generateMetadata({
|
|
13
12
|
params,
|
|
14
13
|
}: Pick<ChatLayoutProps, "params">): Promise<Metadata> {
|
|
15
|
-
const
|
|
16
|
-
const locale = hasLocale(routing.locales, requestedLocale)
|
|
17
|
-
? requestedLocale
|
|
18
|
-
: routing.defaultLocale;
|
|
14
|
+
const locale = (await params).locale as Locale;
|
|
19
15
|
const t = await getTranslations({ locale, namespace: "Chat" });
|
|
20
16
|
|
|
21
17
|
return {
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
2
|
+
import type { Locale } from "next-intl";
|
|
2
3
|
|
|
3
4
|
<% } %>
|
|
4
5
|
import { ChatPanel } from "./_components/ChatPanel";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
<% if (i18n) { %>type ChatPageProps = Readonly<{
|
|
8
|
+
params: Promise<{ locale: Locale }>;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
<% } %>export default <% if (i18n) { %>async <% } %>function ChatPage(<% if (i18n) { %>{ params }: ChatPageProps<% } %>) {
|
|
12
|
+
<% if (i18n) { %> const { locale } = await params;
|
|
13
|
+
const t = await getTranslations({ locale, namespace: "Chat" });
|
|
8
14
|
|
|
9
15
|
<% } %>
|
|
10
16
|
return (
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import { Geist, Geist_Mono } from "next/font/google";
|
|
3
3
|
import "./globals.css";
|
|
4
|
+
<% if (includeClientSideWrappers) { %>
|
|
5
|
+
import { Suspense } from "react";
|
|
6
|
+
import { ClientSideWrappers } from "./_providers/client-side-wrappers";
|
|
7
|
+
<% } %>
|
|
4
8
|
|
|
5
9
|
const geistSans = Geist({
|
|
6
10
|
variable: "--font-geist-sans",
|
|
@@ -28,8 +32,14 @@ export default function RootLayout({
|
|
|
28
32
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
29
33
|
>
|
|
30
34
|
<body className="min-h-full flex flex-col">
|
|
35
|
+
<% if (includeClientSideWrappers) { %>
|
|
36
|
+
<Suspense>
|
|
37
|
+
<ClientSideWrappers>{children}</ClientSideWrappers>
|
|
38
|
+
</Suspense>
|
|
39
|
+
<% } else { %>
|
|
31
40
|
{/* Scale Stack: wrap with NuqsAdapter and other root providers under src/app/_providers/ */}
|
|
32
41
|
{children}
|
|
42
|
+
<% } %>
|
|
33
43
|
</body>
|
|
34
44
|
</html>
|
|
35
45
|
);
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
2
|
+
import type { Locale } from "next-intl";
|
|
2
3
|
|
|
3
4
|
<% } %>
|
|
4
5
|
import { ExampleForm } from "./_components/ExampleForm";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
<% if (i18n) { %>type DashboardPageProps = Readonly<{
|
|
8
|
+
params: Promise<{ locale: Locale }>;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
<% } %>export default <% if (i18n) { %>async <% } %>function DashboardPage(<% if (i18n) { %>{ params }: DashboardPageProps<% } %>) {
|
|
12
|
+
<% if (i18n) { %> const { locale } = await params;
|
|
13
|
+
const t = await getTranslations({ locale, namespace: "Dashboard" });
|
|
8
14
|
|
|
9
15
|
<% } %>
|
|
10
16
|
return (
|
|
@@ -1,21 +1,45 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Geist_Mono } from "next/font/google";
|
|
2
3
|
import { notFound } from "next/navigation";
|
|
4
|
+
import { Suspense } from "react";
|
|
3
5
|
import { NextIntlClientProvider, hasLocale } from "next-intl";
|
|
4
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getMessages,
|
|
8
|
+
getTranslations,
|
|
9
|
+
setRequestLocale,
|
|
10
|
+
} from "next-intl/server";
|
|
5
11
|
import { routing } from "@/i18n/routing";
|
|
12
|
+
import "../globals.css";
|
|
13
|
+
import { ClientSideWrappers } from "../_providers/client-side-wrappers";
|
|
14
|
+
|
|
15
|
+
const geistSans = Geist({
|
|
16
|
+
variable: "--font-geist-sans",
|
|
17
|
+
subsets: ["latin"],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const geistMono = Geist_Mono({
|
|
21
|
+
variable: "--font-geist-mono",
|
|
22
|
+
subsets: ["latin"],
|
|
23
|
+
});
|
|
6
24
|
|
|
7
25
|
type LocaleLayoutProps = Readonly<{
|
|
8
26
|
children: React.ReactNode;
|
|
9
27
|
params: Promise<{ locale: string }>;
|
|
10
28
|
}>;
|
|
11
29
|
|
|
30
|
+
export function generateStaticParams() {
|
|
31
|
+
return routing.locales.map((locale) => ({ locale }));
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
export async function generateMetadata({
|
|
13
35
|
params,
|
|
14
36
|
}: Pick<LocaleLayoutProps, "params">): Promise<Metadata> {
|
|
15
|
-
const { locale
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
37
|
+
const { locale } = await params;
|
|
38
|
+
|
|
39
|
+
if (!hasLocale(routing.locales, locale)) {
|
|
40
|
+
notFound();
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
const t = await getTranslations({ locale, namespace: "Metadata" });
|
|
20
44
|
|
|
21
45
|
return {
|
|
@@ -34,12 +58,24 @@ export default async function LocaleLayout({
|
|
|
34
58
|
notFound();
|
|
35
59
|
}
|
|
36
60
|
|
|
61
|
+
setRequestLocale(locale);
|
|
62
|
+
|
|
37
63
|
const messages = await getMessages();
|
|
38
64
|
const direction = locale === "ar" ? "rtl" : "ltr";
|
|
39
65
|
|
|
40
66
|
return (
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
<html
|
|
68
|
+
lang={locale}
|
|
69
|
+
dir={direction}
|
|
70
|
+
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
|
71
|
+
>
|
|
72
|
+
<body className="min-h-full flex flex-col">
|
|
73
|
+
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
74
|
+
<Suspense>
|
|
75
|
+
<ClientSideWrappers>{children}</ClientSideWrappers>
|
|
76
|
+
</Suspense>
|
|
77
|
+
</NextIntlClientProvider>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
44
80
|
);
|
|
45
81
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<% if (i18n) { %>import { getTranslations } from "next-intl/server";
|
|
2
|
+
import type { Locale } from "next-intl";
|
|
2
3
|
import { Link } from "@/i18n/navigation";
|
|
3
4
|
<% } else { %>import Link from "next/link";
|
|
4
5
|
<% } %>import { Button } from "@/components/ui/button";
|
|
@@ -10,8 +11,13 @@ import {
|
|
|
10
11
|
CardTitle,
|
|
11
12
|
} from "@/components/ui/card";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
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" });
|
|
15
21
|
|
|
16
22
|
<% } %>
|
|
17
23
|
return (
|
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: usehooks
|
|
3
|
+
description: Use @uidotdev/usehooks for common React hooks in this project. Use when adding client-side hooks for browser state, media queries, local storage, timers, events, clipboard, network state, or DOM measurements.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# useHooks
|
|
7
|
+
|
|
8
|
+
## Instructions
|
|
9
|
+
|
|
10
|
+
- Import hooks from `@uidotdev/usehooks`.
|
|
11
|
+
- Use these hooks only from Client Components or other client-only hooks. Add `"use client"` where the component uses browser APIs.
|
|
12
|
+
- Prefer existing useHooks utilities before writing one-off hooks for common browser behavior.
|
|
13
|
+
- Keep server data fetching out of these hooks. Use framework data-fetching patterns for server data and useHooks for client/browser state.
|
|
14
|
+
- Check SSR behavior before use. For browser-only values, handle the initial render state deliberately.
|
|
15
|
+
|
|
16
|
+
## Common Patterns
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
"use client";
|
|
20
|
+
|
|
21
|
+
import { useMediaQuery } from "@uidotdev/usehooks";
|
|
22
|
+
|
|
23
|
+
export function ResponsiveSidebar() {
|
|
24
|
+
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
|
25
|
+
return isDesktop ? <aside /> : null;
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
"use client";
|
|
31
|
+
|
|
32
|
+
import { useLocalStorage } from "@uidotdev/usehooks";
|
|
33
|
+
|
|
34
|
+
export function ThemeToggle() {
|
|
35
|
+
const [theme, setTheme] = useLocalStorage("theme", "system");
|
|
36
|
+
return <button onClick={() => setTheme("dark")}>{theme}</button>;
|
|
37
|
+
}
|
|
38
|
+
```
|