compound-workflow 1.8.0 → 1.9.1
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 +62 -68
- package/package.json +2 -8
- package/scripts/check-pack-readme.mjs +1 -16
- package/scripts/check-workflow-contracts.mjs +34 -44
- package/scripts/install-cli.mjs +273 -555
- package/src/AGENTS.md +59 -192
- package/src/{.agents/agents → agents}/research/best-practices-researcher.md +2 -2
- package/src/{.agents/commands → commands}/assess.md +4 -4
- package/src/commands/install.md +43 -0
- package/src/{.agents/commands → commands}/metrics.md +1 -1
- package/src/{.agents/commands → commands}/test-browser.md +8 -8
- package/src/commands/workflow-agents.md +101 -0
- package/src/{.agents/commands → commands}/workflow-brainstorm.md +5 -5
- package/src/{.agents/commands → commands}/workflow-compound.md +1 -1
- package/src/{.agents/commands → commands}/workflow-plan.md +62 -85
- package/src/commands/workflow-work.md +839 -0
- package/src/{.agents/references → references}/README.md +1 -1
- package/src/{.agents/skills → skills}/capture-skill/SKILL.md +1 -1
- package/src/{.agents/skills → skills}/compound-docs/SKILL.md +6 -6
- package/src/{.agents/skills → skills}/compound-docs/references/yaml-schema.md +2 -2
- package/src/skills/setup-agents/SKILL.md +247 -0
- package/src/skills/standards/SKILL.md +79 -0
- package/src/skills/standards/references/architecture.md +228 -0
- package/src/skills/standards/references/code-quality.md +192 -0
- package/src/skills/standards/references/presentation.md +515 -0
- package/src/skills/standards/references/services.md +172 -0
- package/src/skills/standards/references/state-management.md +936 -0
- package/.claude-plugin/plugin.json +0 -7
- package/.cursor-plugin/plugin.json +0 -20
- package/.cursor-plugin/registration.json +0 -5
- package/scripts/check-version-parity.mjs +0 -36
- package/scripts/generate-platform-artifacts.mjs +0 -230
- package/src/.agents/commands/install.md +0 -51
- package/src/.agents/commands/workflow-work.md +0 -690
- package/src/.agents/registry.json +0 -48
- package/src/.agents/scripts/self-check.mjs +0 -227
- package/src/.agents/scripts/sync-opencode.mjs +0 -362
- package/src/.agents/skills/presentation-composability/SKILL.md +0 -72
- package/src/.agents/skills/react-ddd-mvc-frontend/SKILL.md +0 -51
- package/src/.agents/skills/react-ddd-mvc-frontend/references/feature-structure.md +0 -25
- package/src/.agents/skills/react-ddd-mvc-frontend/references/implementation-principles.md +0 -21
- package/src/.agents/skills/react-ddd-mvc-frontend/references/responsibility-gates.md +0 -41
- package/src/.agents/skills/react-ddd-mvc-frontend/references/source-map.md +0 -11
- package/src/.agents/skills/standards/SKILL.md +0 -747
- package/src/.agents/skills/xstate-actor-orchestration/SKILL.md +0 -197
- package/src/.agents/skills/xstate-actor-orchestration/agents/openai.yaml +0 -4
- package/src/.agents/skills/xstate-actor-orchestration/assets/statecharts/.gitkeep +0 -0
- package/src/.agents/skills/xstate-actor-orchestration/references/actor-system-patterns.md +0 -71
- package/src/.agents/skills/xstate-actor-orchestration/references/event-contracts.md +0 -73
- package/src/.agents/skills/xstate-actor-orchestration/references/functional-domain-patterns.md +0 -53
- package/src/.agents/skills/xstate-actor-orchestration/references/machine-structure-and-tags.md +0 -36
- package/src/.agents/skills/xstate-actor-orchestration/references/react-container-pattern.md +0 -45
- package/src/.agents/skills/xstate-actor-orchestration/references/reliability-observability.md +0 -39
- package/src/.agents/skills/xstate-actor-orchestration/references/skill-validation.md +0 -33
- package/src/.agents/skills/xstate-actor-orchestration/references/source-map.md +0 -44
- package/src/.agents/skills/xstate-actor-orchestration/references/statechart-review-and-signoff.md +0 -59
- package/src/.agents/skills/xstate-actor-orchestration/references/testing-strategy.md +0 -35
- package/src/.agents/skills/xstate-actor-orchestration/scripts/create-statechart-artifact.sh +0 -71
- package/src/.agents/skills/xstate-actor-orchestration/scripts/validate-skill.sh +0 -138
- package/src/generated/opencode.managed.json +0 -115
- /package/src/{.agents/agents → agents}/research/framework-docs-researcher.md +0 -0
- /package/src/{.agents/agents → agents}/research/git-history-analyzer.md +0 -0
- /package/src/{.agents/agents → agents}/research/learnings-researcher.md +0 -0
- /package/src/{.agents/agents → agents}/research/repo-research-analyst.md +0 -0
- /package/src/{.agents/agents → agents}/review/agent-native-reviewer.md +0 -0
- /package/src/{.agents/agents → agents}/review/planning-technical-reviewer.md +0 -0
- /package/src/{.agents/agents → agents}/workflow/bug-reproduction-validator.md +0 -0
- /package/src/{.agents/agents → agents}/workflow/lint.md +0 -0
- /package/src/{.agents/agents → agents}/workflow/spec-flow-analyzer.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-review.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-tech-review.md +0 -0
- /package/src/{.agents/commands → commands}/workflow-triage.md +0 -0
- /package/src/{.agents/references → references}/standards/README.md +0 -0
- /package/src/{.agents/skills → skills}/agent-browser/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/audit-traceability/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/brainstorming/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/assets/critical-pattern-template.md +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/assets/resolution-template.md +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/schema.project.yaml +0 -0
- /package/src/{.agents/skills → skills}/compound-docs/schema.yaml +0 -0
- /package/src/{.agents/skills → skills}/data-foundations/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/document-review/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/file-todos/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/file-todos/assets/todo-template.md +0 -0
- /package/src/{.agents/skills → skills}/financial-workflow-integrity/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/git-worktree/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/pii-protection-prisma/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/SKILL.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/assets/daily-template.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/assets/monthly-template.md +0 -0
- /package/src/{.agents/skills → skills}/process-metrics/assets/weekly-template.md +0 -0
- /package/src/{.agents/skills → skills}/technical-review/SKILL.md +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Code Quality Reference
|
|
2
|
+
|
|
3
|
+
## Readable, Flat Control Flow
|
|
4
|
+
|
|
5
|
+
Code should be scannable top to bottom without mental stack tracking. The reader should never need to hold multiple branches in their head simultaneously.
|
|
6
|
+
|
|
7
|
+
### Early Exits
|
|
8
|
+
|
|
9
|
+
Return early for each case. Each branch is independent and complete.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// ❌ Nested conditionals with else-if
|
|
13
|
+
export const createAction = (params) => {
|
|
14
|
+
if (params.pendingFolder) {
|
|
15
|
+
return { metadata: { createFolder: params.pendingFolder, mode: "create" } };
|
|
16
|
+
} else if (params.existingFolder) {
|
|
17
|
+
return { metadata: { targetFolder: params.existingFolder, mode: "existing" } };
|
|
18
|
+
} else {
|
|
19
|
+
throw new Error("Invalid params");
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ✅ Flat early exits
|
|
24
|
+
export const createAction = (params) => {
|
|
25
|
+
if (params.pendingFolder) {
|
|
26
|
+
return { metadata: { createFolder: params.pendingFolder, mode: "create" } };
|
|
27
|
+
}
|
|
28
|
+
if (params.existingFolder) {
|
|
29
|
+
return { metadata: { targetFolder: params.existingFolder, mode: "existing" } };
|
|
30
|
+
}
|
|
31
|
+
throw new Error("Invalid params");
|
|
32
|
+
};
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Rules
|
|
36
|
+
|
|
37
|
+
- **No `else` or `else-if`** — `else-if` is a code smell indicating a missed early exit opportunity
|
|
38
|
+
- **No conditional spreading** — `...(condition && { prop: value })` obscures intent and mutates implicitly
|
|
39
|
+
- **No nested ternaries** — a single ternary for a simple inline value is acceptable; nesting is not
|
|
40
|
+
- **No `let` variables modified in conditionals** — use pure transforms instead
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Immutable Transforms
|
|
45
|
+
|
|
46
|
+
Data operations return new values. Never mutate existing objects or arrays.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// ❌ Mutation
|
|
50
|
+
const updatePlaylist = (playlists, id, label) => {
|
|
51
|
+
const playlist = playlists.find(p => p.id === id);
|
|
52
|
+
playlist.label = label; // mutates
|
|
53
|
+
return playlists;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ✅ Immutable transform
|
|
57
|
+
const updatePlaylist = (playlists, id, label) =>
|
|
58
|
+
playlists.map(p => p.id === id ? { ...p, label } : p);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Error Handling
|
|
64
|
+
|
|
65
|
+
### Always Throw on Unexpected State
|
|
66
|
+
|
|
67
|
+
Unexpected states must throw. Silent returns hide bugs and data inconsistencies.
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// ❌ Silent return — hides bugs
|
|
71
|
+
if (!playlist) return;
|
|
72
|
+
|
|
73
|
+
// ✅ Throw — makes failures visible
|
|
74
|
+
if (!playlist) throw new Error("Playlist not found");
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Suppress Only With Documented Intent
|
|
78
|
+
|
|
79
|
+
If suppressing an error is the right call, document why. A suppress with no comment is never acceptable.
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// ❌ Silent suppress — intent unknown
|
|
83
|
+
try {
|
|
84
|
+
doSomething();
|
|
85
|
+
} catch {}
|
|
86
|
+
|
|
87
|
+
// ✅ Intent explicit
|
|
88
|
+
try {
|
|
89
|
+
setPreference(value);
|
|
90
|
+
} catch {
|
|
91
|
+
// localStorage unavailable in SSR context — safe to ignore
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Validation Boundary
|
|
98
|
+
|
|
99
|
+
Validate at the boundary between layers. Controllers validate runtime inputs before passing to entities. Entities validate inbound data from the backend as part of the transform — applying defaults and ensuring shape correctness.
|
|
100
|
+
|
|
101
|
+
### Controller Validates Runtime Inputs
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
// Controller validates before calling entity
|
|
105
|
+
if (!ctx.activeTabId || !ctx.activeFolderId) {
|
|
106
|
+
throw new Error("Active tab ID and folder ID are required");
|
|
107
|
+
}
|
|
108
|
+
const playlist = ctx.availablePlaylists.find(p => p.id === evt.payload.playlistId);
|
|
109
|
+
if (!playlist) throw new Error("Playlist not found");
|
|
110
|
+
|
|
111
|
+
// Entity receives clean, validated data
|
|
112
|
+
const action = tapesActionEntity.createApplyPlaylistActionForFolder({
|
|
113
|
+
playlist,
|
|
114
|
+
activeTabId: ctx.activeTabId,
|
|
115
|
+
activeFolderId: ctx.activeFolderId,
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Entity Validates Inbound Backend Data
|
|
120
|
+
|
|
121
|
+
Entities are the data boundary for backend responses. Apply defaults and ensure correct shape as part of the transform — protect the UI from unexpected or missing values.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
// Entity applies defaults as part of transform
|
|
125
|
+
export const toPlaylistItem = (raw: RawPlaylist): PlaylistItem => ({
|
|
126
|
+
id: raw.playlistId,
|
|
127
|
+
label: raw.playlistTitle ?? "Untitled",
|
|
128
|
+
isActive: raw.isActive ?? false,
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Make Parameters Required
|
|
133
|
+
|
|
134
|
+
When inputs are always validated at call sites, make them required. TypeScript catches missing parameters at compile time, removing redundant runtime checks.
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// ✅ Required — validated at call site, TypeScript enforces
|
|
138
|
+
export const createAction = ({
|
|
139
|
+
playlist,
|
|
140
|
+
activeFolderId,
|
|
141
|
+
}: {
|
|
142
|
+
playlist: InputOption;
|
|
143
|
+
activeFolderId: string;
|
|
144
|
+
}) => {
|
|
145
|
+
// No runtime check needed — caller is responsible
|
|
146
|
+
};
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Quick Check — Common Violations
|
|
152
|
+
|
|
153
|
+
**`else`/`else-if` instead of early exits:**
|
|
154
|
+
```typescript
|
|
155
|
+
// ❌ Nested conditionals
|
|
156
|
+
if (a) { return x; } else if (b) { return y; } else { throw ... }
|
|
157
|
+
|
|
158
|
+
// ✅ Flat early exits
|
|
159
|
+
if (a) return x;
|
|
160
|
+
if (b) return y;
|
|
161
|
+
throw new Error("Unexpected state");
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Conditional spreading:**
|
|
165
|
+
```typescript
|
|
166
|
+
// ❌ Obscures intent
|
|
167
|
+
const action = { ...(condition && { prop: value }) };
|
|
168
|
+
|
|
169
|
+
// ✅ Explicit branch
|
|
170
|
+
if (condition) return { ...base, prop: value };
|
|
171
|
+
return base;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Silent error suppression:**
|
|
175
|
+
```typescript
|
|
176
|
+
// ❌ No intent documented
|
|
177
|
+
try { doSomething(); } catch {}
|
|
178
|
+
|
|
179
|
+
// ✅ Intent explicit
|
|
180
|
+
try { doSomething(); } catch {
|
|
181
|
+
// SSR context — localStorage unavailable, safe to ignore
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Silent return on unexpected state:**
|
|
186
|
+
```typescript
|
|
187
|
+
// ❌ Hides bugs
|
|
188
|
+
if (!playlist) return;
|
|
189
|
+
|
|
190
|
+
// ✅ Makes failures visible
|
|
191
|
+
if (!playlist) throw new Error("Playlist not found");
|
|
192
|
+
```
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# Presentation Reference
|
|
2
|
+
|
|
3
|
+
Presentation components are UI only — they receive props and render. Styling is handled with **Tailwind CSS** utility classes. Variant logic is handled with **CVA** (Class Variance Authority). Class merging is handled with **`cn()`**.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## CSS-Only Responsive Layout
|
|
8
|
+
|
|
9
|
+
**Use Tailwind responsive classes instead of JavaScript resize listeners.** Layout is a CSS concern, not a state machine concern.
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// ❌ Bad — JavaScript-driven layout
|
|
13
|
+
const isMobile = snapshot.context.isMobile; // state machine tracks breakpoint
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const handler = () => send({ type: 'WINDOW_RESIZED', width: window.innerWidth });
|
|
16
|
+
window.addEventListener('resize', handler);
|
|
17
|
+
}, [send]);
|
|
18
|
+
{isMobile ? <MobileDrawer /> : <Sidebar />}
|
|
19
|
+
|
|
20
|
+
// ✅ Good — CSS-only responsive layout
|
|
21
|
+
// No isMobile in machine context, no WINDOW_RESIZED event, no resize listeners
|
|
22
|
+
<button className="md:hidden"> {/* Only show on mobile */}
|
|
23
|
+
<div className="hidden md:block"> {/* Only show on desktop */}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Layout is a presentation concern. CSS handles responsive breakpoints without JavaScript overhead — no re-renders on resize, no state synchronisation issues.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## No Base Element CSS Overrides
|
|
31
|
+
|
|
32
|
+
**When using Tailwind's token-based design system, don't define base element styles (`a`, `h1`, `button`, etc.) with hardcoded values.**
|
|
33
|
+
|
|
34
|
+
```css
|
|
35
|
+
/* ❌ Bad — conflicts with Tailwind's token system */
|
|
36
|
+
a { color: #646cff; }
|
|
37
|
+
h1 { font-size: 3.2em; }
|
|
38
|
+
button { background-color: #1a1a1a; }
|
|
39
|
+
|
|
40
|
+
/* ✅ Good — use @layer base with semantic tokens */
|
|
41
|
+
@layer base {
|
|
42
|
+
* { @apply border-border; }
|
|
43
|
+
body { @apply bg-background text-foreground; }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Base element styles with hex colors bypass the design token system and create inconsistencies. Use `@apply` with semantic tokens or utility classes.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## No Separate `styles.ts` Files
|
|
52
|
+
|
|
53
|
+
**With Tailwind CSS, inline Tailwind classes directly in JSX.** Do not create separate `styles.ts` files that export class strings.
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// ❌ Bad — separate styles.ts with namespace import
|
|
57
|
+
// styles.ts
|
|
58
|
+
export const overlay = 'fixed inset-0 z-50 bg-black/50';
|
|
59
|
+
|
|
60
|
+
// index.tsx
|
|
61
|
+
import * as S from './styles';
|
|
62
|
+
<div className={S.overlay} />
|
|
63
|
+
|
|
64
|
+
// ✅ Good — inline Tailwind classes directly in JSX
|
|
65
|
+
<div className="fixed inset-0 z-50 bg-black/50" />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Separate `styles.ts` files add indirection without benefit when using Tailwind. Classes are already semantic and self-documenting. Inline keeps component logic and styling co-located.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Pure Presentation Rule
|
|
73
|
+
|
|
74
|
+
Presentation components must have **zero framework imports**. They accept only props — no router hooks, no auth hooks, no state machine references, no `Outlet`, no `Navigate`.
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// ✅ Pure — accepts children and a derived value as props
|
|
78
|
+
import type { ReactNode } from 'react'
|
|
79
|
+
|
|
80
|
+
interface AppLayoutProps { children: ReactNode }
|
|
81
|
+
|
|
82
|
+
export function AppLayout({ children }: AppLayoutProps) {
|
|
83
|
+
return <div>{children}</div>
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
// ✅ Pure — accepts extracted state as a prop, not the hook itself
|
|
89
|
+
interface GlobalLoaderProps { isNavigating: boolean }
|
|
90
|
+
|
|
91
|
+
export function GlobalLoader({ isNavigating }: GlobalLoaderProps) {
|
|
92
|
+
if (!isNavigating) return null
|
|
93
|
+
return <div className="..." />
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// ❌ Framework import in presentation — belongs in a container
|
|
99
|
+
import { Outlet, useNavigation } from 'react-router-dom'
|
|
100
|
+
|
|
101
|
+
export function AppLayout() {
|
|
102
|
+
const navigation = useNavigation() // router concern — wrong layer
|
|
103
|
+
return <><GlobalLoader /><Outlet /></>
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
When a presentation component needs data that comes from a framework hook, create a corresponding container that calls the hook and passes the extracted value as a prop. The presentation component never knows where the value came from.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Design Tokens
|
|
112
|
+
|
|
113
|
+
Design tokens are the foundation of the visual system. They are defined once in `tailwind.config` and consumed everywhere as Tailwind utility classes — never as raw values inline.
|
|
114
|
+
|
|
115
|
+
### Defining tokens
|
|
116
|
+
|
|
117
|
+
Tokens are defined in `tailwind.config.ts` under `theme.extend`. This is the single source of truth for colours, spacing, typography, and any other design decisions.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// tailwind.config.ts
|
|
121
|
+
export default {
|
|
122
|
+
theme: {
|
|
123
|
+
extend: {
|
|
124
|
+
colors: {
|
|
125
|
+
brand: {
|
|
126
|
+
primary: "var(--color-brand-primary)",
|
|
127
|
+
secondary: "var(--color-brand-secondary)",
|
|
128
|
+
},
|
|
129
|
+
surface: {
|
|
130
|
+
default: "var(--color-surface-default)",
|
|
131
|
+
elevated: "var(--color-surface-elevated)",
|
|
132
|
+
overlay: "var(--color-surface-overlay)",
|
|
133
|
+
},
|
|
134
|
+
content: {
|
|
135
|
+
primary: "var(--color-content-primary)",
|
|
136
|
+
secondary: "var(--color-content-secondary)",
|
|
137
|
+
disabled: "var(--color-content-disabled)",
|
|
138
|
+
},
|
|
139
|
+
feedback: {
|
|
140
|
+
error: "var(--color-feedback-error)",
|
|
141
|
+
success: "var(--color-feedback-success)",
|
|
142
|
+
warning: "var(--color-feedback-warning)",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
borderRadius: {
|
|
146
|
+
sm: "var(--radius-sm)",
|
|
147
|
+
md: "var(--radius-md)",
|
|
148
|
+
lg: "var(--radius-lg)",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The CSS custom properties are defined in the global stylesheet and can be swapped per theme.
|
|
156
|
+
|
|
157
|
+
### Consuming tokens
|
|
158
|
+
|
|
159
|
+
Always use Tailwind utility classes that reference tokens — never use raw hex values, `style` props, or arbitrary Tailwind values for anything that should be a token.
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// ✅ Uses tokens via Tailwind classes
|
|
163
|
+
<div className="bg-surface-default text-content-primary rounded-md" />
|
|
164
|
+
|
|
165
|
+
// ❌ Raw value — not a token, not themeable
|
|
166
|
+
<div className="bg-[#1a1a2e] text-[#ffffff]" />
|
|
167
|
+
|
|
168
|
+
// ❌ Inline style — bypasses the token system entirely
|
|
169
|
+
<div style={{ backgroundColor: "#1a1a2e" }} />
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## cn() — Class Merging Utility
|
|
175
|
+
|
|
176
|
+
`cn()` is the standard utility for composing and merging Tailwind classes. It combines `clsx` (conditional classes) with `tailwind-merge` (conflict resolution).
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// src/lib/cn.ts
|
|
180
|
+
import { clsx, type ClassValue } from "clsx";
|
|
181
|
+
import { twMerge } from "tailwind-merge";
|
|
182
|
+
|
|
183
|
+
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Use `cn()` whenever classes are conditional or need to be merged from multiple sources:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
// ✅ Conditional classes
|
|
190
|
+
<div className={cn(
|
|
191
|
+
"rounded-md px-4 py-2",
|
|
192
|
+
isActive && "bg-brand-primary text-white",
|
|
193
|
+
isDisabled && "opacity-50 cursor-not-allowed",
|
|
194
|
+
)} />
|
|
195
|
+
|
|
196
|
+
// ✅ Merging variant classes with overrides
|
|
197
|
+
<button className={cn(buttonVariants({ intent: "primary" }), className)} />
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## CVA — Variant Definitions
|
|
203
|
+
|
|
204
|
+
CVA defines the variant logic for a component. CVA definitions live **below the component's JSX** in the same file — no separate file needed.
|
|
205
|
+
|
|
206
|
+
### Basic variant
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// FolderItem/index.tsx
|
|
210
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
211
|
+
import { cn } from "src/lib/cn";
|
|
212
|
+
|
|
213
|
+
interface FolderItemProps extends VariantProps<typeof folderItemVariants> {
|
|
214
|
+
label: string;
|
|
215
|
+
className?: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export const FolderItem = ({ label, intent, size, className }: FolderItemProps) => (
|
|
219
|
+
<div className={cn(folderItemVariants({ intent, size }), className)}>
|
|
220
|
+
{label}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// CVA definition — below the component
|
|
225
|
+
const folderItemVariants = cva(
|
|
226
|
+
// Base classes — always applied
|
|
227
|
+
"flex items-center gap-2 rounded-md transition-colors",
|
|
228
|
+
{
|
|
229
|
+
variants: {
|
|
230
|
+
intent: {
|
|
231
|
+
default: "bg-surface-default text-content-primary hover:bg-surface-elevated",
|
|
232
|
+
active: "bg-brand-primary text-white",
|
|
233
|
+
ghost: "text-content-secondary hover:bg-surface-overlay",
|
|
234
|
+
},
|
|
235
|
+
size: {
|
|
236
|
+
sm: "px-2 py-1 text-sm",
|
|
237
|
+
md: "px-3 py-2 text-base",
|
|
238
|
+
lg: "px-4 py-3 text-lg",
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
defaultVariants: {
|
|
242
|
+
intent: "default",
|
|
243
|
+
size: "md",
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Compound variants
|
|
250
|
+
|
|
251
|
+
Use `compoundVariants` when classes only apply under a specific combination of variants:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
const buttonVariants = cva("font-medium rounded-md transition-colors", {
|
|
255
|
+
variants: {
|
|
256
|
+
intent: {
|
|
257
|
+
primary: "bg-brand-primary text-white",
|
|
258
|
+
secondary: "bg-surface-elevated text-content-primary",
|
|
259
|
+
ghost: "text-content-secondary",
|
|
260
|
+
},
|
|
261
|
+
size: {
|
|
262
|
+
sm: "px-2 py-1 text-sm",
|
|
263
|
+
md: "px-4 py-2 text-base",
|
|
264
|
+
},
|
|
265
|
+
disabled: {
|
|
266
|
+
true: "opacity-50 cursor-not-allowed",
|
|
267
|
+
false: "",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
compoundVariants: [
|
|
271
|
+
// Hover only applies when not disabled
|
|
272
|
+
{ intent: "primary", disabled: false, class: "hover:bg-brand-secondary" },
|
|
273
|
+
{ intent: "secondary", disabled: false, class: "hover:bg-surface-overlay" },
|
|
274
|
+
],
|
|
275
|
+
defaultVariants: {
|
|
276
|
+
intent: "primary",
|
|
277
|
+
size: "md",
|
|
278
|
+
disabled: false,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Required variants
|
|
284
|
+
|
|
285
|
+
When a variant must always be provided, use TypeScript utility types to make it required:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
type FolderItemVariantProps = VariantProps<typeof folderItemVariants>;
|
|
289
|
+
|
|
290
|
+
// Make `intent` required, keep everything else optional
|
|
291
|
+
interface FolderItemProps
|
|
292
|
+
extends Omit<FolderItemVariantProps, "intent">,
|
|
293
|
+
Required<Pick<FolderItemVariantProps, "intent">> {
|
|
294
|
+
label: string;
|
|
295
|
+
className?: string;
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### CVA in compound components
|
|
300
|
+
|
|
301
|
+
In a compound component each sub-component owns its own CVA definition in its own file. Sub-components do not share CVA instances — each is self-contained.
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
// FolderSelectorRoot/index.tsx
|
|
305
|
+
export const FolderSelectorRoot = ({ children, className }: FolderSelectorRootProps) => (
|
|
306
|
+
<div className={cn(rootVariants(), className)}>{children}</div>
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const rootVariants = cva("flex flex-col w-full bg-surface-default rounded-lg");
|
|
310
|
+
|
|
311
|
+
// FolderItem/index.tsx
|
|
312
|
+
export const FolderItem = ({ label, isActive, className }: FolderItemProps) => (
|
|
313
|
+
<div className={cn(itemVariants({ isActive }), className)}>{label}</div>
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const itemVariants = cva("flex items-center gap-2 px-3 py-2 rounded-md", {
|
|
317
|
+
variants: {
|
|
318
|
+
isActive: {
|
|
319
|
+
true: "bg-brand-primary text-white",
|
|
320
|
+
false: "text-content-primary hover:bg-surface-elevated",
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
defaultVariants: { isActive: false },
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Folder Structure
|
|
330
|
+
|
|
331
|
+
Every component lives in its own PascalCase folder under `src/features/{feature}/presentation/`.
|
|
332
|
+
|
|
333
|
+
### Simple component
|
|
334
|
+
|
|
335
|
+
A self-contained component with no sub-components. One file only:
|
|
336
|
+
|
|
337
|
+
```
|
|
338
|
+
FolderItem/
|
|
339
|
+
└── index.tsx # component JSX + CVA definitions below
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Compound component
|
|
343
|
+
|
|
344
|
+
A larger component composed of multiple sub-components, exposed as a single public API via the barrel:
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
FolderSelector/
|
|
348
|
+
├── index.ts # barrel — Object.assign composition
|
|
349
|
+
├── FolderSelectorRoot/
|
|
350
|
+
│ └── index.tsx # root JSX + CVA below
|
|
351
|
+
├── FolderItem/
|
|
352
|
+
│ └── index.tsx # item JSX + CVA below
|
|
353
|
+
├── FolderActions/
|
|
354
|
+
│ ├── index.tsx # actions root JSX + CVA below
|
|
355
|
+
│ ├── FolderActionsAdd.tsx # tightly related — stays in parent folder
|
|
356
|
+
│ └── FolderActionsMore.tsx # tightly related — stays in parent folder
|
|
357
|
+
└── FolderSelectorCollapsed/
|
|
358
|
+
└── index.tsx
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Sub-components that belong to the same concern stay in the parent folder as named exports. Only promote to a subfolder when independently meaningful.
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## Barrel Pattern
|
|
366
|
+
|
|
367
|
+
The `index.ts` barrel assembles the public API of the compound component. It is composition only — no component definitions, no logic, no CVA.
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// FolderSelector/index.ts
|
|
371
|
+
import { FolderSelectorRoot } from "./FolderSelectorRoot";
|
|
372
|
+
import { FolderItem } from "./FolderItem";
|
|
373
|
+
import { FolderActions, FolderActionsAdd, FolderActionsMore } from "./FolderActions";
|
|
374
|
+
import { FolderSelectorCollapsed } from "./FolderSelectorCollapsed";
|
|
375
|
+
import { FolderSelectorExpanded } from "./FolderSelectorExpanded";
|
|
376
|
+
|
|
377
|
+
export const FolderSelector = Object.assign(FolderSelectorRoot, {
|
|
378
|
+
Folder: FolderItem,
|
|
379
|
+
Actions: FolderActions,
|
|
380
|
+
ActionsAdd: FolderActionsAdd,
|
|
381
|
+
ActionsMore: FolderActionsMore,
|
|
382
|
+
Collapsed: FolderSelectorCollapsed,
|
|
383
|
+
Expanded: FolderSelectorExpanded,
|
|
384
|
+
});
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Usage at the call site makes relationships explicit:
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
<FolderSelector>
|
|
391
|
+
<FolderSelector.Collapsed />
|
|
392
|
+
<FolderSelector.Expanded>
|
|
393
|
+
<FolderSelector.Folder intent="active" label="My Playlist" />
|
|
394
|
+
</FolderSelector.Expanded>
|
|
395
|
+
</FolderSelector>
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Rules
|
|
399
|
+
|
|
400
|
+
- Relative imports only — the barrel imports from its own subfolders, never from outside the component boundary
|
|
401
|
+
- No logic in the barrel — composition only
|
|
402
|
+
- What's in `Object.assign` is the public API — if it's not in the barrel, it's internal
|
|
403
|
+
- No `styles.ts` — styling belongs in the component file via CVA and Tailwind
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## className Prop
|
|
408
|
+
|
|
409
|
+
All components accept an optional `className` prop for overrides at the call site. Always merge with `cn()` — never concatenate strings directly.
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
interface FolderItemProps extends VariantProps<typeof folderItemVariants> {
|
|
413
|
+
className?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export const FolderItem = ({ intent, size, className }: FolderItemProps) => (
|
|
417
|
+
<div className={cn(folderItemVariants({ intent, size }), className)} />
|
|
418
|
+
);
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
This allows containers to apply layout-specific classes without breaking the component's internal variant logic.
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Quick Check — Common Violations
|
|
426
|
+
|
|
427
|
+
**Raw value instead of a token:**
|
|
428
|
+
```typescript
|
|
429
|
+
// ❌
|
|
430
|
+
<div className="bg-[#1a1a2e] text-[14px]" />
|
|
431
|
+
|
|
432
|
+
// ✅
|
|
433
|
+
<div className="bg-surface-default text-sm" />
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**CVA in a separate file:**
|
|
437
|
+
```typescript
|
|
438
|
+
// ❌ Separate variants.ts file
|
|
439
|
+
import { buttonVariants } from "./variants";
|
|
440
|
+
|
|
441
|
+
// ✅ CVA defined below the component in the same file
|
|
442
|
+
export const Button = ({ intent }: ButtonProps) => (
|
|
443
|
+
<button className={buttonVariants({ intent })} />
|
|
444
|
+
);
|
|
445
|
+
const buttonVariants = cva("...", { variants: { intent: { ... } } });
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**String concatenation instead of cn():**
|
|
449
|
+
```typescript
|
|
450
|
+
// ❌ Class conflicts not resolved
|
|
451
|
+
className={`${baseClasses} ${isActive ? "bg-brand-primary" : ""}`}
|
|
452
|
+
|
|
453
|
+
// ✅
|
|
454
|
+
className={cn(baseClasses, isActive && "bg-brand-primary")}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
**Styles outside the component file:**
|
|
458
|
+
```typescript
|
|
459
|
+
// ❌ styles.ts with styled components
|
|
460
|
+
import { Base, BaseContent } from "./styles";
|
|
461
|
+
|
|
462
|
+
// ✅ Tailwind classes inline, CVA below the component
|
|
463
|
+
<div className={cn(rootVariants(), className)} />
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
**Barrel importing from outside component boundary:**
|
|
467
|
+
```typescript
|
|
468
|
+
// ❌
|
|
469
|
+
import { FolderItem } from "src/features/other/presentation/FolderItem";
|
|
470
|
+
|
|
471
|
+
// ✅
|
|
472
|
+
import { FolderItem } from "./FolderItem";
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
**Variant logic inline in JSX instead of CVA:**
|
|
476
|
+
```typescript
|
|
477
|
+
// ❌ Variant logic scattered through JSX
|
|
478
|
+
<div className={`rounded-md ${intent === "primary" ? "bg-brand-primary text-white" : "bg-surface-default text-content-primary"}`} />
|
|
479
|
+
|
|
480
|
+
// ✅ Variant logic in CVA, JSX stays clean
|
|
481
|
+
<div className={cn(itemVariants({ intent }), className)} />
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
**JavaScript-driven responsive layout:**
|
|
485
|
+
```typescript
|
|
486
|
+
// ❌ Resize listener + state machine tracking breakpoint
|
|
487
|
+
const isMobile = snapshot.context.isMobile;
|
|
488
|
+
{isMobile ? <MobileDrawer /> : <Sidebar />}
|
|
489
|
+
|
|
490
|
+
// ✅ CSS-only
|
|
491
|
+
<div className="hidden md:block"><Sidebar /></div>
|
|
492
|
+
<div className="md:hidden"><MobileDrawer /></div>
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
**Base element styles with hardcoded values:**
|
|
496
|
+
```css
|
|
497
|
+
/* ❌ Bypasses token system */
|
|
498
|
+
a { color: #646cff; }
|
|
499
|
+
button { background-color: #1a1a1a; }
|
|
500
|
+
|
|
501
|
+
/* ✅ Use @layer base with semantic tokens */
|
|
502
|
+
@layer base {
|
|
503
|
+
body { @apply bg-background text-foreground; }
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
**Separate `styles.ts` file:**
|
|
508
|
+
```typescript
|
|
509
|
+
// ❌
|
|
510
|
+
import * as S from './styles';
|
|
511
|
+
<div className={S.overlay} />
|
|
512
|
+
|
|
513
|
+
// ✅
|
|
514
|
+
<div className="fixed inset-0 z-50 bg-black/50" />
|
|
515
|
+
```
|