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/.changepacks/changepack_log_skills_fetch_first.json b/.changepacks/changepack_log_skills_fetch_first.json new file mode 100644 index 00000000..933a8b16 --- /dev/null +++ b/.changepacks/changepack_log_skills_fetch_first.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "devup_skills install now prefers each embedded skill's current upstream documents and falls back to the copy in the binary when fetching fails. A bare machine is the reason skill delivery exists, and is also where a download is least likely to work, so freshness cannot become an installation prerequisite. The CLI already had an ETag-aware fetcher; it now accepts a URL so the MCP installer can derive each document's address from its manifest repository and path instead of accidentally fetching devup-ui for every skill. The CLI still installs devup-ui to its existing HOME targets. MCP writes stay project-local and use the server's existing OutputPolicy and one OutputTransaction. A four-second budget covers the entire install's fetch work, rather than multiplying a timeout by the number of skills. One failed document falls back to the whole embedded skill, so an updated entry is not paired with old references. Network errors, HTTP errors and missing ETags report an embedded source and the fallback reason without warnings; a 404 additionally warns that the manifest path may have moved. Fetched files cannot claim the vendored commit: their entry note and response reuse sourceUrl, etag, fetchedAt and sha256 provenance, with the digest describing upstream bytes before annotation. The note stays after frontmatter so the installed skill still loads. Own skills still come only from the binary, and external skills are never fetched, written or installed by a shell command. DEVUP_MCP_SKILLS_OFFLINE is read once and bypasses the HTTP client. MCP integration tests set it in child processes before startup, while injected upstreams exercise fetched and failure paths without opening a socket. Status, resource reads and self-check cannot enter the fetch path.", + "date": "2026-09-14T13:26:37.677275+00: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/README.md b/README.md index 12e310aa..cfee845b 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ devup-mcp가 돌려주는 TSX는 **devup-ui 코드**입니다. devup-mcp만 깔 | 스킬 | 출처 | devup-mcp가 하는 일 | |---|---|---| -| `devup-ui` · `vespera` · `vespertide` | dev-five-git (우리 것) | **바이너리에 내장.** `{"action":"install"}`이 네트워크 없이 스킬 디렉터리에 씁니다 | +| `devup-ui` · `vespera` · `vespertide` | dev-five-git (우리 것) | **바이너리에 내장.** `{"action":"install"}`이 upstream 최신 문서를 우선 가져오고, 실패하면 내장본을 스킬 디렉터리에 씁니다 | | `vercel-react-best-practices` · `vercel-react-view-transitions` | vercel-labs/agent-skills | **내장하지 않음.** 설치 명령 `npx skills add vercel-labs/agent-skills`를 넘길 뿐, 실행하지 않습니다 | vercel 것을 내장하지 않는 이유는 두 가지입니다. **`vercel-labs/agent-skills`에는 LICENSE 파일이 없어** 재배포할 권리가 없고, 그 스킬들은 단일 파일이 아니라 `SKILL.md` + `AGENTS.md` + 규칙 파일 수십 개(합쳐 ~350 KB)라서 애초에 던져줄 물건이 아니라 설치할 물건입니다. @@ -224,6 +224,12 @@ vercel 것을 내장하지 않는 이유는 두 가지입니다. **`vercel-labs/ 설치 위치는 프로젝트 안입니다 — 이미 있는 것을 우선해 `.claude/skills`, `.opencode/skill`, `.agents/skills` 순으로 고릅니다. 프로젝트 루트는 devup-mcp가 쓸 수 있는 유일한 곳이라 새 권한이 필요 없고, 스킬이 저장소를 따라다닙니다. 이미 깔려 있으면 다시 쓰지 않습니다. +설치 응답의 `installed[].source`는 `fetched` 또는 `embedded`이며, 내장본을 썼다면 `reason`도 반환합니다. 가져오기는 manifest의 저장소와 문서 경로에서 만든 `https://raw.githubusercontent.com/REPO/HEAD/PATH`를 사용하며, 호출 전체의 네트워크 대기는 최대 4초입니다. 네트워크 오류, HTTP 오류, ETag 누락은 내장본으로 돌아가고, 404는 upstream 경로가 바뀌었을 수 있으므로 `warnings`에도 알립니다. 여러 문서 중 하나라도 실패하면 그 스킬 전체를 내장본으로 설치합니다. + +`DEVUP_MCP_SKILLS_OFFLINE=1`로 서버를 시작하면 가져오기를 생략합니다. 이 설정은 프로세스에서 한 번 읽으며 `0`, `false`, 빈 값은 가져오기를 허용합니다. `own` 스킬은 항상 바이너리에서, `external` 스킬은 여전히 설치 명령 안내만 합니다. `--self-check`와 스킬 resource 읽기, `status`는 가져오기를 실행하지 않습니다. 별도 CLI인 `--install-skills`의 devup-ui/HOME 설치 계약은 그대로입니다. + +가져온 문서의 주석은 고정 커밋을 주장하지 않고 URL, 가져온 시각(Unix seconds), ETag, 주석 추가 전 SHA-256을 기록합니다. 응답의 `installed[].documents[].provenance`도 같은 정보를 담습니다. 내장본을 설치하거나 resource로 읽을 때는 기존 커밋 정보를 유지합니다. + 내장본은 각 레포의 `SKILL.md`를 그대로 복사한 것이고, 응답과 설치된 파일 모두 **어느 커밋인지와 최신본 URL**을 함께 답니다. 사본은 낡습니다 — 그게 내장의 정직한 비용이고, `node scripts/refresh-skills.mjs`가 그걸 갱신하는 방법입니다(`--check`는 쓰지 않고 드리프트만 보고). 주석은 YAML frontmatter **뒤에** 들어갑니다. `---`는 0번째 바이트에 있어야 로더가 읽습니다. 설치하지 않고 읽기만 하려면 `devup://skill/devup-ui` 리소스도 있습니다. 다만 그건 fallback입니다 — 설치해야 로더가 알아서 꺼내 줍니다. diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index a575cddf..425cb059 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -913,7 +913,7 @@ impl DevupServer { description = "Report which agent skills the code devup-mcp emits depends on and whether this workspace has them, then install the ones devup-mcp carries (action: status | install). \ The TSX devup_figma_export returns is devup-ui code, and an agent that has never seen devup-ui does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array - it guesses, and this server cannot see the guesses. \ Call status before writing or editing that code. Anything reported missing is a gap you can close in one step. \ - install writes the vendored SKILL.md for devup-ui, vespera and vespertide into the workspace skill root (.claude/skills, .opencode/skill or .agents/skills - an existing one is preferred), with no network. Load them afterwards the way your runtime loads a project skill; an installed skill keeps applying to later sessions, which reading a document once does not. \ + install fetches current documents for embedded skills, falling back to the binary on failure, and writes own skills from the binary into the workspace skill root (.claude/skills, .opencode/skill or .agents/skills - an existing one is preferred), reporting source and any fallback reason (DEVUP_MCP_SKILLS_OFFLINE=1 disables fetching). Load them afterwards the way your runtime loads a project skill; an installed skill keeps applying to later sessions, which reading a document once does not. \ External skills are reported, never written: devup-mcp hands over its publisher's install command and does not run it.", output_schema = permissive_object_output_schema() )] @@ -926,8 +926,9 @@ impl DevupServer { self.output_policy.primary_root(), ))), "install" => { - let outcome = - skills::install(&self.output_policy, &input.names).map_err(to_mcp_error)?; + let outcome = skills::install(&self.output_policy, &input.names) + .await + .map_err(to_mcp_error)?; // The state after the write, from the same reader `status` // uses. An install that reports what it meant to do rather than // what is now on disk is the report that cannot be trusted. 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..42c3a9f6 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -16,12 +16,22 @@ //! **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 as an offline fallback; install prefers current upstream documents. +//! The fallback 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 @@ -38,8 +48,47 @@ use std::path::{Path, PathBuf}; use std::sync::LazyLock; +use crate::skills::{RawGithubSkillUpstream, SkillFetchError, SkillProvenance, SkillUpstream}; use serde::Deserialize; +const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); +const OFFLINE_ENV: &str = "DEVUP_MCP_SKILLS_OFFLINE"; + +// Read once per process. Client construction and fetching happen only on an +// explicit install, never while constructing a server or running self-check. +static OFFLINE: LazyLock = LazyLock::new(|| { + std::env::var(OFFLINE_ENV).is_ok_and(|value| { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" + ) + }) +}); + +struct InstallUpstream; + +#[async_trait::async_trait] +impl SkillUpstream for InstallUpstream { + async fn fetch( + &self, + source_url: &str, + ) -> Result { + if *OFFLINE { + return Err(SkillFetchError::Network(format!( + "{OFFLINE_ENV} disables fetching" + ))); + } + static UPSTREAM: LazyLock> = LazyLock::new(|| { + RawGithubSkillUpstream::with_timeout(FETCH_TIMEOUT).map_err(|error| error.to_string()) + }); + UPSTREAM + .as_ref() + .map_err(|error| SkillFetchError::Network(error.clone()))? + .fetch(source_url) + .await + } +} + pub const MIME_TYPE: &str = "text/markdown"; /// The registry. Parsed rather than duplicated into consts so that refreshing a @@ -47,16 +96,60 @@ 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 +198,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 +239,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 +254,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 +281,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 +292,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 +338,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 @@ -233,7 +376,84 @@ pub fn installed_paths(project: &Path, name: &str) -> Vec { .collect() } +struct FetchedDocuments { + documents: Vec<(&'static str, String)>, + provenance: Vec, +} + impl Skill { + /// The manifest's path is either the entry file or the skill directory. + /// References live beside that entry, even for nested upstream skills. + fn upstream_url(&self, relative: &str) -> String { + let path = self.record.path.trim_end_matches('/'); + let directory = if path == ENTRY_DOCUMENT { + "" + } else { + path.strip_suffix("/SKILL.md").unwrap_or(path) + }; + let document = if directory.is_empty() { + relative.to_owned() + } else { + format!("{directory}/{relative}") + }; + format!( + "https://raw.githubusercontent.com/{}/HEAD/{document}", + self.record.repo + ) + } + + /// Resolve the complete set before staging any of it. One failed reference + /// must not leave a new entry document next to old embedded references. + async fn fetch_documents( + &self, + upstream: &dyn SkillUpstream, + deadline: tokio::time::Instant, + ) -> Result { + let mut documents = Vec::new(); + let mut provenance = Vec::new(); + for (relative, _) in self.texts.expect("only carried skills can be fetched") { + let url = self.upstream_url(relative); + let timeout_error = || { + SkillFetchError::Network( + "the four-second skill install fetch budget elapsed".to_owned(), + ) + }; + if tokio::time::Instant::now() >= deadline { + return Err((url, timeout_error())); + } + let fetched = tokio::time::timeout_at(deadline, upstream.fetch(&url)) + .await + .unwrap_or_else(|_| Err(timeout_error())) + .map_err(|error| (url.clone(), error))?; + let text = std::str::from_utf8(&fetched.contents).map_err(|error| { + ( + url.clone(), + SkillFetchError::Network(format!("upstream document is not UTF-8: {error}")), + ) + })?; + let record = SkillProvenance { + source_url: url, + etag: fetched.etag, + fetched_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + sha256: crate::skills::sha256(&fetched.contents), + }; + let contents = if *relative == ENTRY_DOCUMENT { + self.annotated_with(text, Some(&record)) + } else { + text.to_owned() + }; + documents.push((*relative, contents)); + provenance.push(serde_json::json!({"path": relative, "provenance": record})); + } + Ok(FetchedDocuments { + documents, + provenance, + }) + } + /// The provenance a reader needs alongside the text: which revision this /// is, and where the current one lives. Carried in the body rather than /// returned beside it, because the installed file outlives this response @@ -250,9 +470,86 @@ 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 { + self.annotated_with(text, None) + } + + fn annotated_with(&self, text: &str, fetched: Option<&SkillProvenance>) -> String { + let note = self.provenance_note(fetched); + 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, fetched: Option<&SkillProvenance>) -> String { let r = &self.record; - let note = format!( + if let Some(p) = fetched { + return format!( + "", + p.source_url, + p.fetched_at, + p.etag.replace("--", "--"), + p.sha256, + r.used_for, + ); + } + 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} + + +