pasika 0.1.0 → 0.1.4

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 (59) hide show
  1. package/README.md +31 -25
  2. package/claude/hooks/.vulyk +3 -0
  3. package/claude/hooks/AGENTS.md +3 -0
  4. package/claude/hooks/claude-hooks.md +30 -0
  5. package/claude/{.claude/hooks → hooks}/notification.sh +0 -0
  6. package/claude/{.claude/hooks → hooks}/protect-files.sh +0 -0
  7. package/claude/scripts/render-settings.ts +25 -9
  8. package/dist/claude/scripts/render-settings.js +22 -9
  9. package/dist/eslint/pasika/index.d.ts +2 -0
  10. package/dist/eslint/pasika/index.js +14 -0
  11. package/dist/eslint/pasika/rules/organization-imports.d.ts +2 -0
  12. package/dist/eslint/pasika/rules/organization-imports.js +124 -0
  13. package/docs/agent-conventions.md +21 -0
  14. package/docs/code-organization-guide/code-organization-guide.md +65 -0
  15. package/docs/code-organization-guide/references/application-architecture-reference.md +107 -0
  16. package/docs/code-organization-guide/rules/component-placement-rule.md +135 -0
  17. package/docs/code-organization-guide/rules/configuration-rule.md +42 -0
  18. package/docs/code-organization-guide/rules/constants-rule.md +77 -0
  19. package/docs/code-organization-guide/rules/exports-and-imports-rule.md +81 -0
  20. package/docs/code-organization-guide/rules/folder-nesting-rule.md +83 -0
  21. package/docs/code-organization-guide/rules/hook-extraction-rule.md +142 -0
  22. package/docs/code-organization-guide/rules/interactive-component-rule.md +100 -0
  23. package/docs/code-organization-guide/rules/jsx-hygiene-rule.md +68 -0
  24. package/docs/code-organization-guide/rules/locales-rule.md +55 -0
  25. package/docs/code-organization-guide/rules/nameable-visual-concept-rule.md +66 -0
  26. package/docs/code-organization-guide/rules/native-prop-forwarding-rule.md +38 -0
  27. package/docs/code-organization-guide/rules/no-mixed-concerns-rule.md +64 -0
  28. package/docs/code-organization-guide/rules/repeated-structure-rule.md +93 -0
  29. package/docs/code-organization-guide/rules/smart-vs-dumb-component-rule.md +116 -0
  30. package/docs/code-organization-guide/rules/sole-state-owner-rule.md +102 -0
  31. package/docs/code-organization-guide/rules/types-and-schemas-rule.md +138 -0
  32. package/docs/code-organization-guide/rules/utilities-rule.md +87 -0
  33. package/docs/documentation-guide/_templates/guide.md +19 -0
  34. package/docs/documentation-guide/_templates/reference.md +17 -0
  35. package/docs/documentation-guide/_templates/rule.md +21 -0
  36. package/docs/documentation-guide/documentation-guide.md +13 -0
  37. package/docs/documentation-guide/references/documentation-types-reference.md +9 -0
  38. package/docs/documentation-guide/rules/guide-creation-rule.md +112 -0
  39. package/docs/documentation-guide/rules/reference-creation-rule.md +132 -0
  40. package/docs/documentation-guide/rules/rule-creation-rule.md +81 -0
  41. package/docs/documentation-guide/rules/template-usage-rule.md +48 -0
  42. package/docs/shadcn-theme.md +121 -0
  43. package/docs/styling-guide/rules/class-composition-rule.md +32 -0
  44. package/docs/styling-guide/rules/color-role-naming-rule.md +115 -0
  45. package/docs/styling-guide/rules/component-state-rule.md +26 -0
  46. package/docs/styling-guide/rules/component-variant-rule.md +128 -0
  47. package/docs/styling-guide/rules/global-style-system-rule.md +63 -0
  48. package/docs/styling-guide/rules/style-placement-rule.md +49 -0
  49. package/docs/styling-guide/rules/tailwind-utility-rule.md +48 -0
  50. package/docs/styling-guide/rules/theme-and-utility-definition-rule.md +129 -0
  51. package/docs/styling-guide/rules/theme-token-rule.md +32 -0
  52. package/docs/styling-guide/styling-guide.md +17 -0
  53. package/package.json +24 -10
  54. package/AGENTS.md +0 -15
  55. package/docs/common/merge-behavior.md +0 -17
  56. package/docs/common/overview.md +0 -20
  57. /package/{CLAUDE.md → claude/hooks/CLAUDE.md} +0 -0
  58. /package/claude/{.claude/hooks → hooks}/status-line/index.js +0 -0
  59. /package/claude/{.claude/settings.base.json → settings.base.json} +0 -0
