一键导入
my-web
React web development conventions and patterns
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
React web development conventions and patterns
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
Plan and execute 2-week engineering sprints with structured debate, task-by-task execution, and incremental markdown reporting. Use when asked to "plan a sprint", "create a sprint", "execute a sprint", "resume a sprint", or "plan roadmap". Supports pause/resume, tracks progress in markdown files as source of truth. Scans the project, proposes a plan, critiques it, synthesizes a final plan, then executes each task sequentially with review after each one.
Use for planning work
Planning for React web UI implementation with component design focus
Explain how to do logging
基于 SOC 职业分类
| name | my-web |
| description | React web development conventions and patterns |
When writing or reviewing React web application code, follow these principles.
any)Each file exports one React component. File name matches the component name: MessageRow.tsx exports MessageRow.
When a component has sub-components used only by it, group them in a folder:
Sidebar/
Sidebar.tsx -> main component
SidebarItem.tsx -> internal, used only by Sidebar
SidebarSection.tsx -> internal, used only by Sidebar
Simple components with no sub-components stay as standalone files — no folder needed. Do not put unrelated components in one file.
Component names use domain-prefix style — the domain comes first, then the specific part:
Sidebar, SidebarItem, SidebarSection — not Item, SectionMessage, MessageRow, MessageActions — not Row, ActionsChannel, ChannelHeader, ChannelSettings — not Header, SettingsThe prefix makes it immediately clear which domain a component belongs to, even outside its folder. Grep-friendly and unambiguous.
Shared UI primitives (components/ui/) are the exception — Button, Dialog, Avatar need no domain prefix since they are domain-agnostic.
Fragments are large, self-contained UI regions — the biggest building blocks below a page. Think of them like Android Fragments or Atomic Design's organisms: a chat conversation, a profile editor, a settings form. Each fragment owns its own layout, internal state, and child components.
Fragments are not modals, panels, or wrappers — they are the main content regions that a route composes together. A page might render one fragment (full-screen chat) or several side by side (sidebar + chat + thread panel).
ChatConversation, ProfileEditor, ChannelSettings — not ChatModal, ProfilePanel.fragments/ folder. Each fragment gets its own folder, and fragment-specific components go in a components/ subfolder within it:
fragments/
ChatConversation/
ChatConversation.tsx -> the fragment
components/
ChatMessageList.tsx -> specific to this fragment
ChatComposer.tsx -> specific to this fragment
ProfileEditor/
ProfileEditor.tsx -> the fragment
components/
ProfileAvatarUpload.tsx
ProfileFormFields.tsx
components/ folder is for shared, reusable components (UI primitives, domain components used across multiple fragments). Fragment-specific components stay inside the fragment's own components/ subfolder.function WorkspaceRoute() {
const { channelId, threadId } = Route.useParams()
return (
<WorkspaceLayout>
<Sidebar />
<ChatConversation channelId={channelId} />
{threadId && <ThreadPanel threadId={threadId} />}
</WorkspaceLayout>
)
}
Route files handle routing concerns only: declaring the route, params, loaders, error boundaries, and rendering the top-level page component.
Move all business logic, layout, data transformation, and UI orchestration into dedicated components imported by the route.
// Good: thin shell
function ChannelRoute() {
const { channelId } = Route.useParams()
return <ChannelView channelId={channelId} />
}
// Bad: entire page in the route file
function ChannelRoute() {
const { channelId } = Route.useParams()
const messages = useMessages(channelId)
const members = useMembers(channelId)
// ... 200 lines of UI, handlers, and logic
}
Components with loading/placeholder states must never change their size when transitioning between loading and loaded. Reserve the exact dimensions so the layout stays stable.
min-height so surrounding content never shifts.// Good: fixed-height footer — no jump when loading ends
<div className="h-10 flex items-center justify-center">
{isLoading ? <Spinner /> : hasMore ? null : <span className="text-muted">No more items</span>}
</div>
// Bad: footer collapses when loading ends, list jumps
{isLoading && <Spinner />}
When list rows have hover states (highlight, action buttons, menus), getting the behavior right is critical. Flickering, ghost hovers, or disappearing menus feel broken.
One hovered row at a time. Track hoveredId in state — only the row matching hoveredId renders its hover appearance. Do not rely on CSS :hover alone for anything that shows interactive controls, because CSS hover cannot coordinate with menu-open state.
Menu open locks the hover. When a row's context menu or dropdown is open, that row stays hovered regardless of where the mouse moves. The menu and hover highlight must remain until the menu is explicitly closed (click outside, Escape, or selecting an item).
No flicker on transitions. Moving the mouse between the row content and its action buttons (or the open menu popover) must not cause the hover to blink off and back on. Hover state is driven by the row container's mouseenter/mouseleave, not by individual child elements.
Hover changes happen on every mouse move — re-rendering the entire list on each hover is unacceptable. Use a React context to broadcast hover/menu state so that only the row entering hover and the row leaving hover re-render, not the whole list.
// --- HoverContext.tsx ---
type HoverState = {
hoveredId: string | null
menuOpenId: string | null
onMouseEnter: (id: string) => void
onMouseLeave: () => void
onMenuOpen: (id: string) => void
onMenuClose: () => void
}
const HoverContext = createContext<HoverState>(null!)
function HoverProvider({ children }: { children: ReactNode }) {
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const onMouseEnter = useCallback((id: string) => {
setHoveredId((prev) => prev === id ? prev : id)
}, [])
const onMouseLeave = useCallback(() => {
setHoveredId(null)
}, [])
const onMenuOpen = useCallback((id: string) => {
setMenuOpenId(id)
setHoveredId(id)
}, [])
const onMenuClose = useCallback(() => {
setMenuOpenId(null)
}, [])
const value = useMemo(
() => ({ hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose }),
[hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose],
)
return <HoverContext.Provider value={value}>{children}</HoverContext.Provider>
}
// --- useRowHover.ts ---
// Each row subscribes to context but only re-renders when *its own* hover status changes.
function useRowHover(id: string) {
const { hoveredId, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose } =
useContext(HoverContext)
const isHovered = hoveredId === id
const isMenuOpen = menuOpenId === id
// Stable callbacks bound to this row's id
const handlers = useMemo(() => ({
onMouseEnter: () => onMouseEnter(id),
onMouseLeave: menuOpenId ? undefined : onMouseLeave,
onMenuOpen: () => onMenuOpen(id),
onMenuClose,
}), [id, menuOpenId, onMouseEnter, onMouseLeave, onMenuOpen, onMenuClose])
return { isHovered, isMenuOpen, handlers }
}
The list component itself never re-renders on hover — it wraps children in HoverProvider and each memo'd row reads its own hover state via useRowHover:
const MessageRow = memo(function MessageRow({ message }: { message: Message }) {
const { isHovered, isMenuOpen, handlers } = useRowHover(message.id)
return (
<div onMouseEnter={handlers.onMouseEnter} onMouseLeave={handlers.onMouseLeave}>
<MessageContent message={message} />
{(isHovered || isMenuOpen) && (
<MessageActions onMenuOpen={handlers.onMenuOpen} onMenuClose={handlers.onMenuClose} />
)}
</div>
)
})
function MessageList({ messages }: { messages: Message[] }) {
return (
<HoverProvider>
{messages.map((msg) => (
<MessageRow key={msg.id} message={msg} />
))}
</HoverProvider>
)
}
When hover moves from row A to row B: context value changes → only row A and row B re-render (their isHovered changed). All other rows stay untouched because memo sees the same props and useRowHover returns the same false/false.
Important caveat: the context approach above re-renders all rows when hoveredId changes because every row consumes the full context. To truly limit re-renders to only the two affected rows, use a ref + subscription pattern or a Zustand store instead of context — rows subscribe and only re-render when their own derived isHovered value flips. The context pattern shown is the simple starting point; if profiling shows too many re-renders in large lists, switch to a store-based approach.
Use agent-browser to verify hover interactions. These are the specific scenarios to test:
1. Basic hover highlighting:
2. Action buttons visibility:
3. Menu open — hover lock:
4. Menu open — no blink on click:
5. Menu close — return to normal:
mouseleave events when the overlay disappears, leading to a hover blink between menu close and the browser re-firing mouseenter. The implementation must account for this.6. Edge cases:
useEffectuseEffect is a code smell in most cases. Before reaching for it, consider alternatives:
useMemo. Never useEffect + setState to mirror a prop — just derive it.useRef + useLayoutEffect only when truly necessary (focus, scroll, measure).useEffect + fetch + setState.useSyncExternalStore or the store's own hook, not useEffect with manual subscribe/unsubscribe.Legitimate uses are rare: setting up/tearing down non-React subscriptions with no hook abstraction, or one-time initialization with no better home. When you do use one, leave a comment explaining why.
uiStore (modals, drafts, sidebar), connectionStore (online/offline), toastStore.useShallow to keep snapshots stable and avoid render loops.// Good: stable selector with useShallow
const channels = useStore(useShallow((s) => s.channels.filter((c) => !c.archived)))
// Bad: creates new array reference every render
const channels = useStore((s) => s.channels.filter((c) => !c.archived))
Authorization header.app/
routes/ -> route files (thin shells only)
fragments/ -> large self-contained UI regions (ChatConversation/, ProfileEditor/, ...)
[Fragment]/
Fragment.tsx
components/ -> components specific to this fragment
components/
ui/ -> shadcn/ui primitives (button, input, dialog, avatar, etc.)
[domain]/ -> shared domain components reused across fragments
stores/ -> Zustand stores (one per concern)
lib/ -> utilities, hooks, session management, route guards
api/ -> typed API client, HTTP helpers, SSE subscriber
types.ts -> shared TypeScript types
channelCreate not createChannel, messageSend not sendMessage.domainVerb.ts + domainVerb.spec.ts side by side.*.spec.ts, live next to the file under test.index.ts files.Date only at boundaries for parsing/formatting.any.For non-trivial UI components, add a section on a dev route (/dev) for visual testing in isolation:
Use the agent-browser skill to visually verify UI changes. After building or modifying components, launch the dev server and use agent-browser to navigate to the page, take screenshots, and confirm the result matches expectations.
deviceScaleFactor on Retina displays — a 1440x900 viewport at 2x produces a 2880x1800 screenshot).This replaces manual "open the browser and check" steps — let the agent verify visually.
useEffect — derive state, handle events, use proper hooksuseShallow for derived selectorsdomainVerb.ts), tests next to source