Skip to content

Commit 58b5df4

Browse files
feat(ui): add component-scaffold skill for MetaMask Mobile (#52)
Fixes https://consensyssoftware.atlassian.net/browse/MCWP-470 ## Description Adds the `component-scaffold` skill for the MetaMask Mobile repository. The skill teaches agents how to scaffold a new React Native component following all repo conventions: directory placement, required file set, MMDS design-system layer, styling patterns (twClassName / Box enums), i18n, relative imports, testIds, test quality, barrel registration, and bottom-sheet specifics. Includes a full scaffold checklist and copy-paste file templates. ## Type of Change - [x] New skill ## Skill Details (if adding a new skill) **Provider Name:** MetaMask Mobile **Skill Name:** component-scaffold **Brief Description:** Scaffolds a new React Native component for MetaMask Mobile following all repo conventions (file structure, MMDS design system, i18n, tests, testIds, barrel exports). > [!NOTE] > Marked as stable (implicit default): the skill is grounded in existing codebase patterns and the evaluation shows ≥95% correctness across 4 models and 5 prompts, providing sufficient evidence to skip the experimental gate. ## Checklist - [x] I have read the [CONTRIBUTING.md](../CONTRIBUTING.md) guidelines - [x] My skill follows the [SKILL_TEMPLATE.md](.github/SKILL_TEMPLATE.md) format --> none of the skill in this repo is following the skill template, it's obsolete. But I checked. - [x] I have tested this skill with an AI agent --> yes, even better, see Testing - [x] My skill does not contain any secrets, private keys, or sensitive data - [x] I have added appropriate documentation - [x] My changes don't break existing skills ## Testing Evaluated with an automated harness: 4 models × 5 prompts, judged by `claude-opus-4-8-thinking-high` using an 11-criterion rubric derived directly from the skill. **Prompts (increasing difficulty)** | ID | Task | |----|------| | p1 | Simple presentational component | | p2 | Bottom sheet (exercises `useElevatedSurface` + gesture-handler `ScrollView`) | | p3 | List item with dynamic testID per item | | p4 | Redux-connected component (forces `renderWithProvider`) | | p5 | Vague prompt — robustness test | **Results** | Model | Avg Score% | |-------|-----------| | gpt-5.3-codex | 98.4% | | composer-2.5 | 98.3% | | claude-opus-4-8-thinking-high | 98.1% | | claude-4.6-sonnet-medium | 95.7% | **Attached evidence** - Archive [out.zip](https://github.com/user-attachments/files/29009230/out.zip) contains: - `eval-explainer.md` — methodology and how to read the results - `metrics.md` — full scored matrix across all models and criteria - `dataset.md` — generated code + judge rationale per run ## Additional Context The evaluation harness scripts are not published yet, only the result in the above archive. ### Reference implementations in metamask-mobile: - [app/components/UI/Compliance/AccessRestrictedModal/AccessRestrictedModal.tsx](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI/Compliance/AccessRestrictedModal/AccessRestrictedModal.tsx) — example of a bottom sheet component following all conventions - [app/components/UI/Earn/components/EarnHeaderSubtitle/EarnHeaderSubtitle.tsx](https://github.com/MetaMask/metamask-mobile/blob/main/app/components/UI/Earn/components/EarnHeaderSubtitle/EarnHeaderSubtitle.tsx) — example of a simple presentational component - [app/component-library/components/design-system.stories.tsx](https://github.com/MetaMask/metamask-mobile/blob/main/app/component-library/components/design-system.stories.tsx) — overview of available MMDS primitives
1 parent d23becf commit 58b5df4

2 files changed

Lines changed: 398 additions & 0 deletions

File tree

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
---
2+
repo: metamask-mobile
3+
parent: component-scaffold
4+
---
5+
6+
# Component Scaffold — MetaMask Mobile
7+
8+
---
9+
10+
## 1. Where to put the component
11+
12+
| Scope | Location |
13+
|-------|----------|
14+
| Shared across features | `app/components/UI/<Feature>/` |
15+
| Feature-internal composite | `app/components/UI/<Feature>/components/<ComponentName>/` |
16+
| Screen / route | `app/components/Views/<ScreenName>/` |
17+
| Feature-internal only (not exported) | nested `components/` subdir inside the feature folder |
18+
19+
**Naming rules:**
20+
- Component directory: `PascalCase` matching the component name exactly (`AccessRestrictedModal/`, `EarnHeaderSubtitle/`)
21+
- Feature container folders are lowercase: `components/`, `hooks/`, `utils/`, `types/`
22+
- Files: `PascalCase` prefix with a dotted role suffix (`Foo.tsx`, `Foo.types.ts`, `Foo.testIds.ts`, `Foo.test.tsx`)
23+
- Barrel: lowercase `index.ts`
24+
25+
---
26+
27+
## 2. Required file set
28+
29+
Tests are **colocated** — do NOT create a separate `__tests__/` folder.
30+
31+
```
32+
ComponentName/
33+
ComponentName.tsx ← always required
34+
ComponentName.types.ts ← recommended; inline interface only for trivial components
35+
ComponentName.testIds.ts ← recommended; skip only when no testable elements exist
36+
ComponentName.test.tsx ← MANDATORY, colocated next to the component
37+
index.ts ← MANDATORY
38+
```
39+
40+
Optional situational files:
41+
- `ComponentName.constants.ts` — when the component has non-trivial magic values
42+
- Co-located `useComponentName.ts` hooks — when logic warrants extraction
43+
- `README.md` — only for larger feature-level folders
44+
45+
---
46+
47+
## 3. Design system: which layer to use
48+
49+
Two systems coexist in the repo:
50+
51+
### Layer 1 — `@metamask/design-system-react-native` (MMDS, npm package) ✅ Always use first
52+
53+
The only correct choice for new component primitives. Exports `Box`, `Text`, `BottomSheet`, `BottomSheetHeader`, `BottomSheetFooter`, `ButtonBase`, `ButtonIcon`, `HeaderStandard`, `Icon`, `Skeleton`, `Tag`, and more.
54+
55+
Before writing any UI, verify what the installed version actually exports — the package changes frequently:
56+
57+
```
58+
node_modules/@metamask/design-system-react-native/dist/components/index.d.cts
59+
```
60+
61+
If the file layout differs, also try:
62+
```
63+
node_modules/@metamask/design-system-react-native/dist/components/index.d.ts
64+
node_modules/@metamask/design-system-react-native/src/components/index.ts
65+
```
66+
67+
The installed package is the source of truth, not this skill's examples.
68+
69+
For local visual testing of a component in isolation, see `docs/readme/storybook.md`.
70+
71+
### Layer 2 — `app/component-library/components/`
72+
73+
Valid second choice **only** when `@metamask/design-system-react-native` does not export an equivalent (e.g. Tabs, MetaMask-specific modal wrappers not yet migrated to MMDS).
74+
75+
Do not use any component in this library that carries a `@deprecated` JSDoc annotation — those have MMDS equivalents and must be replaced. For everything else in this library, prefer MMDS first; use `app/component-library` only when no MMDS primitive covers the need.
76+
77+
### Layer 3 — Feature-specific composites
78+
79+
Build from MMDS primitives when no primitive covers the use case.
80+
81+
### Never use
82+
- Raw `View` from `react-native` — use `Box`
83+
- Raw `Text` from `react-native` without variants — use `Text` with `TextVariant`
84+
- `StyleSheet.create()` — use `twClassName` or Box layout props
85+
86+
---
87+
88+
## 4. Styling patterns
89+
90+
### `twClassName` string prop — for utility classes
91+
92+
```tsx
93+
<Box twClassName="px-4 pb-6 w-full rounded-xl bg-muted" />
94+
```
95+
96+
Use for: width/height, borders, rounded corners, shadows, opacity, overflow, z-index, absolute positioning, background colours via semantic tokens (`bg-default`, `bg-muted`, `bg-pressed`).
97+
98+
### Box layout enum props — preferred for flexbox
99+
100+
```tsx
101+
<Box
102+
flexDirection={BoxFlexDirection.Row}
103+
alignItems={BoxAlignItems.Center}
104+
justifyContent={BoxJustifyContent.Between}
105+
gap={2}
106+
padding={4}
107+
/>
108+
```
109+
110+
Type-safe, prevents class-string typos. Spacing units: each unit = 4px (`padding={4}` = 16px, max `12` = 48px).
111+
112+
### Enum constants for props
113+
114+
Import from `@metamask/design-system-react-native` — never use raw strings:
115+
116+
```tsx
117+
import {
118+
TextVariant,
119+
TextColor,
120+
FontWeight,
121+
ButtonBaseSize,
122+
BoxFlexDirection,
123+
BoxAlignItems,
124+
BoxJustifyContent,
125+
BoxBackgroundColor,
126+
} from '@metamask/design-system-react-native';
127+
```
128+
129+
### Interactive / pressed state
130+
131+
```tsx
132+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
133+
134+
const tw = useTailwind();
135+
136+
<ButtonBase
137+
style={({ pressed }) =>
138+
tw.style('w-full flex-row items-center', pressed && 'bg-pressed')
139+
}
140+
/>
141+
```
142+
143+
---
144+
145+
## 5. Bottom sheet specifics
146+
147+
1. Apply `useElevatedSurface()` to the `BottomSheet`'s `twClassName` — required for pure-black theme support:
148+
149+
```tsx
150+
import { useElevatedSurface } from '../../../../util/theme/themeUtils'; // adjust depth
151+
152+
const surfaceClass = useElevatedSurface();
153+
154+
<BottomSheet twClassName={surfaceClass}>
155+
```
156+
157+
2. `ScrollView` inside a `BottomSheet` must come from `react-native-gesture-handler` — the standard React Native `ScrollView` will not scroll on Android inside a gesture-managed bottom sheet:
158+
159+
```tsx
160+
// ✅ correct
161+
import { ScrollView } from 'react-native-gesture-handler';
162+
163+
// ❌ will not scroll on Android
164+
import { ScrollView } from 'react-native';
165+
```
166+
167+
---
168+
169+
## 6. i18n
170+
171+
All user-visible strings must go through i18n:
172+
173+
```tsx
174+
import { strings } from '../../../../locales/i18n'; // adjust depth to file location
175+
176+
{strings('namespace.key')}
177+
```
178+
179+
Add new keys to `app/locales/languages/en.json` only. Crowdin picks up new English strings automatically after the PR merges — do not edit other language files manually.
180+
181+
---
182+
183+
## 7. Imports: no path aliases
184+
185+
`tsconfig.json` defines no `@components` or `@app` path aliases. All imports are relative. Compute the correct `../` depth based on the file's actual location.
186+
187+
Example from `app/components/UI/Compliance/AccessRestrictedModal/AccessRestrictedModal.tsx`:
188+
```tsx
189+
import { strings } from '../../../../../locales/i18n';
190+
import { useElevatedSurface } from '../../../../util/theme/themeUtils';
191+
```
192+
193+
---
194+
195+
## 8. ESLint import fences
196+
197+
| Blocked import | Use instead |
198+
|---------------|-------------|
199+
| `expo-haptics` | `app/util/haptics` |
200+
| `app/util/number/index.js` | `app/util/number/bigint` |
201+
| Sibling feature directories (route-isolation zones) | Only import from your own feature or shared `app/components/UI/` |
202+
203+
- One allowed `eslint-disable`: `// eslint-disable-next-line @typescript-eslint/no-require-imports` inside a `jest.mock` factory that uses `require('react-native')`
204+
205+
---
206+
207+
## 9. File templates
208+
209+
Replace `Foo` with the `PascalCase` component name and `foo` with its `kebab-case` form. Adjust `../` import depth to match the actual file location.
210+
211+
### `Foo.types.ts`
212+
213+
```ts
214+
export interface FooProps {
215+
/**
216+
* Whether the component is visible.
217+
*/
218+
isVisible: boolean;
219+
/**
220+
* Callback fired when the user dismisses the component.
221+
*/
222+
onClose: () => void;
223+
/**
224+
* Optional test ID for the root element.
225+
*/
226+
testID?: string;
227+
}
228+
```
229+
230+
### `Foo.testIds.ts`
231+
232+
```ts
233+
export const FooSelectorsIDs = {
234+
CONTAINER: 'foo',
235+
TITLE: 'foo-title',
236+
} as const;
237+
```
238+
239+
- Export name: `<ComponentName>SelectorsIDs`
240+
- Keys: `SCREAMING_SNAKE_CASE`
241+
- Values: `dash-case`, prefixed with the component's kebab-case name
242+
243+
**List items:** when the component renders in a list, append a unique data value at render time to avoid duplicate testIDs:
244+
245+
```tsx
246+
// testIds.ts
247+
ITEM: 'foo-item',
248+
249+
// usage
250+
testID={`${FooSelectorsIDs.ITEM}-${item.id}`}
251+
```
252+
253+
### `Foo.tsx`
254+
255+
```tsx
256+
import React from 'react';
257+
import {
258+
Box,
259+
ButtonBase,
260+
Text,
261+
TextColor,
262+
TextVariant,
263+
} from '@metamask/design-system-react-native';
264+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
265+
import { strings } from '../../../../locales/i18n'; // adjust depth
266+
import { FooProps } from './Foo.types';
267+
import { FooSelectorsIDs } from './Foo.testIds';
268+
269+
const Foo: React.FC<FooProps> = ({
270+
isVisible,
271+
onClose,
272+
testID = FooSelectorsIDs.CONTAINER,
273+
}) => {
274+
const tw = useTailwind();
275+
276+
if (!isVisible) return null;
277+
278+
return (
279+
<ButtonBase
280+
onPress={onClose}
281+
testID={testID}
282+
style={({ pressed }) =>
283+
tw.style('px-4 pb-6', pressed && 'bg-pressed')
284+
}
285+
>
286+
<Text
287+
variant={TextVariant.BodyMd}
288+
color={TextColor.TextAlternative}
289+
testID={FooSelectorsIDs.TITLE}
290+
>
291+
{strings('foo.title')}
292+
</Text>
293+
</ButtonBase>
294+
);
295+
};
296+
297+
export default Foo;
298+
```
299+
300+
> For a bottom sheet, add `useElevatedSurface()` — see Section 5.
301+
302+
### `index.ts`
303+
304+
```ts
305+
export { default } from './Foo';
306+
export type { FooProps } from './Foo.types';
307+
export { FooSelectorsIDs } from './Foo.testIds';
308+
```
309+
310+
### `Foo.test.tsx`
311+
312+
```tsx
313+
import React from 'react';
314+
import { fireEvent } from '@testing-library/react-native';
315+
import renderWithProvider from '../../../../util/test/renderWithProvider'; // adjust depth
316+
import Foo from './Foo';
317+
import { FooSelectorsIDs } from './Foo.testIds';
318+
319+
describe('Foo', () => {
320+
const defaultProps = {
321+
isVisible: true,
322+
onClose: jest.fn(),
323+
};
324+
325+
beforeEach(() => {
326+
jest.clearAllMocks();
327+
});
328+
329+
it('renders nothing when isVisible is false', () => {
330+
const { queryByTestId } = renderWithProvider(
331+
<Foo {...defaultProps} isVisible={false} />,
332+
);
333+
334+
expect(queryByTestId(FooSelectorsIDs.CONTAINER)).toBeNull();
335+
});
336+
337+
it('renders the title when visible', () => {
338+
const { getByTestId } = renderWithProvider(<Foo {...defaultProps} />);
339+
340+
expect(getByTestId(FooSelectorsIDs.TITLE)).toBeOnTheScreen();
341+
});
342+
343+
it('calls onClose when dismissed', () => {
344+
const { getByTestId } = renderWithProvider(<Foo {...defaultProps} />);
345+
346+
fireEvent.press(getByTestId(FooSelectorsIDs.CONTAINER));
347+
348+
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
349+
});
350+
});
351+
```
352+
353+
Use `renderWithProvider` when the component:
354+
- reads from the Redux store (selectors, hooks like `useSelector`)
355+
- dispatches actions
356+
- accesses theme tokens via hooks
357+
358+
Use `render` from `@testing-library/react-native` when the component only receives props and has no store/theme dependency.
359+
360+
---
361+
362+
## 10. Parent barrel registration
363+
364+
Register in the feature-level `index.ts` only when the component is consumed outside its own folder.
365+
366+
Add to `app/components/UI/<Feature>/index.ts`:
367+
368+
```ts
369+
export { default as Foo } from './Foo'; // converts the default export to a named export
370+
export type { FooProps } from './Foo';
371+
export { FooSelectorsIDs } from './Foo';
372+
```
373+
374+
Re-exports point at the component's own `index.ts` (`./Foo`), not directly at the implementation file.
375+
376+
---
377+
378+
## 11. Scaffold checklist
379+
380+
- [ ] Directory with `PascalCase` name in the correct location (Section 1)
381+
- [ ] `Foo.types.ts` — recommended; `FooProps` interface with JSDoc per prop, optional `testID?: string`; inline interface acceptable only for trivial components with 1–2 props
382+
- [ ] `Foo.testIds.ts` — recommended; `FooSelectorsIDs as const`, `SCREAMING_SNAKE` keys, `dash-case` values prefixed with kebab component name; skip only when the component has no testable elements
383+
- [ ] `Foo.tsx` — primitives from `@metamask/design-system-react-native` (or `app/component-library` for MetaMask-specific components with no MMDS equivalent and no `@deprecated` annotation); no `View`, no `StyleSheet`; `strings()` for all user-visible copy; every asserted element has `testID` wired from the testIds constant
384+
- [ ] `index.ts` — exports `default`, `type FooProps`, and `FooSelectorsIDs`
385+
- [ ] `Foo.test.tsx` — colocated (not in `__tests__/`); testIds via constant (never raw strings); `toBeOnTheScreen()` for presence, `.toBeNull()` for absence; `beforeEach(jest.clearAllMocks)`
386+
- [ ] ESLint and TypeScript pass: no `any`, no `eslint-disable`, no import fence violations
387+
- [ ] If consumed outside the folder: parent feature `index.ts` updated (Section 10)
388+
- [ ] If bottom sheet: `useElevatedSurface()` applied; `ScrollView` from `react-native-gesture-handler`
389+
- [ ] All user-visible strings use `strings()` — no hardcoded copy
390+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
name: component-scaffold
3+
description: >-
4+
Scaffolds (initializes) a new React Native component for MetaMask
5+
Mobile. Use when the user asks to create, add, generate, scaffold,
6+
initialize, or set up a new component, UI component, or feature
7+
component.
8+
---

0 commit comments

Comments
 (0)