@@ -0,0 +1,68 @@
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 at most one logical operator, one ternary, or one built-in method call. `cn()` is an explicit exception to the external-function-call restriction. Calls inside an event-handler callback are not JSX expressions.
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
+ const daysAgoLabel = locales.daysAgo;
44
+
45
+ let statusView = <Content />;
46
+ if (isLoading) {
47
+ statusView = <Spinner />;
48
+ } else if (hasError) {
49
+ statusView = <Error />;
50
+ }
51
+
52
+ return (
53
+ <div>
54
+ <span>{daysSinceUpdate} {daysAgoLabel}</span>
55
+ <ul>{activeItems.map(renderItem)}</ul>
56
+ <p>
57
+ {updatedLabel} — {total}
58
+ </p>
59
+ {statusView}
60
+ {scorePercent}%
61
+ <div className={cn("base", isActive && "bg-primary-100")} />
62
+ {canShowAdmin && <AdminPanel />}
63
+ <span>{publishDate}</span>
64
+ </div>
65
+ );
66
+ ```
67
+
68
+ 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,55 @@
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
+ - A locale consumer is a module that reads the locale.
7
+ - A new locale with no consumers MUST use a feature namespace when it is for one feature; otherwise it MUST live at the top level of `locales`.
8
+ - Locales read only by modules in one feature folder MUST live in an object with the camelCase form of its feature folder name (for example, `user-settings` becomes `userSettings`).
9
+ - Locales read by modules 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`.
10
+ - A namespaced locale MUST be read through its full dotted path (`locales.stream.watchLiveStream`).
11
+ - 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.
12
+
13
+ ## Incorrect — Flat Feature Locale Keys
14
+
15
+ ```ts
16
+ // src/locales/index.ts
17
+ export const locales = {
18
+ streamPlayerLiveText: "Дивитись прямий ефір", // describes the element, not the text
19
+ ctaButton: "Прийняти всі cookies", // describes the element, not the text
20
+ youSuccessfullySubscribedToUpdates: "Ви успішно підписалися на оновлення", // direct translation — unwieldy
21
+ };
22
+ ```
23
+
24
+ ```tsx
25
+ // src/features/stream/stream-player.tsx
26
+ locales.streamPlayerLiveText; // no namespace — collides with any other feature that picks this key
27
+ ```
28
+
29
+ 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.
30
+
31
+ ## Correct — Namespaced Feature Locale Keys
32
+
33
+ ```ts
34
+ // src/locales/index.ts
35
+ export const locales = {
36
+ stream: {
37
+ watchLiveStream: "Дивитись прямий ефір",
38
+ },
39
+ acceptAllCookies: "Прийняти всі cookies", // shared — direct text-based key
40
+ subscriptionConfirmed: "Ви успішно підписалися на оновлення", // shared — describes the message's purpose
41
+ };
42
+ ```
43
+
44
+ ```tsx
45
+ // src/features/stream/stream-player.tsx
46
+ locales.stream.watchLiveStream; // namespaced by feature
47
+
48
+ // src/shared/cookie-banner.tsx
49
+ locales.acceptAllCookies; // shared — read from the top level
50
+
51
+ // src/shared/subscription-form.tsx
52
+ locales.subscriptionConfirmed; // shared — read from the top level
53
+ ```
54
+
55
+ 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,38 @@
1
+ # Native Prop Forwarding Rule
2
+
3
+ When a `src/shared/` component renders the same intrinsic root element in every rendered result, a smaller custom prop interface can hide capabilities that consumers need. This rule preserves native props while allowing focused component-specific props.
4
+
5
+ - A `src/shared/` component with the same intrinsic root element in every rendered result MUST include that element's native props in its props type.
6
+ - A `src/shared/` component with the same intrinsic root element in every rendered result MUST forward unconsumed native props to that element.
7
+ - A `src/shared/` component with the same intrinsic root element in every rendered result MAY add component-specific props alongside its native props.
8
+
9
+ ## Incorrect — Native Props Replaced
10
+
11
+ ```tsx
12
+ // src/shared/button.tsx
13
+ type ButtonProps = {
14
+ label: string;
15
+ onClick(): void;
16
+ };
17
+
18
+ export function Button({ label, onClick }: ButtonProps) {
19
+ return <button onClick={onClick}>{label}</button>;
20
+ }
21
+ ```
22
+
23
+ Why: the custom interface leaves out native button props such as `disabled`, `type`, and accessible labels.
24
+
25
+ ## Correct — Native Props Extended and Forwarded
26
+
27
+ ```tsx
28
+ // src/shared/button.tsx
29
+ type ButtonProps = React.ComponentProps<"button"> & {
30
+ loading?: boolean;
31
+ };
32
+
33
+ export function Button({ className, loading, disabled, ...props }: ButtonProps) {
34
+ return <button className={cn("inline-flex items-center", className)} disabled={disabled || loading} {...props} />;
35
+ }
36
+ ```
37
+
38
+ Why: the component forwards its unused native props while adding the `loading` state it needs.
@@ -0,0 +1,64 @@
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
+ - A non-component module MUST use `.ts`, except when a framework requires a `.tsx` routing file.
7
+
8
+ ## Incorrect — Two Components in One File
9
+
10
+ Two components in one file:
11
+
12
+ ```tsx
13
+ // src/features/nav/menu.tsx
14
+ export function Menu(): React.JSX.Element {
15
+ return (
16
+ <nav>
17
+ <MenuItem label="Home" />
18
+ <MenuItem label="About" />
19
+ </nav>
20
+ );
21
+ }
22
+
23
+ export function MenuItem({ label }: { label: string }): React.JSX.Element {
24
+ return <a href="#">{label}</a>;
25
+ }
26
+ ```
27
+
28
+ Why: two components share `menu.tsx`, so a reviewer searching for `MenuItem` cannot find it in the file tree.
29
+
30
+ ## Correct — Extracted Child in Its Own File
31
+
32
+ One component per file, with the extracted child nested under its owner:
33
+
34
+ ```text
35
+ src/features/nav/
36
+ menu/
37
+ index.ts # re-exports only menu.tsx
38
+ menu.tsx
39
+ menu-item.tsx # exclusive child — imported directly by menu.tsx
40
+ ```
41
+
42
+ ```tsx
43
+ // src/features/nav/menu/menu-item.tsx
44
+ export function MenuItem({ label }: { label: string }): React.JSX.Element {
45
+ return <a href="#">{label}</a>;
46
+ }
47
+ ```
48
+
49
+ ```tsx
50
+ // src/features/nav/menu/menu.tsx
51
+ import { MenuItem } from "./menu-item";
52
+ import { locales } from "@/locales";
53
+
54
+ export function Menu(): React.JSX.Element {
55
+ return (
56
+ <nav>
57
+ <MenuItem label={locales.nav.home} />
58
+ <MenuItem label={locales.nav.about} />
59
+ </nav>
60
+ );
61
+ }
62
+ ```
63
+
64
+ 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 the same structural frame and semantic purpose appear in two or more places. 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,116 @@
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
+ - Remote or shared application data or state is data from a server, context, store, URL, or server action. A component MUST be smart when it fetches or mutates that data, performs an effect, or defines a callback that changes it, including through a hook or service.
6
+ - A component is considered to perform these operations when it calls a custom hook that performs them.
7
+ - In this rule, an effect MUST be a subscription, external I/O, DOM manipulation, or resource lifecycle.
8
+ - Local UI state and callbacks that only update it MUST NOT make a component smart, including when passed to another component or directly to a native element.
9
+ - A dumb component MUST NOT do any of these.
10
+ - A component that does none of these MUST be dumb.
11
+ - A smart component file name MUST be `PascalCase.tsx`.
12
+ - A dumb component file name MUST be `kebab-case.tsx`.
13
+ - 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`.
14
+ - A smart component without one outer DOM element in every rendered result MAY omit `data-testid`.
15
+ - A dumb component MAY set `data-testid` on its root element, and the value MUST be `kebab-case`.
16
+ - [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.
17
+
18
+ ## Incorrect — Smart Component Uses a Dumb Name
19
+
20
+ ```tsx
21
+ // src/features/social/social-stats-panel.tsx
22
+ "use client";
23
+
24
+ import { useSocialStats } from "./hooks/use-social-stats";
25
+ import { PlatformCard } from "./platform-card";
26
+ import { locales } from "@/locales";
27
+
28
+ export function SocialStatsPanel(): React.JSX.Element {
29
+ const { stats, isLoading } = useSocialStats();
30
+
31
+ return (
32
+ <div data-testid="social-stats-panel">
33
+ {isLoading ? (
34
+ <p>{locales.social.loading}</p>
35
+ ) : (
36
+ stats.map((stat) => <PlatformCard key={stat.platform} data={stat} />)
37
+ )}
38
+ </div>
39
+ );
40
+ }
41
+ ```
42
+
43
+ Why: fetching data makes the component smart, but its file name and `data-testid` use dumb-component casing.
44
+
45
+ ## Correct — Smart Component Uses a Smart Name
46
+
47
+ ```tsx
48
+ // src/features/social/SocialStatsPanel.tsx
49
+ "use client";
50
+
51
+ import { useSocialStats } from "./hooks/use-social-stats";
52
+ import { PlatformCard } from "./platform-card";
53
+ import { locales } from "@/locales";
54
+
55
+ export function SocialStatsPanel(): React.JSX.Element {
56
+ const { stats, isLoading } = useSocialStats();
57
+
58
+ return (
59
+ <div data-testid="SocialStatsPanel">
60
+ {isLoading ? (
61
+ <p>{locales.social.loading}</p>
62
+ ) : (
63
+ stats.map((stat) => <PlatformCard key={stat.platform} data={stat} />)
64
+ )}
65
+ </div>
66
+ );
67
+ }
68
+ ```
69
+
70
+ Why: fetching data makes the component smart, so its file name and `data-testid` use `PascalCase`.
71
+
72
+ ## Incorrect — Dumb Component Uses a Smart Name
73
+
74
+ ```tsx
75
+ // src/features/social/PlatformCard.tsx
76
+
77
+ export function PlatformCard({
78
+ data,
79
+ onFollowClick,
80
+ }: {
81
+ data: PlatformStat;
82
+ onFollowClick: () => void;
83
+ }): React.JSX.Element {
84
+ return (
85
+ <article data-testid="PlatformCard">
86
+ <h3>{data.platform}</h3>
87
+ <FollowButton onFollowClick={onFollowClick} />
88
+ </article>
89
+ );
90
+ }
91
+ ```
92
+
93
+ 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.
94
+
95
+ ## Correct — Dumb Component Uses a Dumb Name
96
+
97
+ ```tsx
98
+ // src/features/social/platform-card.tsx
99
+
100
+ export function PlatformCard({
101
+ data,
102
+ onFollowClick,
103
+ }: {
104
+ data: PlatformStat;
105
+ onFollowClick: () => void;
106
+ }): React.JSX.Element {
107
+ return (
108
+ <article data-testid="platform-card">
109
+ <h3>{data.platform}</h3>
110
+ <FollowButton onFollowClick={onFollowClick} />
111
+ </article>
112
+ );
113
+ }
114
+ ```
115
+
116
+ Why: the component is dumb, so its file name and `data-testid` use `kebab-case`.
@@ -0,0 +1,102 @@
1
+ # Sole State Owner Rule
2
+
3
+ Some blocks of elements are the only consumers of a state hook. This rule extracts those blocks into components that own the hook.
4
+
5
+ - A state hook's consumers are the JSX, callbacks, and effects that read its state value or call its updater.
6
+ - A proper sub-block of a component's output MUST be extracted when it contains every consumer of one state hook and one clear component name describes it. A consumer declared before `return` belongs to a sub-block only when it is used exclusively by that sub-block; otherwise this rule does not require extraction. The component that owns the state is not itself an extraction trigger.
7
+
8
+ ## Incorrect — Parent Keeps Child-Only State
9
+
10
+ ```tsx
11
+ // src/compositions/dashboard-view.tsx
12
+ import { locales } from "@/locales";
13
+
14
+ export function DashboardView(): React.JSX.Element {
15
+ const [isHelpOpen, setIsHelpOpen] = useState(false);
16
+
17
+ return (
18
+ <div>
19
+ <h1>{locales.dashboard}</h1>
20
+ <button onClick={() => setIsHelpOpen(true)} type="button">
21
+ {locales.help}
22
+ </button>
23
+ {isHelpOpen && (
24
+ <Modal onClose={() => setIsHelpOpen(false)}>
25
+ <HelpContent />
26
+ </Modal>
27
+ )}
28
+ <DashboardContent />
29
+ </div>
30
+ );
31
+ }
32
+ ```
33
+
34
+ Why: the help button and modal are the only users of `isHelpOpen`, but the state lives in `DashboardView`.
35
+
36
+ ## Correct — Child Owns Its State
37
+
38
+ ```text
39
+ src/compositions/
40
+ dashboard-view/
41
+ index.ts # re-exports only dashboard-view.tsx
42
+ dashboard-view.tsx
43
+ help-dialog/
44
+ index.ts # re-exports only help-dialog.tsx
45
+ help-dialog.tsx
46
+ help-trigger.tsx # exclusive child — imported directly
47
+ ```
48
+
49
+ ```tsx
50
+ // src/compositions/dashboard-view/help-dialog/help-dialog.tsx
51
+ import { HelpTrigger } from "./help-trigger";
52
+
53
+ export function HelpDialog(): React.JSX.Element {
54
+ const [isHelpOpen, setIsHelpOpen] = useState(false);
55
+
56
+ return (
57
+ <>
58
+ <HelpTrigger onOpen={() => setIsHelpOpen(true)} />
59
+ {isHelpOpen && (
60
+ <Modal onClose={() => setIsHelpOpen(false)}>
61
+ <HelpContent />
62
+ </Modal>
63
+ )}
64
+ </>
65
+ );
66
+ }
67
+ ```
68
+
69
+ ```tsx
70
+ // src/compositions/dashboard-view/help-dialog/help-trigger.tsx
71
+ import { locales } from "@/locales";
72
+
73
+ type HelpTriggerProps = {
74
+ onOpen: () => void;
75
+ };
76
+
77
+ export function HelpTrigger({ onOpen }: HelpTriggerProps): React.JSX.Element {
78
+ return (
79
+ <button onClick={onOpen} type="button">
80
+ {locales.help}
81
+ </button>
82
+ );
83
+ }
84
+ ```
85
+
86
+ ```tsx
87
+ // src/compositions/dashboard-view/dashboard-view.tsx
88
+ import { HelpDialog } from "./help-dialog";
89
+ import { locales } from "@/locales";
90
+
91
+ export function DashboardView(): React.JSX.Element {
92
+ return (
93
+ <div>
94
+ <h1>{locales.dashboard}</h1>
95
+ <HelpDialog />
96
+ <DashboardContent />
97
+ </div>
98
+ );
99
+ }
100
+ ```
101
+
102
+ Why: `HelpDialog` owns the help button, modal, and `isHelpOpen` state. `DashboardView` only composes it.