From 319d0af1b185ba177897f70734de6315208d7a2a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 14 Sep 2026 21:14:49 +0900 Subject: [PATCH 1/3] feat(skills): carry devfive-frontend and let a skill be more than one file devup_figma_export returns one screen's TSX, but a project is a tree of routes, and knowing devup-ui does not tell an agent which file that TSX becomes. The gap is not hypothetical. A vinext project created from service-template was implemented as a Vite SPA - a main.tsx swapping screens on local state, with authored CSS files beside code that has globalCss() - by an agent that had been given the devup-ui skill and nothing else. devup-ui is a styling skill and correctly says nothing about project structure, so the rules that would have caught it live in devfive-frontend, which no bare machine had. devfive-frontend is five files, so carrying it meant a skill could no longer be one document. A third origin carries it. An `embedded` skill is copied from another DevFive repository and pins the commit it copied, so refresh-skills.mjs can move it forward. An `own` skill is authored here, has no second copy to drift from, and pins no commit - printing "at unknown" would read as a lost revision rather than one that never existed. Making that an origin rather than a special case keeps both on the same install path. The manifest now records documents[{path,bytes,sha256}] per skill instead of one digest per skill, and every origin uses skills//, so a document's source path and its install path are the same string and cannot disagree. install() stages a skill's documents in one OutputTransaction: a SKILL.md that survived while its four references did not is the failure this exists to avoid, because it looks installed and its links go nowhere. Only the entry document is annotated, so the manifest digest stays true of every reference that lands on disk. Two things surfaced while building it. .gitattributes matched skills/*.md, and a git pattern containing a slash does not let * cross one. Moving the documents into per-skill directories silently dropped the -text attribute that the file's own comment says exists to stop a Windows checkout from failing the integrity check on that platform alone. It now matches skills/**/*.md, confirmed with git check-attr. refresh-skills.mjs skipped own skills entirely, which left no way to update a digest after editing a document this repository authors - the trap that makes someone write a SHA-256 by hand. For an own skill the direction reverses: the file on disk is the truth and the script reseals the manifest from it. The integration tests spelled out how many skills exist, so adding one failed four tests that had nothing to say about it. They derive the counts now. devfive-frontend also gained the two rules the SPA incident needed - that vite.config.ts does not mean Vite, because vinext runs Next App Router on Vite, and that no .css or .scss belongs in application source - plus the extraction rule from devup-ui issue 663, checked against the extractor itself: an inline object literal indexed at a style prop extracts to static classes, an external object referenced by name becomes a CSS variable, and an external object of css() results is neither, because css() already extracted at its own call site and className is never a style-extraction source. --- .../changepack_log_multi_document_skills.json | 7 + .gitattributes | 2 +- crates/devup-mcp/src/server/resources.rs | 26 +- crates/devup-mcp/src/server/skills.rs | 454 +++- .../server/skills/devfive-frontend/SKILL.md | 553 ++++ .../references/anti-patterns.md | 377 +++ .../references/common-patterns.md | 537 ++++ .../references/critical-rules.md | 2256 +++++++++++++++++ .../references/theme-colors.md | 151 ++ .../skills/{devup-ui.md => devup-ui/SKILL.md} | 0 .../devup-mcp/src/server/skills/manifest.json | 68 +- .../skills/{vespera.md => vespera/SKILL.md} | 0 .../{vespertide.md => vespertide/SKILL.md} | 0 crates/devup-mcp/tests/skills_install.rs | 98 +- scripts/refresh-skills.mjs | 84 +- 15 files changed, 4483 insertions(+), 130 deletions(-) create mode 100644 .changepacks/changepack_log_multi_document_skills.json create mode 100644 crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md create mode 100644 crates/devup-mcp/src/server/skills/devfive-frontend/references/anti-patterns.md create mode 100644 crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md create mode 100644 crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md create mode 100644 crates/devup-mcp/src/server/skills/devfive-frontend/references/theme-colors.md rename crates/devup-mcp/src/server/skills/{devup-ui.md => devup-ui/SKILL.md} (100%) rename crates/devup-mcp/src/server/skills/{vespera.md => vespera/SKILL.md} (100%) rename crates/devup-mcp/src/server/skills/{vespertide.md => vespertide/SKILL.md} (100%) diff --git a/.changepacks/changepack_log_multi_document_skills.json b/.changepacks/changepack_log_multi_document_skills.json new file mode 100644 index 00000000..37a166a9 --- /dev/null +++ b/.changepacks/changepack_log_multi_document_skills.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "A skill can now be more than one file, and devfive-frontend is carried as one. devup_figma_export returns one screen's TSX, but a project is a tree of routes, and knowing devup-ui does not tell an agent which file that TSX becomes. The gap is not hypothetical: a vinext project built from service-template was implemented as a Vite SPA with hand-rolled state routing and authored CSS files, by an agent that had been given the devup-ui skill and nothing else. devup-ui is a styling skill and correctly says nothing about project structure, so the rules that would have caught it were in devfive-frontend, which no bare machine had. A third origin, own, carries it. An embedded skill is copied from another DevFive repository and pins the commit it copied, so scripts/refresh-skills.mjs can move it forward; an own skill is authored in this repository, has no second copy to drift from, and pins no commit - printing 'at unknown' would read as a lost revision rather than one that never existed. The distinction is an origin rather than a special case so both install through exactly the same path. The manifest now records documents[{path,bytes,sha256}] per skill instead of one digest per skill, and the layout is skills// for every origin, so a document's source path and its install path are the same string and cannot disagree. install() stages a skill's documents in one OutputTransaction: a SKILL.md that survived while its four references did not is the failure this feature exists to avoid, because it looks installed and its links go nowhere. Only the entry document is annotated with provenance; references are written byte for byte, which keeps the manifest digest true of the installed file. Two things surfaced while building it. .gitattributes matched skills/*.md, and a git pattern containing a slash does not let * cross one, so moving the documents into per-skill directories silently dropped the -text attribute that the file's own comment says exists to stop a Windows checkout from failing the integrity check on that platform alone; it now matches skills/**/*.md, verified with git check-attr. And refresh-skills.mjs skipped own skills entirely, which left no way to update a digest after editing a document this repository authors - the trap that makes someone write a SHA-256 by hand. For an own skill the direction reverses, the file on disk is the truth, and the script reseals the manifest from it; --check reports the disagreement and exits 1. The integration tests spelled out how many skills exist, so adding one failed four tests that had nothing to say about it; they now derive the counts from the report. devfive-frontend also gained the two rules the SPA incident needed - vite.config.ts does not mean Vite, because vinext runs Next App Router on Vite, and no .css or .scss belongs in application source - plus the extraction rule from devup-ui issue 663, verified against the extractor: an inline object literal indexed at a style prop extracts to static classes, an external object referenced by name becomes a CSS variable, and an external object of css() results is neither, because css() already extracted at its own call site and className is never a style-extraction source.", + "date": "2026-09-14T21:30:00+09:00" +} diff --git a/.gitattributes b/.gitattributes index 840f6409..ebd7e1d6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,5 +5,5 @@ # made the integrity check fail on that platform alone while passing on Linux # and macOS. `-text` is stronger than `eol=lf` on purpose - it says these bytes # are not git's to touch in either direction. -crates/devup-mcp/src/server/skills/*.md -text +crates/devup-mcp/src/server/skills/**/*.md -text crates/devup-mcp/src/server/skills/manifest.json -text diff --git a/crates/devup-mcp/src/server/resources.rs b/crates/devup-mcp/src/server/resources.rs index 8e1774b0..15d7b9f3 100644 --- a/crates/devup-mcp/src/server/resources.rs +++ b/crates/devup-mcp/src/server/resources.rs @@ -101,18 +101,32 @@ fn guide_resource() -> Resource { .with_mime_type(guide::GUIDE_MIME_TYPE) } -/// Only the embedded skills are readable here. An external one has no bytes in -/// this binary, so publishing a URI for it would advertise a document that -/// cannot be served. +/// Only the skills this binary carries are readable here. An external one has +/// no bytes in this binary, so publishing a URI for it would advertise a +/// document that cannot be served. +/// +/// One URI serves one document - the entry `SKILL.md`. A multi-document skill's +/// references are not addressable here, which is the second reason installing +/// beats reading: `devup_skills` writes the whole set, so the links inside the +/// document it writes resolve. fn skill_resource(skill: &'static skills::Skill) -> Option { - skill.text?; + skill.entry_text()?; + let extra = skill.record.documents.len().saturating_sub(1); Some( Resource::new(skill.uri.clone(), skill.resource_name.clone()) .with_title(skill.record.title.clone()) .with_description(format!( "{} Installing it with devup_skills is better than reading it here: your skill \ - loader then applies it on its own triggers, in this session and later ones.", - skill.record.description + loader then applies it on its own triggers, in this session and later ones{}.", + skill.record.description, + if extra == 0 { + String::new() + } else { + format!( + ", and this URI serves only SKILL.md while the install also writes its \ + {extra} reference document(s)" + ) + } )) .with_mime_type(skills::MIME_TYPE), ) diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index 2ab122be..2c5322fa 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -16,12 +16,21 @@ //! **whether each skill is installed** and hand over the one action that //! installs it - a concrete gap the agent can close, rather than advice. //! -//! ## Two origins, and why they are handled differently +//! ## Three origins, and why they are handled differently //! -//! `embedded` skills are DevFive's own - devup-ui, vespera, vespertide. Their -//! canonical `SKILL.md` is vendored into the binary, so installing them needs -//! no network. That matters because the situation this exists for, a bare -//! machine, is the one in which a download is least likely to work. +//! `embedded` skills are DevFive's own but live in another repository - +//! devup-ui, vespera, vespertide. Their canonical `SKILL.md` is vendored into +//! the binary, so installing them needs no network. That matters because the +//! situation this exists for, a bare machine, is the one in which a download is +//! least likely to work. A vendored copy can fall behind its repository, so +//! each one pins the commit it copied and `scripts/refresh-skills.mjs` is how +//! that copy is moved forward. +//! +//! `own` skills are written here, in this repository - devfive-frontend. There +//! is no upstream to vendor from and nothing for the refresh script to compare +//! against, so they pin no commit; this repository's own history is their +//! provenance. They are otherwise installed exactly like an embedded skill, +//! which is the point of giving them an origin rather than a special case. //! //! `external` skills belong to someone else. vercel-labs/agent-skills publishes //! **no LICENSE file**, so its content is all-rights-reserved and devup-mcp @@ -47,16 +56,63 @@ pub const MIME_TYPE: &str = "text/markdown"; /// literal that can disagree about which commit is in the binary. pub const MANIFEST_JSON: &str = include_str!("skills/manifest.json"); -/// Name to text, for the embedded origin only. `include_str!` needs a literal -/// path, so this is the one place the set is spelled out; -/// [`tests::embedded_and_external_entries_are_each_well_formed`] holds it to the -/// manifest in both directions. -const EMBEDDED: &[(&str, &str)] = &[ - ("devup-ui", include_str!("skills/devup-ui.md")), - ("vespera", include_str!("skills/vespera.md")), - ("vespertide", include_str!("skills/vespertide.md")), +/// Skill name to its documents, for the origins that carry text. `include_str!` +/// needs a literal path, so this is the one place the set is spelled out; +/// [`tests::every_origin_is_well_formed`] holds it to the manifest in both +/// directions. +/// +/// The inner key is the document's path relative to the skill directory, and it +/// is deliberately the same string on both sides: `references/theme-colors.md` +/// is read from `skills/devfive-frontend/references/theme-colors.md` here and +/// written to `/devfive-frontend/references/theme-colors.md` on install. +/// A skill whose list is one `SKILL.md` is the single-file case, not a +/// different one. +type Documents = &'static [(&'static str, &'static str)]; + +const EMBEDDED: &[(&str, Documents)] = &[ + ( + "devup-ui", + &[("SKILL.md", include_str!("skills/devup-ui/SKILL.md"))], + ), + ( + "devfive-frontend", + &[ + ( + "SKILL.md", + include_str!("skills/devfive-frontend/SKILL.md"), + ), + ( + "references/critical-rules.md", + include_str!("skills/devfive-frontend/references/critical-rules.md"), + ), + ( + "references/anti-patterns.md", + include_str!("skills/devfive-frontend/references/anti-patterns.md"), + ), + ( + "references/common-patterns.md", + include_str!("skills/devfive-frontend/references/common-patterns.md"), + ), + ( + "references/theme-colors.md", + include_str!("skills/devfive-frontend/references/theme-colors.md"), + ), + ], + ), + ( + "vespera", + &[("SKILL.md", include_str!("skills/vespera/SKILL.md"))], + ), + ( + "vespertide", + &[("SKILL.md", include_str!("skills/vespertide/SKILL.md"))], + ), ]; +/// The document every skill loader opens first. A skill is installed when this +/// file is on disk; the rest are what it links to. +pub const ENTRY_DOCUMENT: &str = "SKILL.md"; + /// Where agent runtimes keep project-local skills, in the order they are /// preferred when none exists yet. /// @@ -105,12 +161,32 @@ fn frontmatter_end(text: &str) -> Option { #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Origin { - /// Vendored into this binary; installable with no network. + /// Copied from another DevFive repository into this binary; installable + /// with no network, and pins the revision it copied. Embedded, + /// Written in this repository. Installable with no network like an embedded + /// skill, but pins no upstream revision because it has no upstream. + Own, /// Someone else's, installed from source by the agent. External, } +impl Origin { + /// The word used for this origin everywhere a caller can read it. + pub fn as_str(self) -> &'static str { + match self { + Self::Embedded => "embedded", + Self::Own => "own", + Self::External => "external", + } + } + + /// Whether devup-mcp carries this skill's text and can therefore write it. + pub fn is_carried(self) -> bool { + matches!(self, Self::Embedded | Self::Own) + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillRecord { @@ -126,11 +202,14 @@ pub struct SkillRecord { pub source_url: String, pub latest_url: String, - // Embedded only. + /// Every file this skill installs. Present for the origins devup-mcp + /// carries, absent for external. + #[serde(default)] + pub documents: Vec, + + // Embedded only: an `own` skill has no upstream revision to pin. pub commit: Option, pub committed_at: Option, - pub sha256: Option, - pub bytes: Option, // External only. pub install_command: Option, @@ -138,6 +217,22 @@ pub struct SkillRecord { pub license_note: Option, } +/// One file of a skill, and the bytes it is supposed to be. +/// +/// The digest is what makes a hand-edit of a vendored copy fail the build +/// instead of shipping quietly, so it is recorded per file rather than once per +/// skill: a five-document skill whose fourth reference was edited has to fail +/// on that reference, naming it. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentRecord { + /// Relative to the skill directory, on both sides: the path under + /// `skills//` here and under `//` once installed. + pub path: String, + pub bytes: usize, + pub sha256: String, +} + #[derive(Debug, Deserialize)] struct Manifest { skills: Vec, @@ -149,8 +244,8 @@ pub struct Skill { /// The MCP resource name. Prefixed so it cannot collide with the usage /// guide or with a generated output manifest. pub resource_name: String, - /// `Some` for the embedded origin, `None` for external. - pub text: Option<&'static str>, + /// `Some` for the origins devup-mcp carries, `None` for external. + pub texts: Option, } static SKILLS: LazyLock> = LazyLock::new(|| { @@ -160,17 +255,17 @@ static SKILLS: LazyLock> = LazyLock::new(|| { .skills .into_iter() .map(|record| { - let text = (record.origin == Origin::Embedded).then(|| { + let texts = record.origin.is_carried().then(|| { EMBEDDED .iter() .find(|(name, _)| *name == record.name) - .map(|(_, text)| *text) - .expect("every embedded manifest entry has a document") + .map(|(_, documents)| *documents) + .expect("every carried manifest entry has documents") }); Skill { uri: uri_for(&record.name), resource_name: format!("devup-skill-{}", record.name), - text, + texts, record, } }) @@ -206,9 +301,20 @@ pub fn existing_roots(project: &Path) -> Vec { .collect() } +/// Where one of a skill's documents lives under a given root. +/// +/// `relative` is split on `/` for the same reason [`join_root`] splits: a +/// `references/theme-colors.md` joined whole would keep its forward slash and +/// report `...\devfive-frontend\references/theme-colors.md` on Windows. +pub fn document_path(root: &Path, name: &str, relative: &str) -> PathBuf { + relative + .split('/') + .fold(root.join(name), |path, part| path.join(part)) +} + /// Where a skill's `SKILL.md` lives under a given root. pub fn install_path(root: &Path, name: &str) -> PathBuf { - root.join(name).join("SKILL.md") + document_path(root, name, ENTRY_DOCUMENT) } /// The root an install would use: the first that already exists, else the @@ -250,9 +356,72 @@ impl Skill { /// /// `None` for external skills, which have no text here to carry. pub fn document(&self) -> Option { - let text = self.text?; + Some(self.annotated(self.entry_text()?)) + } + + /// The entry document's text exactly as it sits in the binary, with no + /// provenance note added. `None` for external skills. + pub fn entry_text(&self) -> Option<&'static str> { + self.texts? + .iter() + .find(|(path, _)| *path == ENTRY_DOCUMENT) + .map(|(_, text)| *text) + } + + /// Every file an install writes, as `(path relative to the skill + /// directory, contents)`. + /// + /// Only the entry document is annotated. A reference file is written byte + /// for byte, which keeps the digest in the manifest true of the installed + /// file as well - the note is worth breaking that for on the one document a + /// reader opens cold, and not on the four it links to. + pub fn installable_documents(&self) -> Option> { + Some( + self.texts? + .iter() + .map(|(path, text)| { + let contents = if *path == ENTRY_DOCUMENT { + self.annotated(text) + } else { + (*text).to_owned() + }; + (*path, contents) + }) + .collect(), + ) + } + + /// The document with its provenance note placed after any frontmatter. + fn annotated(&self, text: &str) -> String { + let note = self.provenance_note(); + match frontmatter_end(text) { + Some(end) => format!("{}\n{note}\n{}", &text[..end], &text[end..]), + None => format!("{note}\n\n{text}"), + } + } + + /// What a reader who finds this file on disk needs in order to decide + /// whether to trust it over the repository. + /// + /// An `own` skill gets a different note, not a filled-in version of the + /// vendored one: it has no upstream commit, and printing "at unknown" would + /// read as a lost revision rather than as one that never existed. + fn provenance_note(&self) -> String { let r = &self.record; - let note = format!( + if r.origin == Origin::Own { + return format!( + "", + repo = r.repo, + path = r.path, + latest = r.latest_url, + used = r.used_for, + ); + } + format!( "
+
+``` + +### The `css()` exception + +An external object holding **`css()` results** is not the same thing and is +fine. `css()` runs at build time and returns a plain className string, so the +extraction already happened at the `css()` call; the object only carries +strings, and no style prop is involved. + +```tsx +// FINE - extraction happened inside css(); this object holds classNames +const variantStyles = { + primary: css({ bg: '$primary', color: '#FFF' }), + secondary: css({ bg: '$gray100', color: '$text' }), +} + +``` + +The rule is about **style prop values**, not about objects. "No external style +objects" below means no external object of style *values*. + +## Project Structure + +DevFive projects use **monorepo structure**: + +``` +project-root/ +├── apps/ # Frontend applications +│ ├── front/ # Main web app +│ ├── admin/ # Admin panel +│ └── app/ # React Native (Expo) +├── apis/ # Backend services +├── shared/ # Shared packages +├── package.json # Root (includes lint:fix command) +└── bun.lock # bun usage indicator +``` + +### Frontend App Structure (apps/front/src/) + +``` +src/ +├── app/ # Next.js App Router +│ ├── (auth)/ # Route Groups (auth required) +│ ├── (public)/ # Route Groups (public) +│ ├── layout.tsx # Root layout +│ └── page.tsx +├── components/ +│ ├── common/ # Shared components (multi-page use) +│ ├── layout/ # Layout components (Header, Footer, etc.) +│ ├── pages/ # Page-specific components +│ │ ├── my-page/ # /my-page only +│ │ ├── login/ # /login only +│ │ └── home/ # / (home) only +│ └── provider.tsx # Global Provider composition +├── contexts/ # Context definitions +├── hooks/ # Custom hooks +├── stores/ # Zustand stores +├── utils/ # Utility functions +└── api.ts # API client setup +``` + +### Component Location Rules + +| Scope | Location | Example | +|-------|----------|---------| +| **app/ folder** | **Only `layout.tsx`, `page.tsx`** | No other component files! | +| Multi-page shared | `components/` or `components/common/` | `Button.tsx`, `Modal.tsx` | +| Layout related | `components/layout/` | `Header.tsx`, `Footer.tsx` | +| Page-specific | `components/pages/{page-path}/` | `pages/my-page/ProfileCard.tsx` | +| **Single-color SVG icons** | `public/icons/` (as `.svg` file) | `icons/arrow-right.svg` | + +> **CRITICAL: `src/app/` 폴더에는 `layout.tsx`와 `page.tsx`만 존재해야 함!** +> 다른 컴포넌트는 `src/components/` 내에 위치. + +### SVG Icon Rule + +**Single-color SVG → `public/icons/*.svg`, NOT a React component.** + +```tsx +// Good - single-color SVG as file +// public/icons/arrow-right.svg + + + + +// Usage in component +arrow + +// Bad - single-color SVG as React component (unnecessary JS bundle) +// components/icons/ArrowRightIcon.tsx +export function ArrowRightIcon() { + return ( + + + + ) +} +``` + +**When to use React component for SVG:** +- Multi-color SVG with dynamic color props +- SVG with animation/interaction logic +- SVG that changes based on state + +## Critical Rules Summary + +> **Full details: See [references/critical-rules.md](references/critical-rules.md)** + +### Server Component First (CRITICAL) + +Maximize Server Components to minimize JS bundle size. + +**'use client' Decision Guide:** + +| Feature | 'use client'? | +|---------|--------------| +| Static UI rendering | No | +| Data fetch (async/await) | No | +| `useState`, `useEffect` | **Yes** | +| Event handlers **defined internally** | **Yes** | +| Event handlers **received via props** | **No** | +| `useRouter`, `usePathname` | **Yes** | +| `framer-motion` animation | **Yes** | + +**Key insight:** Components receiving optional functions via props don't need `'use client'` - but **only if you pass props directly without wrapping in arrow function**. + +```tsx +// NO 'use client' needed - direct prop pass +interface CardProps { + onClick?: () => void +} + +export function Card({ onClick }: CardProps) { + // Good patterns (can stay Server Component) + return Content + // or: onClick={onClick ? onClick : undefined} + // or: onClick={onClick ? () => onClick() : undefined} +} + +// ANTI-PATTERN - forces 'use client' due to arrow function +export function Card({ onClick }: CardProps) { + return onClick?.()}>Content // BAD! + // or: onClick={() => onClick ? onClick() : undefined} // BAD! +} + +// NEEDS 'use client' - defines handler internally +'use client' +export function Card() { + const handleClick = () => console.log('clicked') + return Content +} +``` + +**Why?** Wrapping in arrow function (`() => ...`) means you're **defining** a new function, which requires `'use client'`. Direct prop pass doesn't define anything. + +### Export Rules + +- **One component per file** - Each file exports exactly one component +- `export default` **only in `page.tsx`** +- Page components: descriptive name + `Page` suffix (e.g., `LoginPage`, not `Page`) +- No `'use client'` in `page.tsx` + +### Required Verification + +```bash +bun tsc --noEmit && bun run lint:fix && bun run lint +``` + +### Dev Server + +```bash +# Good +cd apps/front && bun dev + +# Bad (zombie process risk) +bun -F front dev +``` + +## File Naming Conventions + +| Type | Pattern | Example | +|------|---------|---------| +| Components | PascalCase | `CommonButton.tsx` | +| Hooks | camelCase + use prefix | `useBookmark.ts` | +| Utils | camelCase | `formatPrice.ts` | +| Context | PascalCase + Context | `ToastContext.tsx` | +| Page | `page.tsx` | `app/(auth)/my-page/page.tsx` | + +## Component Structure + +```tsx +'use client' // Only when necessary! + +// 1. React/Next.js imports +import { useState } from 'react' +import Link from 'next/link' + +// 2. Third-party imports +import { AnimatePresence } from 'framer-motion' + +// 3. devup-ui imports +import { Box, Flex, Text, VStack, css } from '@devup-ui/react' + +// 4. Internal imports with @ aliases +import { useToastContext } from '@/contexts/ToastContext' + +// 5. Relative imports +import { ChildComponent } from './ChildComponent' + +// 6. Interface definitions +interface ButtonProps { + variant: 'primary' | 'secondary' + children: React.ReactNode +} + +// 7. Style objects +const variantStyles = { + primary: css({ bg: '$primary', color: '#FFF' }), + secondary: css({ bg: '$gray100', color: '$text' }), +} + +// 8. Named export (default export only in page.tsx!) +export function Button({ variant, children }: ButtonProps) { + return ( + + {children} + + ) +} +``` + +## Page Component Pattern + +```tsx +// app/(auth)/my-page/page.tsx +// No 'use client'! Page is Server Component + +import { Suspense } from 'react' +import { Header } from '@/components/layout/Header' +import { MyPageContent } from '@/components/pages/my-page/MyPageContent' +import { Loading } from '@/components/common/Loading' + +export default function MyPagePage() { // Name must end with Page + return ( + +
+ }> + + + + ) +} +``` + +## Context Provider Pattern + +```tsx +// contexts/ToastContext.tsx +'use client' +import { createContext, ReactNode, useCallback, useContext, useState } from 'react' + +interface ToastContextValue { + showToast: (config: { type: 'success' | 'error'; message: string }) => void +} + +const ToastContext = createContext(undefined) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((config) => { + const id = Math.random().toString(36).substring(2, 9) + setToasts((prev) => [...prev, { ...config, id }]) + }, []) + + return ( + + {children} + + ) +} + +export function useToastContext() { + const context = useContext(ToastContext) + if (context === undefined) { + throw new Error('useToastContext must be used within a ToastProvider') + } + return context +} +``` + +## API Integration + +```tsx +// Server Component - direct fetch (recommended) +async function getData() { + const res = await fetch('https://api.example.com/data') + return res.json() +} + +export default async function DataPage() { + const data = await getData() + return +} + +// Client Component - react-query +'use client' +import { queryApi } from '@/api' + +export function DataList() { + const { data, isError, isPending } = queryApi.useQuery('get', '/items') + + if (isPending) return + if (isError) return + + return +} +``` + +## Form Pattern + +```tsx +'use client' +import { FormProvider, useForm } from 'react-hook-form' + +interface FormData { + email: string + password: string +} + +export function LoginForm() { + const methods = useForm() + + return ( + + + + + + + + + ) +} +``` + +## ESLint Configuration + +```js +// eslint.config.mjs +import { configs } from 'eslint-plugin-devup' + +export default [ + { ignores: ['node_modules/**/*', '.next/**/*'] }, + ...configs.recommended, +] +``` + +## Quick Rules Summary + +| Rule | Requirement | +|------|-------------| +| React Compiler | `reactCompiler: true` in next.config.ts | +| SEO | `metadata` with `openGraph` required | +| Image alt | Always required | +| Interactive states | `_hover`, `_active`, `cursor`, `transition` | +| Query states | Handle `isPending`, `isError` | +| Tests | Required for non-async components | +| JSDoc | Required for components | +| Types | `interface` over `type` | +| Functions | Regular function over arrow function | +| Navigation | `` over `useRouter` for simple nav | +| **HTML tag** | `suppressHydrationWarning` required (theme) | +| **Footer background** | body bg = footer bg, content gets own bg | +| **as const** | Use for object/array literals without explicit type | +| **Inline variant style** | `{{ sm: '36px', md: '44px' }[size]}`, `typography`만 `as const` 필수 | +| **$color vs var(--color)** | `$color` in JSX prop only, `var(--color)` in external objects | +| **css() returns string** | Use `className={css(...)}`, NOT `{...css(...)}` | +| **No inline style** | Use `className={css({...})}`, NOT `style={{...}}` | +| **No internal handler** | `onClick={!disabled && onChange ? () => onChange() : undefined}` | +| **Direct props over css()** | `w={fullWidth ? '100%' : 'auto'}`, NOT `className={css({...})}` | +| **No external style objects** | No external object of style *values* (`{red:'#f00'}`). An object of `css()` classNames is fine | +| **Framework** | `vinext` in deps = Next App Router. `vite.config.ts` proves nothing. No `main.tsx`/`index.html` | +| **No authored CSS** | No `.css`/`.scss` in source. `resetCss()` + `globalCss()` + style props | +| **No barrel files** | Don't create `index.tsx` just for re-exports | +| **Avoid closure in props** | Pass value to child, let child handle callback | +| **Static strings only** | `'translateX(20px)'` NOT `` `translateX(${val})` `` | +| **No intermediate variables** | `w={{ md: '44px' }[size]}` NOT `const w = ...; w={w}` | +| **app/ folder** | Only `layout.tsx`, `page.tsx` allowed | +| **Isolate client hooks** | Extract hook-dependent parts to separate Client Component | + +## References + +- **[Critical Rules (detailed)](references/critical-rules.md)** - Full rule explanations with examples +- **[Anti-Patterns](references/anti-patterns.md)** - What NOT to do +- **[Theme Colors](references/theme-colors.md)** - Project theme color tokens +- **[Common Patterns](references/common-patterns.md)** - Common UI patterns (Modal, Toast, etc.) diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/anti-patterns.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/anti-patterns.md new file mode 100644 index 00000000..69e97960 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/anti-patterns.md @@ -0,0 +1,377 @@ +# Anti-Patterns (NEVER do) + +## Quick Reference Table + +| Wrong | Right | Why | +|-------|-------|-----| +| `bun -F front dev` | `cd apps/front && bun dev` | Prevent zombie processes | +| `reactCompiler: false` | `reactCompiler: true` | React Compiler required | +| metadata without `openGraph` | Include `openGraph: {...}` | OpenGraph required | +| `` without alt | `...` | Accessibility required | +| Button without `_hover`, `_active` | Add interactive states | UX required | +| Clickable without `cursor` | `cursor="pointer"` | Indicate clickability | +| No `transition` on interactive | `transition="all 0.2s ease"` | Smooth UX | +| Query without `isPending` check | `if (isPending) return ` | Loading UI required | +| Query without `isError` check | `if (isError) return ` | Error UI required | +| Component without test | `__tests__/Component.test.tsx` | Testing required (except async) | +| Component without JSDoc | `/** @description ... */` | Documentation required | +| `export default` in non-page | Named export | default export only in page.tsx | +| `export default function Page()` | `LoginPage`, `DashboardPage` etc | Descriptive name required | +| `'use client'` in page.tsx | Remove it | Page is Server Component | +| `'use client'` overuse | Minimize separation | Minimize JS bundle size | +| Entire component as Client | Use children pattern | Keep Server Components | +| Layout as separate Client component | Place directly in page.tsx | Remove unnecessary JS | +| `async` without `await` in RSC | Remove `async` | Remove unnecessary async | +| `<>` (1 child) | Remove fragment | Remove Fragment if 1 child | +| `` | `` | style prop not extracted | +| Skip type check | `bun tsc --noEmit` | Type check required | +| Skip lint | `bun run lint:fix && bun run lint` | Lint required | +| Create custom Checkbox/Select/etc. | Use `@devup-ui/components` | Use existing components | +| `type Props = {...}` | `interface Props {...}` | Prefer interface | +| `const Comp = () => {}` | `function Comp() {}` | Prefer regular function | +| Unused params/variables | Remove them | Remove unnecessary code | +| `` for internal | `` | Next.js Link required | +| `useRouter` for simple nav | `` | Link works in Server Component, useRouter needs 'use client' | +| Skip loading required skills | Load `/devup-ui` first | Load required skills first | +| `onClick={() => onClick?.()}` | `onClick={onClick}` | Arrow wrapper forces 'use client', direct pass doesn't | +| `onClick={() => fn ? fn() : undefined}` | `onClick={fn ? fn : undefined}` | Arrow wrapper forces 'use client' | +| `onClick={disabled ? undefined : () => fn?.()}` | `onClick={!disabled && fn ? () => fn() : undefined}` | Optional chaining still creates function | +| `` `translateX(${size === 'md' ? '20px' : '16px'})` `` | `size === 'md' ? 'translateX(20px)' : 'translateX(16px)'` | Static strings enable build optimization | +| `const w = size === 'md' ? '44px' : '36px'; w={w}` | `w={{ md: '44px', sm: '36px' }[size]}` | No intermediate variables | +| `typography={{ a: 'x', b: 'y' }[key]}` | `typography={({ a: 'x', b: 'y' } as const)[key]}` | typography requires literal types | +| Component files in `app/` folder | Only `layout.tsx`, `page.tsx` in app/ | Components go to `src/components/` | +| Entire component Client for hooks | Extract only hook-dependent parts | Minimize Client Component scope | + +## Detailed Examples + +### 'use client' Overuse + +```tsx +// WRONG - entire component as client +'use client' +export function ProductPage() { + const [count, setCount] = useState(0) + return ( + + {/* Now unnecessarily client */} + {/* Now unnecessarily client */} + setCount(c => c + 1)} /> + + ) +} + +// RIGHT - minimal client separation +// page.tsx (Server Component) +export default function ProductPage() { + return ( + + {/* Server Component */} + {/* Server Component */} + {/* Only this is Client */} + + ) +} + +// AddButtonWrapper.tsx +'use client' +export function AddButtonWrapper() { + const [count, setCount] = useState(0) + return setCount(c => c + 1)} /> +} +``` + +### Props-based Functions (CRITICAL) + +The key is **how you pass the prop**, not whether you receive it. + +**Good patterns (can stay Server Component):** + +```tsx +interface CardProps { + onClick?: () => void +} + +// Direct pass - OK +export function Card({ onClick }: CardProps) { + return Content +} + +// Conditional direct pass - OK +export function Card({ onClick }: CardProps) { + return Content +} + +// Conditional wrapper (only creates when exists) - OK +export function Card({ onClick }: CardProps) { + return onClick() : undefined}>Content +} +``` + +**Anti-patterns (forces 'use client'):** + +```tsx +// WRONG - arrow function ALWAYS creates a new function +export function Card({ onClick }: CardProps) { + return onClick?.()}>Content +} + +// WRONG - same problem +export function Card({ onClick }: CardProps) { + return onClick ? onClick() : undefined}>Content +} +``` + +**Why?** `onClick={() => ...}` always creates a new arrow function, which means you're **defining** a handler internally. This forces `'use client'`. + +Direct pass (`onClick={onClick}`) just passes the reference - no new function is defined, so no `'use client'` needed. + +### Combined Conditions with && (CRITICAL) + +When you have multiple conditions (disabled, onChange, etc.), combine them ALL with `&&`. + +```tsx +// WRONG - disabled만 체크하고 onChange는 optional chaining +'use client' // 불필요! +export function Checkbox({ checked, onChange, disabled }: CheckboxProps) { + return ( +
onChange?.(!checked)} + > + ... +
+ ) +} +// 문제: disabled=false 일 때, onChange가 없어도 () => undefined?.(!checked) 함수가 생성됨 + +// RIGHT - 모든 조건을 &&로 결합 +export function Checkbox({ checked, onChange, disabled }: CheckboxProps) { + return ( +
onChange(!checked) : undefined} + > + ... +
+ ) +} +// 장점: disabled=true 이거나 onChange가 없으면 함수 생성 안 함 → 'use client' 불필요 +``` + +**Key insight:** `() => fn?.()` ALWAYS creates a function, even if `fn` is undefined. Use `fn ? () => fn() : undefined` instead. + +### Interactive States Missing + +```tsx +// WRONG - missing states + + Click me + + +// RIGHT - all states included + + Click me + +``` + +### Query State Handling + +```tsx +// WRONG - no state handling +export function UserList() { + const { data } = queryApi.useQuery('get', '/users') + return data?.map(user => ) +} + +// RIGHT - all states handled +export function UserList() { + const { data, isError, isPending } = queryApi.useQuery('get', '/users') + + if (isPending) return + if (isError) return + if (!data?.length) return + + return data.map(user => ) +} +``` + +### Navigation + +```tsx +// WRONG - useRouter for simple navigation +'use client' +export function NavButton() { + const router = useRouter() + return +} + +// RIGHT - Link (Server Component compatible) +import Link from 'next/link' + +export function NavButton() { + return +} +``` + +### Template Literals with Dynamic Values + +```tsx +// WRONG - 템플릿 리터럴 내 동적 값 + + +// RIGHT - 완전한 정적 문자열 + + +// BEST - 인라인 객체 인덱싱 + +``` + +### Intermediate Style Variables + +```tsx +// WRONG - 불필요한 중간 변수 +export function Toggle({ size = 'md', checked, disabled, onChange }: ToggleProps) { + const width = size === 'md' ? '44px' : '36px' + const height = size === 'md' ? '24px' : '20px' + const thumbSize = size === 'md' ? '20px' : '16px' + + return ( + + + + ) +} + +// RIGHT - 인라인 객체 인덱싱 +export function Toggle({ size = 'md', checked, disabled, onChange }: ToggleProps) { + return ( + + + + ) +} +``` + +### Component Files in app/ Folder + +``` +// WRONG - app/ 폴더에 컴포넌트 파일 존재 +src/app/ +├── layout.tsx +├── page.tsx +├── DetailPageContent.tsx ❌ 컴포넌트 파일! +└── (auth)/ + ├── page.tsx + └── LoginForm.tsx ❌ 컴포넌트 파일! + +// RIGHT - 컴포넌트는 src/components/ 내에 위치 +src/app/ +├── layout.tsx +├── page.tsx +└── (auth)/ + └── page.tsx + +src/components/ +├── common/ +│ └── Button.tsx ✅ +├── pages/ +│ ├── home/ +│ │ └── HomeContent.tsx ✅ +│ └── auth/ +│ └── LoginForm.tsx ✅ +└── layout/ + └── Header.tsx ✅ +``` + +### Client Hook Isolation (CRITICAL) + +`'use client'`가 필요한 hook (`useRouter`, `useState`, `useEffect` 등)을 사용할 때, +**hook이 필요한 부분만 최소 범위로 분리**합니다. + +```tsx +// WRONG - hook 때문에 전체가 Client Component +'use client' +export function DetailPageContent({ id }: { id: string }) { + const router = useRouter() + + return ( + <> + router.back()} title={`Item #${id}`} /> + + {/* 많은 정적 UI... 전부 불필요하게 Client */} + ...
+ ...
+
+ + + + + + ) +} + +// RIGHT - hook 사용 부분만 분리 +// DetailPageContent.tsx (Server Component) +export function DetailPageContent({ id }: { id: string }) { + return ( + <> + + + ...
{/* Server Component 유지! */} + ...
+
+ + + ) +} + +// PageHeaderWithBack.tsx (Client Component - 최소 범위) +'use client' +export function PageHeaderWithBack({ title }: { title: string }) { + const router = useRouter() + return router.back()} title={title} /> +} + +// DetailPageFooter.tsx (Client Component - 최소 범위) +'use client' +export function DetailPageFooter() { + const router = useRouter() + return ( + + + + + ) +} +``` + +**핵심:** hook이 필요한 부분만 최소 범위로 추출! 나머지 정적 UI는 Server Component로 유지해 JS 번들 최소화. diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md new file mode 100644 index 00000000..be93d2ad --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/common-patterns.md @@ -0,0 +1,537 @@ +# Common Patterns Reference + +데브파이브 프론트엔드 프로젝트에서 자주 사용되는 UI 패턴들입니다. + +## 1. Global Provider Pattern + +`provider.tsx`에서 모든 글로벌 Provider를 조합합니다: + +```tsx +// src/components/provider.tsx +import { BottomSheetProvider } from '@/contexts/BottomSheetContext' +import { LoadingProvider } from '@/contexts/LoadingContext' +import { ModalProvider } from '@/contexts/ModalContext' +import { ToastProvider } from '@/contexts/ToastContext' + +export function Provider({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} +``` + +## 2. Modal Pattern + +### Context 기반 Modal + +```tsx +// contexts/ModalContext.tsx +'use client' +import { createContext, ReactNode, useCallback, useContext, useState } from 'react' +import { Modal } from '@/components/layout/modal' + +interface ModalConfig { + title: ReactNode + description?: ReactNode + onOk?: () => void | Promise + onClose?: () => void + okButton?: ReactNode + closeButton?: ReactNode +} + +interface ModalContextValue { + openModal: (config: ModalConfig) => void + closeModal: () => void +} + +const ModalContext = createContext(undefined) + +export function ModalProvider({ children }: { children: ReactNode }) { + const [modalConfig, setModalConfig] = useState(null) + + const openModal = useCallback((config: ModalConfig) => { + setModalConfig(config) + }, []) + + const closeModal = useCallback(() => { + setModalConfig(null) + }, []) + + return ( + + {children} + { + modalConfig?.onClose?.() + closeModal() + }} + onOk={async () => { + await modalConfig?.onOk?.() + closeModal() + }} + /> + + ) +} + +export function useModalContext() { + const context = useContext(ModalContext) + if (context === undefined) { + throw new Error('useModalContext must be used within a ModalProvider') + } + return context +} +``` + +### 사용 예시 + +```tsx +const { openModal } = useModalContext() + +openModal({ + title: '삭제하시겠습니까?', + description: '이 작업은 되돌릴 수 없습니다.', + onOk: async () => { + await deleteItem() + }, +}) +``` + +## 3. Toast Pattern + +```tsx +// contexts/ToastContext.tsx +'use client' +import { createContext, ReactNode, useCallback, useContext, useState } from 'react' + +export interface ToastConfig { + id: string + type: 'success' | 'error' | 'warning' + message: string + duration?: number +} + +interface ToastContextValue { + showToast: (config: Omit) => void +} + +const ToastContext = createContext(undefined) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((config: Omit) => { + const id = Math.random().toString(36).substring(2, 9) + setToasts((prev) => [...prev, { ...config, id }]) + + // Auto dismiss + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, config.duration ?? 3000) + }, []) + + return ( + + {children} + {/* Toast UI 렌더링 */} + + ) +} + +export function useToastContext() { + const context = useContext(ToastContext) + if (context === undefined) { + throw new Error('useToastContext must be used within a ToastProvider') + } + return context +} +``` + +### 사용 예시 + +```tsx +const { showToast } = useToastContext() + +showToast({ + type: 'success', + message: '저장되었습니다.', + duration: 2000, +}) +``` + +## 4. BottomSheet Pattern + +```tsx +// components/layout/bottom-sheet/index.tsx +'use client' +import { Box, Center, css } from '@devup-ui/react' +import { AnimatePresence } from 'framer-motion' +import { useState } from 'react' +import { MotionDiv } from '@/components/motion' + +interface BottomSheetProps { + children?: React.ReactNode + defaultOpen?: boolean + open?: boolean + onClose?: () => void + viewCloseBar?: boolean +} + +export function BottomSheet({ + children, + defaultOpen = false, + open, + onClose, + viewCloseBar = true, +}: BottomSheetProps) { + const [innerOpen, setInnerOpen] = useState(defaultOpen) + const resultOpen = open ?? innerOpen + + return ( + + {resultOpen && ( + { + setInnerOpen(false) + onClose?.() + }} + > + e.stopPropagation()} + > + {viewCloseBar && ( +
+ +
+ )} + {children} +
+
+ )} +
+ ) +} +``` + +## 5. Loading Pattern + +```tsx +// components/layout/loading/Loading.tsx +import { Box, Center, Flex, Text, VStack } from '@devup-ui/react' + +interface LoadingProps { + message?: React.ReactNode +} + +export function Loading({ message }: LoadingProps) { + return ( +
+ + + {[1, 0.7, 0.4].map((opacity, i) => ( + + ))} + + + {message || '로딩 중...'} + + +
+ ) +} +``` + +## 6. Empty State Pattern + +```tsx +import { Center, Text, VStack } from '@devup-ui/react' +import Image from 'next/image' + +interface EmptyDataProps { + icon?: string + message: string +} + +export function EmptyData({ icon = '/icons/emptyData.svg', message }: EmptyDataProps) { + return ( +
+ + empty + + {message} + + +
+ ) +} +``` + +## 7. Button Variant Pattern + +```tsx +import { css } from '@devup-ui/react' +import clsx from 'clsx' + +const buttonVariants = { + primary: css({ + bg: '$primary', + color: '#FFF', + _hover: { bg: '$primaryBold' }, + _active: { bg: '$primaryExBold', scale: 0.95 }, + }), + secondary: css({ + bg: '$containerBackground', + color: '$text', + border: '1px solid $border', + _hover: { bg: '$gray100' }, + }), + sub: css({ + bg: '$primaryBg', + color: '$primary', + _hover: { bg: '$primaryBgBold', color: '$primaryBold' }, + }), + disabled: css({ + bg: '$gray200', + color: '$gray400', + cursor: 'not-allowed', + }), +} + +interface ButtonProps { + variant: keyof typeof buttonVariants + className?: string + children: React.ReactNode +} + +export function Button({ variant, className, children, ...props }: ButtonProps) { + return ( + + {children} + + ) +} +``` + +## 8. Tab Pattern + +```tsx +'use client' +import { Box, Flex, Text } from '@devup-ui/react' +import { useState } from 'react' + +interface Tab { + id: string + label: string +} + +interface TabsProps { + tabs: Tab[] + defaultTab?: string + onChange?: (tabId: string) => void +} + +export function Tabs({ tabs, defaultTab, onChange }: TabsProps) { + const [activeTab, setActiveTab] = useState(defaultTab ?? tabs[0]?.id) + + const handleTabClick = (tabId: string) => { + setActiveTab(tabId) + onChange?.(tabId) + } + + return ( + + {tabs.map((tab) => ( + handleTabClick(tab.id)} + pb="12px" + > + + {tab.label} + + + ))} + + ) +} +``` + +## 9. Responsive Layout Pattern + +```tsx +// 모바일 퍼스트, 최대 너비 420px 제한 + + {/* Content */} + + +// 반응형 그리드 + + {/* Grid items */} + + +// 반응형 숨김/표시 + + 데스크탑에서만 표시 + + + 모바일에서만 표시 + +``` + +## 10. Motion Pattern + +```tsx +// components/motion.ts +'use client' +import { motion } from 'framer-motion' + +export const MotionDiv = motion.div +export const MotionBox = motion.div + +// 사용 예시 + + {content} + +``` + +## 11. Status Tag Pattern + +```tsx +import { Box, Text } from '@devup-ui/react' + +type Status = 'success' | 'warning' | 'error' | 'info' + +const statusColors: Record = { + success: { bg: '$greenTagBg', color: '$greenTag' }, + warning: { bg: '$orangeTagBg', color: '$orangeTag' }, + error: { bg: '$redTagBg', color: '$redTag' }, + info: { bg: '$blueTagBg', color: '$blueTag' }, +} + +interface StatusTagProps { + status: Status + label: string +} + +export function StatusTag({ status, label }: StatusTagProps) { + const { bg, color } = statusColors[status] + + return ( + + + {label} + + + ) +} +``` + +## 12. Card Pattern + +```tsx +import { Box, Text, VStack } from '@devup-ui/react' + +interface CardProps { + title: string + description?: string + children?: React.ReactNode + onClick?: () => void +} + +export function Card({ title, description, children, onClick }: CardProps) { + return ( + + + {title} + {description && ( + {description} + )} + {children} + + + ) +} +``` diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md new file mode 100644 index 00000000..cc8940b5 --- /dev/null +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/critical-rules.md @@ -0,0 +1,2256 @@ +# Critical Rules - Detailed Reference + +## 1. React Compiler + +Next.js projects **must** enable React Compiler: + +```ts +// next.config.ts +const nextConfig = { + experimental: { + reactCompiler: true, // Required! + }, +} +``` + +## 2. SEO Metadata + +Each page must export `metadata` with **required `openGraph`**: + +```tsx +// app/(auth)/my-page/page.tsx +import { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'My Page | Service', + description: 'Manage your profile and settings.', + openGraph: { + title: 'My Page | Service', + description: 'Manage your profile and settings.', + images: ['/og-image.png'], + }, +} + +export default function MyPagePage() { + return +} +``` + +## 3. Image Alt Required + +All `Image` and `img` tags **must** have `alt` attribute: + +```tsx +// Good +Service logo + +// Bad - missing alt + +``` + +## 4. Interactive States & Transitions + +Interactive elements **must** have hover, active states with cursor and transition: + +```tsx +// Good - all interactive states + + +// Bad - missing interactive states + +``` + +**Required checklist:** + +| Element | Required Props | +|---------|----------------| +| Clickable | `cursor="pointer"` | +| Disabled state | `cursor="not-allowed"`, `_disabled={{...}}` | +| Hover effect | `_hover={{...}}` | +| Click effect | `_active={{...}}` | +| Smooth transition | `transition="all 0.2s ease"` or similar | + +**Transition recommended values:** + +```tsx +// Standard buttons/cards +transition="all 0.2s ease" + +// Fast response (small icons) +transition="all 0.15s ease" + +// Smooth animation (modals, panels) +transition="all 0.3s ease" +``` + +## 5. Export Rules + +| Rule | Description | +|------|-------------| +| **One component per file** | Each file exports exactly one component | +| `export default` only in `page.tsx` | All other files use named export | +| Page components need descriptive name + `Page` suffix | e.g., `LoginPage`, `MyPagePage` (not just `Page`) | +| No `'use client'` in page.tsx | Page must be Server Component | + +```tsx +// Good - one component per file +// Button.tsx +export function Button({ children }: ButtonProps) { + return {children} +} + +// Bad - multiple components in one file +// Button.tsx +export function Button({ children }: ButtonProps) { + return {children} +} + +export function IconButton({ icon }: IconButtonProps) { // Should be in separate file! + return {icon} +} +``` + +```tsx +// Good +export default function LoginPage() { + return +} + +// Bad - just "Page" +export default function Page() { + return +} + +// Bad - 'use client' in page +'use client' // Forbidden! +export default function LoginPage() { ... } +``` + +## 6. Server Component First (CRITICAL) + +> Maximize Server Components to minimize JavaScript bundle size. + +**Core principles:** +- Place base UI structure in `page.tsx`, `layout.tsx` (Server Component) +- Write components without `'use client'` by default +- Split only client-required parts minimally +- Use `children` pattern to pass Server Components to Client Components + +### Correct Structure Pattern + +```tsx +// Good - base structure in page.tsx (Server Component) +// app/(auth)/dashboard/page.tsx +import { Header } from '@/components/layout/Header' +import { Sidebar } from '@/components/layout/Sidebar' +import { DashboardContent } from '@/components/pages/dashboard/DashboardContent' + +export default function DashboardPage() { + return ( + + {/* Server Component */} + +
{/* Server Component */} + {/* Server Component */} + + + ) +} + +// Bad - separate as client component +'use client' // Unnecessary! +export function DashboardLayout() { + return ( + + + +
+ + + + ) +} +``` + +### Client Separation Pattern (only when necessary) + +```tsx +// Good - only client parts separated +// page.tsx (Server Component) +export default function ProductPage() { + return ( + + {/* Server Component - static info */} + {/* Server Component - image list */} + {/* Client Component - button only */} + {/* Server Component - review list */} + + ) +} + +// AddToCartButton.tsx - only part needing click handler is Client +'use client' +export function AddToCartButton() { + const [loading, setLoading] = useState(false) + return +} +``` + +### Client Hook 분리 패턴 (CRITICAL) + +`'use client'`가 필요한 hook을 사용하는 부분만 별도 Client Component로 분리합니다. + +**분리가 필요한 hook들:** +- `useRouter`, `usePathname`, `useSearchParams` (Next.js) +- `useState`, `useEffect`, `useRef` (React) +- `useContext` (Context) +- Custom hooks that use above + +```tsx +// ❌ Bad - hook 때문에 전체가 Client Component +'use client' +export function DetailPageContent({ id }: { id: string }) { + const router = useRouter() + + return ( + <> + router.back()} title={`Item #${id}`} /> + {/* 많은 정적 UI 코드... */} + + ...
{/* 전부 Server Component여도 됨 */} + ...
+
+ + {/* 이것 때문에 전체가 Client */} + + + + ) +} + +// ✅ Good - hook 사용 부분만 분리 +// DetailPageContent.tsx (Server Component) +export function DetailPageContent({ id }: { id: string }) { + return ( + <> + {/* Client */} + + ...
{/* Server Component 유지! */} + ...
+
+ {/* Client */} + + ) +} + +// PageHeaderWithBack.tsx (Client Component - 최소 범위) +'use client' +import { useRouter } from 'next/navigation' +import { PageHeader } from '@/components/layout/PageHeader' + +export function PageHeaderWithBack({ title }: { title: string }) { + const router = useRouter() + return router.back()} title={title} /> +} + +// DetailPageFooter.tsx (Client Component - 최소 범위) +'use client' +import { useRouter } from 'next/navigation' + +export function DetailPageFooter() { + const router = useRouter() + return ( + + + + + ) +} +``` + +**핵심:** hook이 필요한 부분만 최소 범위로 Client Component 추출! 나머지 정적 UI는 Server Component로 유지해서 JS 번들 최소화! + +### Children Pattern (Key!) + +```tsx +// Good - pass Server Components via children +// page.tsx (Server Component) +export default function ItemListPage() { + return ( + + {/* These components all remain Server Components */} + + + + + ) +} + +// InteractiveWrapper.tsx - only interaction logic is Client +'use client' +export function InteractiveWrapper({ children }: { children: React.ReactNode }) { + const [filter, setFilter] = useState(...) + + return ( + + {children} {/* Server Components passed as-is - not included in JS bundle! */} + + ) +} + +// Bad - making entire component Client +'use client' +export function ItemListPage() { + // All child components become Client Components - JS bundle size increases! + return ( + + + + + + ) +} +``` + +### 'use client' Decision Guide + +| Feature | 'use client' needed? | Reason | +|---------|---------------------|--------| +| Static UI rendering | No | Rendered on Server | +| Data fetch (async/await) | No | Direct fetch in Server Component | +| `useState`, `useEffect` | **Yes** | React hooks | +| `onClick`, `onChange` etc. **defined internally** | **Yes** | Browser events | +| `onClick` etc. **received via props** | **No** | Parent is already client if passing function | +| `useRouter`, `usePathname` | **Yes** | Next.js client hooks | +| Context usage (`useContext`) | **Yes** | Client-side state | +| Conditional rendering (props-based) | No | Can process on Server | +| `framer-motion` animation | **Yes** | Uses browser API | + +**Important: Props-based Function Calls** + +Components receiving optional functions via props don't need `'use client'` - but **only if you pass props directly without wrapping in arrow function**. + +**Good patterns (can stay Server Component):** + +```tsx +interface CardProps { + onClick?: () => void +} + +// Direct prop pass - NO 'use client' needed +export function Card({ onClick }: CardProps) { + return Content +} + +// Conditional direct pass - also OK +export function Card({ onClick }: CardProps) { + return Content +} + +// Conditional with wrapper - also OK (only creates function when prop exists) +export function Card({ onClick }: CardProps) { + return onClick() : undefined}>Content +} +``` + +**Anti-patterns (forces 'use client'):** + +```tsx +// BAD - arrow function wrapper always creates a function +export function Card({ onClick }: CardProps) { + return onClick?.()}>Content // Forces 'use client'! +} + +// BAD - same problem +export function Card({ onClick }: CardProps) { + return onClick ? onClick() : undefined}>Content // Forces 'use client'! +} +``` + +**Why?** Wrapping in arrow function (`() => ...`) means you're **defining** a new function internally, which requires `'use client'`. Direct prop pass (`onClick={onClick}`) doesn't define anything - it just passes the reference. + +```tsx +// Needs 'use client' - defines handler internally +'use client' +export function Card({ title }: CardProps) { + const handleClick = () => { // Defines handler internally + console.log('clicked') + } + + return ( + + {title} + + ) +} +``` + +### RSC async Rules + +**Server Components without await must be sync:** + +```tsx +// Good - has await, use async +async function UserProfilePage() { + const user = await fetchUser() // Uses await + return +} + +// Good - no await, use sync +function SettingsPage() { + return // No await -> sync +} + +// Bad - async without await +async function AboutPage() { + return // No await but using async +} +``` + +## 7. Fragment Rules + +| Children count | Rule | +|----------------|------| +| **1** | Must remove Fragment | +| **2+** | Fragment allowed | + +```tsx +// Good - 1 child: no Fragment needed +export default function HomePage() { + return ( + +
+ + + ) +} + +// Bad - 1 child with Fragment +export default function HomePage() { + return ( + <> + +
+ + + + ) +} +``` + +## 8. Type Check & Lint Must Pass + +> **CRITICAL: Run these commands in order after completing work!** + +```bash +# 1. Type check +bun tsc --noEmit + +# 2. Lint auto-fix +bun run lint:fix + +# 3. Final lint check (must be 0 errors) +bun run lint +``` + +**Full run (recommended):** + +```bash +bun tsc --noEmit && bun run lint:fix && bun run lint +``` + +## 9. Package Manager + +**Default is bun.** Use other manager if different lock file exists: + +| Lock File | Package Manager | +|-----------|-----------------| +| `bun.lock` | bun | +| `pnpm-lock.yaml` | pnpm | +| `package-lock.json` | npm | +| `yarn.lock` | yarn | + +**Dev server execution:** + +```bash +# Good - move to project directory and run +cd apps/front +bun dev + +# Bad - don't use -F option (can cause zombie processes) +bun -F front dev +``` + +## 10. Testing Required + +**Tests must be written for non-async components.** + +### Test file location + +``` +src/ +├── components/ +│ ├── Button.tsx +│ ├── __tests__/ +│ │ └── Button.test.tsx # ComponentName.test.tsx +``` + +### Test setup + +```bash +bun add -d @devup-ui/bun-plugin bun-test-env-dom +``` + +```toml +# bunfig.toml +[test] +preload = ["@devup-ui/bun-plugin", "bun-test-env-dom"] +``` + +### Test example + +```tsx +// __tests__/Button.test.tsx +import { describe, expect, it } from 'bun:test' +import { render, screen, fireEvent } from '@testing-library/react' + +import { Button } from '../Button' + +describe('Button', () => { + it('renders children correctly', () => { + render() + expect(screen.getByText('Click me')).toBeInTheDocument() + }) + + it('calls onClick when clicked', () => { + const handleClick = vi.fn() + render() + fireEvent.click(screen.getByText('Click')) + expect(handleClick).toHaveBeenCalledTimes(1) + }) +}) +``` + +## 11. Component Documentation + +**Write detailed JSDoc comments for each component:** + +```tsx +/** + * Common button component + * + * @description Base button used throughout the project. + * Supports primary, secondary, disabled variants. + * + * @example + * ```tsx + * + * ``` + * + * @param variant - Button style ('primary' | 'secondary' | 'disabled') + * @param children - Button content + * @param onClick - Click event handler + * @param disabled - Disabled state + */ +export function Button({ variant, children, onClick, disabled }: ButtonProps) { + // ... +} +``` + +## 12. Query Error & Loading States + +Client components using react-query **must handle `isError`, `isPending` states**: + +```tsx +'use client' +import { queryApi } from '@/api' + +export function UserList() { + const { data, isError, isPending, error } = queryApi.useQuery('get', '/users') + + // Must handle loading state + if (isPending) { + return + } + + // Must handle error state + if (isError) { + return + } + + return ( + + {data.map((user) => ( + + ))} + + ) +} +``` + +## 13. Use @devup-ui/components + +**Maximize use of @devup-ui/components package.** + +Components like Checkbox, Select, Input, Button already exist in `@devup-ui/components`. **Use existing components instead of creating new ones.** + +```tsx +// Good - use @devup-ui/components +import { Checkbox, Select, Input, Button } from '@devup-ui/components' + +// Bad - recreating existing component +export function Checkbox({ checked, onChange, children }) { + return ( + + ) +} +``` + +## 14. Interface over Type + +**Use `interface` instead of `type`:** + +```tsx +// Good +interface ButtonProps { + variant: 'primary' | 'secondary' + children: React.ReactNode +} + +// Bad +type ButtonProps = { + variant: 'primary' | 'secondary' + children: React.ReactNode +} +``` + +**Exceptions where `type` is allowed:** + +| Situation | Example | +|-----------|---------| +| Union types | `type Status = 'idle' \| 'loading' \| 'error'` | +| Intersection types | `type Combined = A & B` | +| Mapped types | `type Readonly = { readonly [K in keyof T]: T[K] }` | + +## 15. Regular Functions over Arrow Functions + +**Use regular function instead of arrow function:** + +```tsx +// Good +export function Button({ children }: ButtonProps) { + return {children} +} + +function handleClick() { + console.log('clicked') +} + +// Bad +export const Button = ({ children }: ButtonProps) => { + return {children} +} + +const handleClick = () => { + console.log('clicked') +} +``` + +**Exceptions where arrow function is allowed:** + +| Situation | Example | +|-----------|---------| +| Inline callback | `onClick={() => setState(true)}` | +| Array methods | `items.map((item) => )` | +| Immediate return | `const double = (n: number) => n * 2` | + +## 16. Use Next.js Link for Navigation + +> **Link works in Server Components, but useRouter needs 'use client'.** +> **Using Link enables navigation without 'use client', reducing JS bundle size.** + +```tsx +// Good - Link in Server Component (no 'use client' needed!) +import Link from 'next/link' + +export function Navbar() { // No 'use client'! + return ( + + ) +} + +// Bad - useRouter requires 'use client' +'use client' // Required because of useRouter! +import { useRouter } from 'next/navigation' + +export function Navbar() { + const router = useRouter() + return ( + + ) +} +``` + +**useRouter allowed cases (programmatic navigation only):** + +```tsx +// Good - conditional navigation after API call (useRouter allowed) +'use client' +export function CreatePostForm() { + const router = useRouter() + + async function handleSubmit() { + const result = await api.createPost(data) + if (result.success) { + router.push(`/posts/${result.id}`) // Conditional - OK + } + } + + return
...
+} +``` + +## 17. No Unnecessary Parameters & Variables + +**Must remove unused parameters and variables:** + +```tsx +// Good - declare only what's needed +function UserCard({ name, email }: UserCardProps) { + return ( + + {name} + {email} + + ) +} + +// Bad - unused parameters +function UserCard({ name, email, id, createdAt }: UserCardProps) { + // id, createdAt not used + return ( + + {name} + {email} + + ) +} +``` + +## 18. SVG Icon Files + +**Single-color SVG icons must be stored as `.svg` files in `public/icons/`, NOT as React components.** + +### Why? + +- Reduces JS bundle size (SVG files are not bundled) +- Better caching (static assets) +- Simpler maintenance + +### Decision Guide + +| SVG Type | Location | Format | +|----------|----------|--------| +| Single-color icon | `public/icons/` | `.svg` file | +| Multi-color with dynamic props | `components/` | React component | +| Animated/interactive SVG | `components/` | React component | + +### Single-color SVG (use file) + +```svg + + + + +``` + +**Usage:** + +```tsx +// With Next.js Image +import Image from 'next/image' + +arrow right + +// With CSS for color control + +``` + +### Multi-color/Dynamic SVG (use component) + +```tsx +// components/icons/StatusIcon.tsx - OK as component (multi-color, dynamic) +interface StatusIconProps { + status: 'success' | 'error' | 'warning' +} + +export function StatusIcon({ status }: StatusIconProps) { + const colors = { + success: { bg: '#4CAF50', icon: '#FFF' }, + error: { bg: '#F44336', icon: '#FFF' }, + warning: { bg: '#FF9800', icon: '#000' }, + } + + return ( + + + + + ) +} +``` + +### Anti-pattern + +```tsx +// Bad - single-color SVG as React component +// components/icons/ArrowRightIcon.tsx +export function ArrowRightIcon() { + return ( + + + + ) +} +// This adds unnecessary JS to bundle! +// Should be: public/icons/arrow-right.svg +``` + +## 19. HTML suppressHydrationWarning (Theme) + +> **html 태그에 `suppressHydrationWarning` 필수!** + +다크모드/라이트모드 테마 전환 시 서버-클라이언트 hydration 불일치 warning을 방지합니다. + +```tsx +// src/app/layout.tsx +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + {/* REQUIRED! */} + + {children} + + + ) +} +``` + +**왜 필요한가?** +- 테마(다크모드)는 클라이언트 localStorage/cookie에서 결정됨 +- 서버 렌더링 시 테마를 알 수 없어 hydration mismatch 발생 +- `suppressHydrationWarning`으로 해당 warning 억제 + +**Anti-pattern:** +```tsx +// Bad - suppressHydrationWarning 없음 + {/* Warning 발생! */} +``` + +## 20. Footer Background Strategy + +> **Footer가 있다면 body 배경색 = Footer 배경색으로 설정!** + +컨텐츠 높이가 짧을 때 Footer만 다른 배경색이면 어색하게 보입니다. + +### 문제 상황 + +``` +┌─────────────────────┐ +│ Header (흰색) │ +├─────────────────────┤ +│ │ +│ Content (흰색) │ ← 컨텐츠가 짧으면 +│ │ +├─────────────────────┤ +│ Footer (회색) │ ← Footer만 튀어 보임 +├─────────────────────┤ +│ 빈 공간 (???) │ ← body 색상이 뭐지? +└─────────────────────┘ +``` + +### 해결: Body 배경 = Footer 배경 + +```tsx +// src/app/layout.tsx +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {/* body 자체는 Footer와 같은 배경색 */} + + {/* 본문 영역에 별도 배경색 */} + +
+ + {children} + + +