pasika 0.1.1 → 0.1.6
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/README.md +31 -25
- package/claude/hooks/.vulyk +3 -0
- package/claude/hooks/AGENTS.md +3 -0
- package/claude/hooks/claude-hooks.md +30 -0
- package/claude/{.claude/hooks → hooks}/notification.sh +0 -0
- package/claude/{.claude/hooks → hooks}/protect-files.sh +0 -0
- package/claude/scripts/render-settings.ts +25 -9
- package/dist/claude/scripts/render-settings.js +22 -9
- package/dist/eslint/pasika/index.d.ts +2 -0
- package/dist/eslint/pasika/index.js +17 -0
- package/dist/eslint/pasika/rules/filename-case.d.ts +2 -0
- package/dist/eslint/pasika/rules/filename-case.js +90 -0
- package/dist/eslint/pasika/rules/organization-imports.d.ts +2 -0
- package/dist/eslint/pasika/rules/organization-imports.js +124 -0
- package/docs/agent-conventions.md +27 -0
- package/docs/code-organization-guide/code-organization-guide.md +69 -0
- package/docs/code-organization-guide/references/application-architecture-reference.md +149 -0
- package/docs/code-organization-guide/rules/component-placement-rule.md +119 -0
- package/docs/code-organization-guide/rules/configuration-rule.md +42 -0
- package/docs/code-organization-guide/rules/constants-rule.md +74 -0
- package/docs/code-organization-guide/rules/exports-and-imports-rule.md +84 -0
- package/docs/code-organization-guide/rules/folder-nesting-rule.md +82 -0
- package/docs/code-organization-guide/rules/hook-extraction-rule.md +141 -0
- package/docs/code-organization-guide/rules/interactive-component-rule.md +97 -0
- package/docs/code-organization-guide/rules/jsx-hygiene-rule.md +67 -0
- package/docs/code-organization-guide/rules/locales-rule.md +53 -0
- package/docs/code-organization-guide/rules/nameable-visual-concept-rule.md +66 -0
- package/docs/code-organization-guide/rules/no-mixed-concerns-rule.md +63 -0
- package/docs/code-organization-guide/rules/repeated-structure-rule.md +93 -0
- package/docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md +112 -0
- package/docs/code-organization-guide/rules/sole-state-owner-rule.md +101 -0
- package/docs/code-organization-guide/rules/types-and-schemas-rule.md +139 -0
- package/docs/code-organization-guide/rules/utilities-rule.md +86 -0
- package/docs/documentation-guide/_templates/grouped-reference.md +11 -0
- package/docs/documentation-guide/_templates/guide.md +19 -0
- package/docs/documentation-guide/_templates/rule.md +21 -0
- package/docs/documentation-guide/_templates/single-lookup-reference.md +5 -0
- package/docs/documentation-guide/documentation-guide.md +13 -0
- package/docs/documentation-guide/references/documentation-types-reference.md +9 -0
- package/docs/documentation-guide/rules/guide-creation-rule.md +113 -0
- package/docs/documentation-guide/rules/reference-creation-rule.md +132 -0
- package/docs/documentation-guide/rules/rule-creation-rule.md +81 -0
- package/docs/documentation-guide/rules/template-usage-rule.md +49 -0
- package/docs/shadcn-theme.md +121 -0
- package/docs/styling-guide/rules/arbitrary-value-rule.md +31 -0
- package/docs/styling-guide/rules/class-composition-rule.md +52 -0
- package/docs/styling-guide/rules/component-ui-state-rule.md +53 -0
- package/docs/styling-guide/rules/component-variant-rule.md +125 -0
- package/docs/styling-guide/rules/global-stylesheet-rule.md +67 -0
- package/docs/styling-guide/rules/theme-and-utility-definition-rule.md +86 -0
- package/docs/styling-guide/styling-guide.md +14 -0
- package/package.json +22 -12
- package/AGENTS.md +0 -15
- package/docs/common/merge-behavior.md +0 -17
- package/docs/common/overview.md +0 -20
- /package/{CLAUDE.md → claude/hooks/CLAUDE.md} +0 -0
- /package/claude/{.claude/hooks → hooks}/status-line/index.js +0 -0
- /package/claude/{.claude/settings.base.json → settings.base.json} +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Hook Extraction Rule
|
|
2
|
+
|
|
3
|
+
Keeping every hook inline makes components bloated, while extracting every hook adds indirection without benefit. This rule defines concrete reuse and imperative-complexity triggers for extraction.
|
|
4
|
+
|
|
5
|
+
- A custom hook MUST be extracted to its own file when two or more consumers use it.
|
|
6
|
+
- A custom hook with exactly one consumer MUST be extracted when it contains two or more imperative categories and can be described as one coherent behavior.
|
|
7
|
+
- An extracted custom hook MUST live in a `hooks/` folder at the closest common folder (CCF) of its consumers.
|
|
8
|
+
- When a custom hook's CCF is `src/features/`, it MUST move to `src/hooks/`.
|
|
9
|
+
- The imperative categories MUST be subscriptions, external I/O and persistence, DOM manipulation, or resource lifecycle.
|
|
10
|
+
- Each operation MUST count toward only one imperative category.
|
|
11
|
+
- Subscriptions MUST include event listeners and registration or cleanup APIs such as `on()` and `off()`.
|
|
12
|
+
- External I/O and persistence MUST include network requests, asynchronous reads or writes, and browser storage.
|
|
13
|
+
- DOM manipulation MUST include imperative APIs such as `focus()`, `classList`, observers, or imperative rendering.
|
|
14
|
+
- Resource lifecycle MUST include setup and teardown APIs such as `load()`, `destroy()`, or `dispose()`.
|
|
15
|
+
- A custom hook with one consumer that does not meet the two-category threshold MUST stay inline in its consumer file.
|
|
16
|
+
|
|
17
|
+
## Incorrect — Two Imperative Categories Left Inline
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
// src/features/player/player.tsx
|
|
21
|
+
export function Player({ src }: PlayerProps): React.JSX.Element {
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
player.on("play", handlePlay);
|
|
24
|
+
player.on("pause", handlePause);
|
|
25
|
+
player.load(src);
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
player.off("play", handlePlay);
|
|
29
|
+
player.off("pause", handlePause);
|
|
30
|
+
player.destroy();
|
|
31
|
+
};
|
|
32
|
+
}, [src]);
|
|
33
|
+
|
|
34
|
+
return <PlayerView />;
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Why: one coherent player-setup behavior combines subscriptions with resource lifecycle, so leaving it inline crosses the two-category threshold.
|
|
39
|
+
|
|
40
|
+
## Correct — Complex Single-Use Hook Extracted
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
// src/features/player/hooks/use-player-setup.ts
|
|
44
|
+
export function usePlayerSetup(src: string): void {
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
player.on("play", handlePlay);
|
|
47
|
+
player.on("pause", handlePause);
|
|
48
|
+
player.load(src);
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
player.off("play", handlePlay);
|
|
52
|
+
player.off("pause", handlePause);
|
|
53
|
+
player.destroy();
|
|
54
|
+
};
|
|
55
|
+
}, [src]);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// src/features/player/player.tsx
|
|
61
|
+
import { usePlayerSetup } from "./hooks/use-player-setup";
|
|
62
|
+
|
|
63
|
+
export function Player({ src }: PlayerProps): React.JSX.Element {
|
|
64
|
+
usePlayerSetup(src);
|
|
65
|
+
return <PlayerView />;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Why: the named hook owns the subscription and resource lifecycle for one coherent behavior, keeping the component focused on rendering.
|
|
70
|
+
|
|
71
|
+
## Incorrect — Reused Hook Kept Inline
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
// src/features/billing/invoice.tsx
|
|
75
|
+
const useInvoiceSort = (invoices: Invoice[]): Invoice[] => {
|
|
76
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/features/billing/invoice-summary.tsx
|
|
80
|
+
const useInvoiceSort = (invoices: Invoice[]): Invoice[] => {
|
|
81
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Why: two components use the same hook behavior, so keeping it inline duplicates it.
|
|
86
|
+
|
|
87
|
+
## Correct — Reused Hook Extracted
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// src/features/billing/hooks/use-invoice-sort.ts
|
|
91
|
+
export function useInvoiceSort(invoices: Invoice[]): Invoice[] {
|
|
92
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
// src/features/billing/invoice.tsx
|
|
98
|
+
import { useInvoiceSort } from "./hooks/use-invoice-sort";
|
|
99
|
+
|
|
100
|
+
// src/features/billing/invoice-summary.tsx
|
|
101
|
+
import { useInvoiceSort } from "./hooks/use-invoice-sort";
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Why: the hook has two consumers, so it has its own file.
|
|
105
|
+
|
|
106
|
+
## Incorrect — Simple Single-Use Hook Extracted
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// src/features/billing/hooks/use-invoice-sort.ts
|
|
110
|
+
export function useInvoiceSort(invoices: Invoice[]): Invoice[] {
|
|
111
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
// src/features/billing/invoice.tsx
|
|
117
|
+
import { useInvoiceSort } from "./hooks/use-invoice-sort";
|
|
118
|
+
|
|
119
|
+
export function Invoice({ invoices }: InvoiceProps): React.JSX.Element {
|
|
120
|
+
const sortedInvoices = useInvoiceSort(invoices);
|
|
121
|
+
return <InvoiceList invoices={sortedInvoices} />;
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Why: the hook has one consumer and no imperative category, so its separate file adds indirection before an extraction trigger exists.
|
|
126
|
+
|
|
127
|
+
## Correct — Simple Single-Use Hook Inline
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// src/features/billing/invoice.tsx
|
|
131
|
+
const useInvoiceSort = (invoices: Invoice[]): Invoice[] => {
|
|
132
|
+
return useMemo(() => invoices.toSorted(byDate), [invoices]);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function Invoice({ invoices }: InvoiceProps): React.JSX.Element {
|
|
136
|
+
const sortedInvoices = useInvoiceSort(invoices);
|
|
137
|
+
return <InvoiceList invoices={sortedInvoices} />;
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Why: the hook stays beside its sole consumer until reuse or imperative complexity provides a mechanical extraction trigger.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Interactive Component Rule
|
|
2
|
+
|
|
3
|
+
Large component files need a clear way to decide what to extract first. This rule treats interactive elements as meaningful component boundaries instead of extracting arbitrary layout elements.
|
|
4
|
+
|
|
5
|
+
- An [interactive HTML element](https://html.spec.whatwg.org/multipage/dom.html#interactive-content) MUST be extracted to a component with a descriptive name.
|
|
6
|
+
|
|
7
|
+
## Incorrect — Interactive Element Kept Inline
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// src/features/layout/header-section.tsx
|
|
11
|
+
import { locales } from "@/locales";
|
|
12
|
+
|
|
13
|
+
export function HeaderSection({
|
|
14
|
+
onMenuClick,
|
|
15
|
+
searchPlaceholder,
|
|
16
|
+
}: {
|
|
17
|
+
onMenuClick: () => void;
|
|
18
|
+
searchPlaceholder: string;
|
|
19
|
+
}): React.JSX.Element {
|
|
20
|
+
return (
|
|
21
|
+
<header>
|
|
22
|
+
<h1>{locales.layout.headerTitle}</h1>
|
|
23
|
+
|
|
24
|
+
<button onClick={onMenuClick} aria-label={locales.layout.openMenu}>
|
|
25
|
+
<Icon name="menu" />
|
|
26
|
+
</button>
|
|
27
|
+
|
|
28
|
+
<input type="search" placeholder={searchPlaceholder} />
|
|
29
|
+
</header>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Why: the interactive elements remain mixed into `HeaderSection` instead of having their own components.
|
|
35
|
+
|
|
36
|
+
## Correct — Interactive Element Extracted
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
src/features/layout/
|
|
40
|
+
header-section/
|
|
41
|
+
index.ts # re-exports only header-section.tsx
|
|
42
|
+
header-section.tsx
|
|
43
|
+
menu-button.tsx # exclusive child — imported directly
|
|
44
|
+
search-field.tsx # exclusive child — imported directly
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// src/features/layout/header-section/menu-button.tsx
|
|
49
|
+
type MenuButtonProps = React.ComponentProps<"button">;
|
|
50
|
+
|
|
51
|
+
export function MenuButton({ onClick, ...props }: MenuButtonProps): React.JSX.Element {
|
|
52
|
+
return (
|
|
53
|
+
<button {...props} onClick={onClick}>
|
|
54
|
+
<Icon name="menu" />
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// src/features/layout/header-section/search-field.tsx
|
|
62
|
+
type SearchFieldProps = Omit<React.ComponentProps<"input">, "type">;
|
|
63
|
+
|
|
64
|
+
export function SearchField(props: SearchFieldProps): React.JSX.Element {
|
|
65
|
+
return <input {...props} type="search" />;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// src/features/layout/header-section/header-section.tsx — now imports both interactive units instead of inline JSX
|
|
71
|
+
|
|
72
|
+
import { MenuButton } from "./menu-button";
|
|
73
|
+
import { SearchField } from "./search-field";
|
|
74
|
+
import { locales } from "@/locales";
|
|
75
|
+
|
|
76
|
+
type HeaderSectionProps = {
|
|
77
|
+
onMenuClick: () => void;
|
|
78
|
+
searchPlaceholder: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function HeaderSection({
|
|
82
|
+
onMenuClick,
|
|
83
|
+
searchPlaceholder,
|
|
84
|
+
}: HeaderSectionProps): React.JSX.Element {
|
|
85
|
+
return (
|
|
86
|
+
<header>
|
|
87
|
+
<h1>{locales.layout.headerTitle}</h1>
|
|
88
|
+
|
|
89
|
+
<MenuButton onClick={onMenuClick} aria-label={locales.layout.openMenu} />
|
|
90
|
+
|
|
91
|
+
<SearchField placeholder={searchPlaceholder} />
|
|
92
|
+
</header>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Why: each interactive element now has its own descriptive component, leaving `HeaderSection` to compose them.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# JSX Hygiene Rule
|
|
2
|
+
|
|
3
|
+
JSX should show the component's structure, not its calculations. This rule moves complex expressions before `return` while keeping simple JSX readable.
|
|
4
|
+
|
|
5
|
+
- Arithmetic, chained built-in method calls, calls to functions declared outside the component, nested ternaries, and conditions containing two or more logical operators MUST be extracted before `return`, including in JSX attributes.
|
|
6
|
+
- An inline expression MAY contain one condition with up to one logical operator, one ternary, or one built-in method call. `cn()` MAY be called inline. An event handler MAY make one call inline.
|
|
7
|
+
|
|
8
|
+
## Incorrect — Computation in JSX
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
return (
|
|
12
|
+
<div>
|
|
13
|
+
<span>{Math.floor((Date.now() - new Date(record.updatedAt).getTime()) / 86400000)} {locales.daysAgo}</span>
|
|
14
|
+
<ul>
|
|
15
|
+
{items
|
|
16
|
+
.filter((x) => x.active)
|
|
17
|
+
.sort(byDate)
|
|
18
|
+
.map(renderItem)}
|
|
19
|
+
</ul>
|
|
20
|
+
<p>
|
|
21
|
+
{formatDate(record.updatedAt, "long")} — {calculateTotal(items)}
|
|
22
|
+
</p>
|
|
23
|
+
{isLoading ? <Spinner /> : hasError ? <Error /> : <Content />}
|
|
24
|
+
{isLoggedIn && hasPermission && isOwner && featureEnabled && <AdminPanel />}
|
|
25
|
+
<span>{new Date(post.publishedAt).toLocaleDateString()}</span>
|
|
26
|
+
{Math.round(score * 100)}%
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Why: calculations, chained methods, function calls, nested ternaries, and long guards all belong outside the return.
|
|
32
|
+
|
|
33
|
+
## Correct — Computation Before `return`
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
const daysSinceUpdate = Math.floor((Date.now() - new Date(record.updatedAt).getTime()) / 86400000);
|
|
37
|
+
const activeItems = items.filter((x) => x.active).sort(byDate);
|
|
38
|
+
const updatedLabel = formatDate(record.updatedAt, "long");
|
|
39
|
+
const total = calculateTotal(items);
|
|
40
|
+
const canShowAdmin = isLoggedIn && hasPermission && isOwner && featureEnabled;
|
|
41
|
+
const publishDate = new Date(post.publishedAt).toLocaleDateString();
|
|
42
|
+
const scorePercent = Math.round(score * 100);
|
|
43
|
+
|
|
44
|
+
let statusView = <Content />;
|
|
45
|
+
if (isLoading) {
|
|
46
|
+
statusView = <Spinner />;
|
|
47
|
+
} else if (hasError) {
|
|
48
|
+
statusView = <Error />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
<span>{daysSinceUpdate} {locales.daysAgo}</span>
|
|
54
|
+
<ul>{activeItems.map(renderItem)}</ul>
|
|
55
|
+
<p>
|
|
56
|
+
{updatedLabel} — {total}
|
|
57
|
+
</p>
|
|
58
|
+
{statusView}
|
|
59
|
+
{scorePercent}%
|
|
60
|
+
<div className={cn("base", isActive && "bg-primary-100")} />
|
|
61
|
+
{canShowAdmin && <AdminPanel />}
|
|
62
|
+
<span>{publishDate}</span>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Why: calculations, chained methods, custom function calls, and nested ternaries now resolve before the return, so only simple conditions, single method calls, and `cn()` stay inline.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Locales Rule
|
|
2
|
+
|
|
3
|
+
When locale strings are scattered across components and constants, they are hard to find and keep consistent. This rule keeps them in one central file, puts each feature's strings together, and uses readable keys.
|
|
4
|
+
|
|
5
|
+
- All locales MUST live in the named `locales` object exported from `src/locales/index.ts`.
|
|
6
|
+
- Locales read only by files in one feature folder MUST live in an object with the camelCase form of its feature folder name (for example, `user-settings` becomes `userSettings`).
|
|
7
|
+
- Locales read by files in more than one feature folder or by `src/shared/`, `src/compositions/`, `src/app/`, or root support folders MUST live at the top level of `locales`.
|
|
8
|
+
- A namespaced locale MUST be read through its full dotted path (`locales.stream.watchLiveStream`).
|
|
9
|
+
- A locale key MUST be camelCase English based on the text, unless a direct translation would be unclear or unwieldy. In that case, it MAY describe the message's purpose instead.
|
|
10
|
+
|
|
11
|
+
## Incorrect — Flat Feature Locale Keys
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// src/locales/index.ts
|
|
15
|
+
export const locales = {
|
|
16
|
+
streamPlayerLiveText: "Дивитись прямий ефір", // describes the element, not the text
|
|
17
|
+
ctaButton: "Прийняти всі cookies", // describes the element, not the text
|
|
18
|
+
youSuccessfullySubscribedToUpdates: "Ви успішно підписалися на оновлення", // direct translation — unwieldy
|
|
19
|
+
};
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
// src/features/stream/stream-player.tsx
|
|
24
|
+
locales.streamPlayerLiveText; // no namespace — collides with any other feature that picks this key
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Why: the first two keys describe elements instead of text, the direct translation is unwieldy, and the flat structure does not show that `streamPlayerLiveText` belongs to the stream feature.
|
|
28
|
+
|
|
29
|
+
## Correct — Namespaced Feature Locale Keys
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/locales/index.ts
|
|
33
|
+
export const locales = {
|
|
34
|
+
stream: {
|
|
35
|
+
watchLiveStream: "Дивитись прямий ефір",
|
|
36
|
+
},
|
|
37
|
+
acceptAllCookies: "Прийняти всі cookies", // shared — direct text-based key
|
|
38
|
+
subscriptionConfirmed: "Ви успішно підписалися на оновлення", // shared — describes the message's purpose
|
|
39
|
+
};
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
// src/features/stream/stream-player.tsx
|
|
44
|
+
locales.stream.watchLiveStream; // namespaced by feature
|
|
45
|
+
|
|
46
|
+
// src/shared/cookie-banner.tsx
|
|
47
|
+
locales.acceptAllCookies; // shared — read from the top level
|
|
48
|
+
|
|
49
|
+
// src/shared/subscription-form.tsx
|
|
50
|
+
locales.subscriptionConfirmed; // shared — read from the top level
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Why: the stream feature has its own namespace, while shared strings stay at the top level. `acceptAllCookies` is based on the text, while `subscriptionConfirmed` describes the message's purpose instead of using an unwieldy direct translation.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Nameable Visual Concept Rule
|
|
2
|
+
|
|
3
|
+
Some groups of elements can be given a clear component name but have no file of their own. This rule recommends extracting those groups into descriptive components.
|
|
4
|
+
|
|
5
|
+
- A block of elements SHOULD be extracted to a component when one clear name describes the whole block.
|
|
6
|
+
|
|
7
|
+
## Incorrect — Nameable Visual Block Kept Inline
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// src/features/feed/feed-view.tsx
|
|
11
|
+
export function FeedView(): React.JSX.Element {
|
|
12
|
+
return (
|
|
13
|
+
<main>
|
|
14
|
+
<article>
|
|
15
|
+
<Avatar />
|
|
16
|
+
<UserName />
|
|
17
|
+
<PostTimestamp />
|
|
18
|
+
<PostBody />
|
|
19
|
+
</article>
|
|
20
|
+
</main>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Why: the avatar, username, and timestamp form a message header but remain inline in `FeedView`.
|
|
26
|
+
|
|
27
|
+
## Correct — Nameable Visual Block Extracted
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
src/features/feed/
|
|
31
|
+
feed-view/
|
|
32
|
+
index.ts # re-exports only feed-view.tsx
|
|
33
|
+
feed-view.tsx
|
|
34
|
+
message-header.tsx # exclusive child — imported directly by feed-view.tsx
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// src/features/feed/feed-view/feed-view.tsx
|
|
39
|
+
import { MessageHeader } from "./message-header";
|
|
40
|
+
|
|
41
|
+
export function FeedView(): React.JSX.Element {
|
|
42
|
+
return (
|
|
43
|
+
<main>
|
|
44
|
+
<article>
|
|
45
|
+
<MessageHeader />
|
|
46
|
+
<PostBody />
|
|
47
|
+
</article>
|
|
48
|
+
</main>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
// src/features/feed/feed-view/message-header.tsx
|
|
55
|
+
export function MessageHeader(): React.JSX.Element {
|
|
56
|
+
return (
|
|
57
|
+
<header>
|
|
58
|
+
<Avatar />
|
|
59
|
+
<UserName />
|
|
60
|
+
<PostTimestamp />
|
|
61
|
+
</header>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Why: `MessageHeader` gives the group a clear name and its own file, while `FeedView` only composes it.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# No Mixed Concerns Rule
|
|
2
|
+
|
|
3
|
+
One component per file keeps components easy to find and change independently. This rule applies the same requirement to copied and generated source.
|
|
4
|
+
|
|
5
|
+
- A `.tsx` file that defines a component MUST contain exactly one component.
|
|
6
|
+
|
|
7
|
+
## Incorrect — Two Components in One File
|
|
8
|
+
|
|
9
|
+
Two components in one file:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
// src/features/nav/menu.tsx
|
|
13
|
+
export function Menu(): React.JSX.Element {
|
|
14
|
+
return (
|
|
15
|
+
<nav>
|
|
16
|
+
<MenuItem label="Home" />
|
|
17
|
+
<MenuItem label="About" />
|
|
18
|
+
</nav>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MenuItem({ label }: { label: string }): React.JSX.Element {
|
|
23
|
+
return <a href="#">{label}</a>;
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Why: two components share `menu.tsx`, so a reviewer searching for `MenuItem` cannot find it in the file tree.
|
|
28
|
+
|
|
29
|
+
## Correct — Extracted Child in Its Own File
|
|
30
|
+
|
|
31
|
+
One component per file, with the extracted child nested under its owner:
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
src/features/nav/
|
|
35
|
+
menu/
|
|
36
|
+
index.ts # re-exports only menu.tsx
|
|
37
|
+
menu.tsx
|
|
38
|
+
menu-item.tsx # exclusive child — imported directly by menu.tsx
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
// src/features/nav/menu/menu-item.tsx
|
|
43
|
+
export function MenuItem({ label }: { label: string }): React.JSX.Element {
|
|
44
|
+
return <a href="#">{label}</a>;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// src/features/nav/menu/menu.tsx
|
|
50
|
+
import { MenuItem } from "./menu-item";
|
|
51
|
+
import { locales } from "@/locales";
|
|
52
|
+
|
|
53
|
+
export function Menu(): React.JSX.Element {
|
|
54
|
+
return (
|
|
55
|
+
<nav>
|
|
56
|
+
<MenuItem label={locales.nav.home} />
|
|
57
|
+
<MenuItem label={locales.nav.about} />
|
|
58
|
+
</nav>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Why: `Menu` and `MenuItem` each have their own file, so both are independently searchable.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Repeated Structure Rule
|
|
2
|
+
|
|
3
|
+
Repeated markup can drift when one copy changes and another does not. This rule makes repeated structure a clear extraction trigger.
|
|
4
|
+
|
|
5
|
+
- A block of elements MUST be extracted as a named component when two or more places use the same arrangement of elements for the same purpose. Different data or labels do not prevent extraction.
|
|
6
|
+
|
|
7
|
+
## Incorrect — Repeated Structure Kept Inline
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
// src/features/dashboard/dashboard-view.tsx
|
|
11
|
+
import { locales } from "@/locales";
|
|
12
|
+
|
|
13
|
+
export function DashboardView({ stats, activity }: DashboardViewProps): React.JSX.Element {
|
|
14
|
+
return (
|
|
15
|
+
<main>
|
|
16
|
+
<section className="w-full rounded border p-6">
|
|
17
|
+
<h2>{locales.dashboard.stats}</h2>
|
|
18
|
+
<ul>
|
|
19
|
+
{stats.map((stat) => (
|
|
20
|
+
<li key={stat.id}>
|
|
21
|
+
{stat.label}: {stat.value}
|
|
22
|
+
</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<section className="w-full rounded border p-6">
|
|
28
|
+
<h2>{locales.dashboard.recentActivity}</h2>
|
|
29
|
+
<ul>
|
|
30
|
+
{activity.map((event) => (
|
|
31
|
+
<li key={event.id}>
|
|
32
|
+
{event.label}: {event.value}
|
|
33
|
+
</li>
|
|
34
|
+
))}
|
|
35
|
+
</ul>
|
|
36
|
+
</section>
|
|
37
|
+
</main>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Why: the same section, heading, and list structure appears in two places. Changing the shared frame requires editing both copies.
|
|
43
|
+
|
|
44
|
+
## Correct — Repeated Structure Extracted
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
src/features/dashboard/
|
|
48
|
+
dashboard-view/
|
|
49
|
+
index.ts # re-exports only dashboard-view.tsx
|
|
50
|
+
dashboard-view.tsx
|
|
51
|
+
panel.tsx # exclusive child — imported directly
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
// src/features/dashboard/dashboard-view/panel.tsx
|
|
56
|
+
type PanelItem = {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
value: string | number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function Panel({ title, items }: { title: string; items: PanelItem[] }): React.JSX.Element {
|
|
63
|
+
return (
|
|
64
|
+
<section className="w-full rounded border p-6">
|
|
65
|
+
<h2>{title}</h2>
|
|
66
|
+
<ul>
|
|
67
|
+
{items.map((item) => (
|
|
68
|
+
<li key={item.id}>
|
|
69
|
+
{item.label}: {item.value}
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// src/features/dashboard/dashboard-view/dashboard-view.tsx
|
|
80
|
+
import { Panel } from "./panel";
|
|
81
|
+
import { locales } from "@/locales";
|
|
82
|
+
|
|
83
|
+
export function DashboardView({ stats, activity }: DashboardViewProps): React.JSX.Element {
|
|
84
|
+
return (
|
|
85
|
+
<main>
|
|
86
|
+
<Panel title={locales.dashboard.stats} items={stats} />
|
|
87
|
+
<Panel title={locales.dashboard.recentActivity} items={activity} />
|
|
88
|
+
</main>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Why: `Panel` owns the shared section, heading, and list structure, while each call site supplies only its title and items.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Smart vs Dumb Component Rule
|
|
2
|
+
|
|
3
|
+
Without a file-name convention, a component's smart vs dumb ownership is invisible to reviewers from the tree alone. Without a `data-testid` matching the file's casing, tests hardcode DOM identities that break on rename or restructure.
|
|
4
|
+
|
|
5
|
+
- A smart component MUST fetch data, or define `handle*` callbacks and pass them to children as `on*` props.
|
|
6
|
+
- A dumb component MUST NOT fetch data or define `handle*` callbacks for children.
|
|
7
|
+
- A smart component file name MUST be `PascalCase.tsx`.
|
|
8
|
+
- A dumb component file name MUST be `kebab-case.tsx`.
|
|
9
|
+
- A smart component with one outer DOM element in every rendered result MUST set `data-testid` on that element, and its value MUST match the component name in `PascalCase`.
|
|
10
|
+
- A smart component without one outer DOM element in every rendered result MAY omit `data-testid`.
|
|
11
|
+
- A dumb component MAY set `data-testid` on its root element, and the value MUST be `kebab-case`.
|
|
12
|
+
- [Next.js App Router routing files](https://nextjs.org/docs/app/getting-started/project-structure#routing-files) MUST use their required kebab-case names and are exempt from smart/dumb file-name and `data-testid` requirements.
|
|
13
|
+
|
|
14
|
+
## Incorrect — Smart Component Uses a Dumb Name
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
// src/features/social/social-stats-panel.tsx
|
|
18
|
+
"use client";
|
|
19
|
+
|
|
20
|
+
import { useSocialStats } from "./hooks/use-social-stats";
|
|
21
|
+
import { PlatformCard } from "./platform-card";
|
|
22
|
+
import { locales } from "@/locales";
|
|
23
|
+
|
|
24
|
+
export function SocialStatsPanel(): React.JSX.Element {
|
|
25
|
+
const { stats, isLoading } = useSocialStats();
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div data-testid="social-stats-panel">
|
|
29
|
+
{isLoading ? (
|
|
30
|
+
<p>{locales.social.loading}</p>
|
|
31
|
+
) : (
|
|
32
|
+
stats.map((stat) => <PlatformCard key={stat.platform} data={stat} />)
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Why: fetching data makes the component smart, but its file name and `data-testid` use dumb-component casing.
|
|
40
|
+
|
|
41
|
+
## Correct — Smart Component Uses a Smart Name
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// src/features/social/SocialStatsPanel.tsx
|
|
45
|
+
"use client";
|
|
46
|
+
|
|
47
|
+
import { useSocialStats } from "./hooks/use-social-stats";
|
|
48
|
+
import { PlatformCard } from "./platform-card";
|
|
49
|
+
import { locales } from "@/locales";
|
|
50
|
+
|
|
51
|
+
export function SocialStatsPanel(): React.JSX.Element {
|
|
52
|
+
const { stats, isLoading } = useSocialStats();
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div data-testid="SocialStatsPanel">
|
|
56
|
+
{isLoading ? (
|
|
57
|
+
<p>{locales.social.loading}</p>
|
|
58
|
+
) : (
|
|
59
|
+
stats.map((stat) => <PlatformCard key={stat.platform} data={stat} />)
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Why: fetching data makes the component smart, so its file name and `data-testid` use `PascalCase`.
|
|
67
|
+
|
|
68
|
+
## Incorrect — Dumb Component Uses a Smart Name
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
// src/features/social/PlatformCard.tsx
|
|
72
|
+
|
|
73
|
+
export function PlatformCard({
|
|
74
|
+
data,
|
|
75
|
+
onFollowClick,
|
|
76
|
+
}: {
|
|
77
|
+
data: PlatformStat;
|
|
78
|
+
onFollowClick: () => void;
|
|
79
|
+
}): React.JSX.Element {
|
|
80
|
+
return (
|
|
81
|
+
<article data-testid="PlatformCard">
|
|
82
|
+
<h3>{data.platform}</h3>
|
|
83
|
+
<FollowButton onFollowClick={onFollowClick} />
|
|
84
|
+
</article>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Why: the component fetches no data and only receives a child callback, so it is dumb. Its file name and `data-testid` use smart-component casing.
|
|
90
|
+
|
|
91
|
+
## Correct — Dumb Component Uses a Dumb Name
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// src/features/social/platform-card.tsx
|
|
95
|
+
|
|
96
|
+
export function PlatformCard({
|
|
97
|
+
data,
|
|
98
|
+
onFollowClick,
|
|
99
|
+
}: {
|
|
100
|
+
data: PlatformStat;
|
|
101
|
+
onFollowClick: () => void;
|
|
102
|
+
}): React.JSX.Element {
|
|
103
|
+
return (
|
|
104
|
+
<article data-testid="platform-card">
|
|
105
|
+
<h3>{data.platform}</h3>
|
|
106
|
+
<FollowButton onFollowClick={onFollowClick} />
|
|
107
|
+
</article>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Why: the component is dumb, so its file name and `data-testid` use `kebab-case`.
|