@conscience-technology/ctds 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,21 @@ CTDS — 하이브리드 디자인 시스템 설치/온보딩 CLI.
7
7
  - Node.js 18+
8
8
  - Next.js App Router 프로젝트 (`app/` 또는 `src/app/` 디렉토리)
9
9
 
10
+ ### 검증된 조합
11
+
12
+ | 패키지 | 검증 범위 | 비고 |
13
+ |---|---|---|
14
+ | Next.js | `>=14.0 <17.0` | Next 16은 자동 패치로 복구 (아래 참고) |
15
+ | React | `>=18.2 <20.0` | React 19 strict 타입 호환 |
16
+
17
+ **Next.js 16 자동 호환 처리**: `create-next-app@latest`가 뽑는 `turbopack.rules["*.css"] + @tailwindcss/turbopack` 조합이 Turbopack built-in Lightning CSS CSS Modules 경로를 bypass해서 `.module.css`의 class가 undefined로 처리되는 함정이 있어요. `ctds init`은 이걸 감지해서 **자동으로**:
18
+
19
+ 1. `next.config.ts`의 `turbopack.rules["*.css"]` 블록을 삭제
20
+ 2. `postcss.config.mjs`를 신규 생성해 `@tailwindcss/postcss` plugin 등록
21
+ 3. 콘솔 fallback으로 `@tailwindcss/turbopack` 제거 명령 안내
22
+
23
+ Next 15 LTS 플젝에서는 위 변경이 적용되지 않고 그대로 작동.
24
+
10
25
  ## 설치 (end user)
11
26
 
12
27
  ```bash
@@ -24,6 +24,19 @@
24
24
  .root:focus-within {
25
25
  box-shadow: var(--ring), var(--shadow-md);
26
26
  }
27
+
28
+ /* variant=flush — shell(사이드바 바닥·바텀바 등)에 edge-to-edge로 들러붙는 형태.
29
+ 외곽 radius·shadow·border 제거, overflow만 유지해 scroll clip은 보장. focus ring도 없음
30
+ (바깥 shell이 focus signal을 책임지는 자리이거나 입력이 늘 활성인 맥락). */
31
+ .variant-flush {
32
+ border: 0;
33
+ border-radius: 0;
34
+ box-shadow: none;
35
+ }
36
+ .variant-flush:focus-within {
37
+ box-shadow: none;
38
+ }
39
+
27
40
  .disabled {
28
41
  opacity: 0.5;
29
42
  pointer-events: none;
@@ -21,6 +21,7 @@ import { SendButton } from "./SendButton";
21
21
  import styles from "./ChatInput.module.css";
22
22
 
23
23
  export type ChatInputShape = "multi" | "single";
24
+ export type ChatInputVariant = "framed" | "flush";
24
25
 
25
26
  /** 첨부 공용 타입 — ChatInput·Message 양쪽에서 재사용. */
26
27
  export interface ChatAttachmentImage {
@@ -48,6 +49,8 @@ export interface ChatInputModel {
48
49
 
49
50
  export interface ChatInputProps {
50
51
  shape?: ChatInputShape;
52
+ /** 외곽 처리. framed(기본)=radius·border·shadow 떠있음, flush=shell 바닥에 edge-to-edge. */
53
+ variant?: ChatInputVariant;
51
54
 
52
55
  value: string;
53
56
  onChange: (v: string) => void;
@@ -85,6 +88,7 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
85
88
  function ChatInput(props, ref) {
86
89
  const {
87
90
  shape = "multi",
91
+ variant = "framed",
88
92
  value,
89
93
  onChange,
90
94
  onSend,
@@ -167,6 +171,7 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
167
171
  const rootCls = [
168
172
  styles.root,
169
173
  styles[`shape-${shape}`],
174
+ styles[`variant-${variant}`],
170
175
  disabled ? styles.disabled : "",
171
176
  className,
172
177
  ]
@@ -2,6 +2,7 @@ export { ChatInput } from "./ChatInput";
2
2
  export type {
3
3
  ChatInputProps,
4
4
  ChatInputShape,
5
+ ChatInputVariant,
5
6
  ChatInputModel,
6
7
  ChatAttachment,
7
8
  ChatAttachmentImage,
@@ -612,6 +612,7 @@ function Rail({
612
612
  className={styles.railSteps}
613
613
  activeIndex={stepIdx}
614
614
  onStepClick={onStepClick}
615
+ marker="number"
615
616
  >
616
617
  {STEPS.map((s, i) => (
617
618
  <Stepper.Item
@@ -488,7 +488,7 @@ export const shadows: [string, string, string][] = [
488
488
  ["shadow-sm", "var(--shadow-sm)", "Card 기본"],
489
489
  ["shadow-md", "var(--shadow-md)", "Dropdown, Popover"],
490
490
  ["shadow-lg", "var(--shadow-lg)", "Modal"],
491
- ["shadow-xl", "var(--shadow-xl)",],
491
+ ["shadow-xl", "var(--shadow-xl)", "Command Palette"],
492
492
  ["shadow-inner", "var(--shadow-inner)", "눌린 상태"],
493
493
  ];
494
494
 
@@ -648,7 +648,8 @@ export const components: { id: string; title: string; items: string[] }[] = [
648
648
  id: "overlay",
649
649
  title: "Overlay / Menu",
650
650
  items: [
651
- "Context Menu", "Dropdown Menu", "Disclosure", "Collapsible", "Toggle Group",
651
+ "Context Menu", "Dropdown Menu",
652
+ "Disclosure", "Collapsible", "Toggle Group",
652
653
  ],
653
654
  },
654
655
  {
@@ -47,6 +47,17 @@ anatomy = padding·gap·radius·border 전략. 이걸 복제하면 벤더가 축
47
47
  - **Icon 이름**은 `web/components/Icon/Icon.tsx`의 phosphor 레지스트리에서 확인 — 레시피/다른 문서에 등장하는 이름이 실제 등재된 이름이라는 보장 없음
48
48
  - **`"use client"` 전염**: 쓰는 벤더 중 하나라도 `"use client"`면 조립하는 **page 자체도 `"use client"`**. compound(`Stepper.Item`·`Card.Header` 등)를 Server Component에서 접근하면 **런타임 undefined**(Next 15/React 19 경계 이슈). 현재 Button/Icon/IconButton/Stepper 등 대다수 vendor가 client — 사실상 조립 page는 모두 client로 가는 쪽이 안전
49
49
 
50
+ **벤더 수정 vs 로컬 파생 결정 트리** ([[feedback_no_vendor_modification]], [[feedback_local_derivative_over_handrolled]]):
51
+
52
+ 벤더에 "비슷하지만 그대로는 안 되는" 자리를 만들 때 네 갈래 중 하나를 **글로 적어 결정**한다 — 수제(div+span 하드코딩) 로 빠지는 순간 토큰·여백·사이즈 리듬이 전부 깨진다.
53
+
54
+ 1. **벤더 variant/size 로 커버됨** → 그대로 import, 변경 금지.
55
+ 2. **커버 안 됨 + 다른 사용처에도 쓰일 패턴** → `ctds-component` 스킬로 넘겨 **벤더에 새 variant/size 신설**. Layer 1(코드)과 Layer 2(문서) 쌍으로 수정. 영향 범위 평가 필수.
56
+ 3. **커버 안 됨 + 번역/페이지 이 자리에만 필요** → **로컬 파생**: 가장 비슷한 벤더 코드(JSX + CSS Module)를 소비 프로젝트 로컬 경로(`web/app/_components/` 또는 `app/components/<page>/_parts/`)에 **다른 이름**으로 복사. 토큰·height·padding·gap·radius 기본값은 **건드리지 말고 유지** — 조정은 추가 slot/위치/variant 수준. 벤더 패키지(`packages/ctds-cli/assets/`, `node_modules/ctds/`) 는 **절대 수정 금지**.
57
+ 4. **벤더에 비슷한 것도 없고 레시피에도 없음** → 사용자에게 보고. 수제 조립로 임의 결정 금지.
58
+
59
+ **왜 로컬 파생이 수제보다 통일성이 좋은가**: 벤더의 토큰 선택을 그대로 베낀 상태로 시작하므로 Shape/Density/Hue 축 스왑에 자동 반응. 수제는 padding `12px`·gap `8px` 같은 직관값이 CTDS space scale 과 미묘하게 어긋나거나 하드코딩되고, 축 스왑에서 그 자리만 깨진다.
60
+
50
61
  ### 3단계 — 레시피를 확인한다
51
62
 
52
63
  `prompts/recipes/`에 **자리별 조립 레시피**가 있다. 맞는 레시피가 있으면 그 토큰·구조 그대로 쓴다.
@@ -16,11 +16,14 @@ Weight는 축이 아니라 `<Icon weight="..." />` prop (컴포넌트별 자유
16
16
  - 아이콘은 항상 의미 기반 이름으로 참조: `<Icon name="check" />`.
17
17
  - 프로젝트 시작 시 Family 하나 결정. 리스킨 아닌 이상 안 바꾼다.
18
18
  - 활성/선택 상태 표시엔 `weight=fill`, 그 외에는 `weight=regular`.
19
+ - **단순한 상징 우선** — Button/MenuItem 라벨이 이미 의미를 담고 있으니, 아이콘은 1초 안에 분류되는 "모양 꼬리표" 역할. 조합형 메타포(`envelope-plus`, `pencil-simple` 등)보다 원형 상징(`plus`, `x`, `check`, `search`, `trash`, `arrow-right`)이 우선 ([[feedback_simple_icons]]).
19
20
 
20
21
  ### Don't
21
22
  - 한 페이지에서 두 Family를 섞지 않는다.
22
23
  - 아이콘을 SVG로 하드코딩하지 않는다. 항상 `<Icon />` 경유.
23
24
  - Lucide에서 `weight=fill`을 요구하지 않는다 (원본이 fill 미제공).
25
+ - 조합형 메타포로 의미를 "그림으로 설명" 하지 않는다 — "새 메시지" 는 `plus` 지 `envelope-plus`/`pencil-simple` 가 아니다. "제출" 은 `arrow-right` 지 `paper-plane` 이 아니다. 라벨에 의미를 넣고 아이콘은 보편 상징.
26
+ - 예외: Sidebar·네비처럼 **아이콘 혼자 뜻을 담아야 하는 자리**는 구체 아이콘 OK (`gear`, `house`, `bell`).
24
27
 
25
28
  ### Fallback
26
29
  Family는 `phosphor`, Weight는 `regular`.
@@ -24,10 +24,13 @@ Theme과 독립 — editorial이든 mechanical이든 어떤 Theme에도 어떤 V
24
24
  - 접근성 문구(에러 설명, 필수 필드 안내, 스크린리더 라벨)는 Voice와 무관하게 **명확성 우선**.
25
25
  `casual`이어도 필수 정보를 희석하지 않는다 (예: "비밀번호는 필수입니다" 수준은 유지).
26
26
  - Voice에 CSS 변수나 토큰을 할당하지 않는다. 코드 레벨 영향은 `data-voice` 속성 노출뿐.
27
- - **제목·헤딩·버튼 라벨·메뉴명에 em dash(—), en dash(–), 하이픈(-)으로 부제를 잇지 않는다.**
28
- 모든 Voice 공통 하드 룰. 문서·UI 카피·AI 생성물 전부 해당.
29
- 나쁜 예: "온보딩 — 테마를 고릅니다", "설정 — 기본값", "저장 — 완료".
30
- 대신: 한 줄 제목으로 끝내거나(`"테마 고르기"`), 부제가 꼭 필요하면 별도 하위 텍스트로 분리.
27
+ - **[HARD] em dash(—), en dash(–), 생략부호(…), 수사적 큰따옴표 전면 금지.** ([[feedback_no_em_dash]])
28
+ 모든 Voice 공통 하드 룰. 범위는 **제목·헤딩·버튼 라벨·메뉴명뿐 아니라 본문·테이블 셀·설명·툴팁·에러 메시지까지 모든 사람 UI 카피** 전부. 문서·UI 카피·AI 생성물 전부 해당.
29
+ 나쁜 예:
30
+ - 제목: "온보딩 — 테마를 고릅니다", "설정 — 기본값", "저장 — 완료"
31
+ - 본문: "작업이 완료되었습니다 — 다시 시작하려면...", "결과는 다음과 같습니다…"
32
+ - 테이블 셀: "완료 — 2026년 10월 15일"
33
+ 대신: 한 줄 제목으로 끝내거나(`"테마 고르기"`), 부제가 꼭 필요하면 별도 하위 텍스트로 분리. 본문에서는 쉼표·마침표·괄호로 흐름을 끊고, "…" 대신 **"기다리는 중"** 처럼 상태를 명시.
31
34
 
32
35
  ### Fallback
33
36
  `neutral`.
@@ -4,21 +4,24 @@
4
4
  사이드바·리스트 끝·설정 패널의 여백에 상주하는 소형 안내 카드. 페이지 흐름을 끊지 않고 새 기능·이동 경로를 제시한다. hover가 없는 [[components/tooltip]], 휘발되는 Toast, 페이지 폭 전체를 덮는 [[recipes/page-banner]]와 구분된다.
5
5
 
6
6
  ### Do
7
- - 표면 강도는 `variant`, 강조 색은 `tone`으로 독립 제어한다.
7
+ - 표면은 `fill` 하나 — tone tint 배경 + 투명 border 로 신호를 담는다. 색 테두리 없음.
8
+ - 강조 색은 `tone` 으로 제어한다.
8
9
  - `dismissible=true`면 `onDismiss`와 함께 부모가 숨김 상태를 저장한다.
9
10
  - `actions`는 [[components/button]] slot이다 — variant는 자유, size는 `xs`(기본) 또는 `sm`만.
10
11
  - 액션 Button의 색은 `tone`을 따라간다 — solid/soft 버튼이면 같은 tone(warning엔 warning 버튼, destructive엔 destructive 버튼), 아니면 `ghost`/`outline` 중립. tone과 다른 색 solid 버튼으로 신호를 깨지 않는다.
11
12
  - 좁은 컨테이너에선 `actionsPlacement="bottom"` 유지, `right`는 본문이 짧고 카드가 가로로 넓을 때만.
12
13
  - `warning`/`destructive` tone은 `icon` 미지정 시 각각 `warning-circle`(주의) / `warning`(위험)이 자동. 다른 의미 아이콘이 필요할 때만 override한다.
14
+ - 톤을 "비권장" 수준으로 낮추고 싶으면 Callout 자체를 쓰지 않는다 — [[components/callout]] 대신 `fg-muted` 색 **인라인 텍스트 한 줄**로 처리.
13
15
 
14
16
  ### Don't
15
17
  - 페이지 폭 전역 경고에 쓰지 않는다 — [[recipes/page-banner]] 영역.
16
18
  - 임시 피드백에 쓰지 않는다 — Toast를 쓴다.
17
19
  - 본문을 4줄 넘게 채우지 않는다.
18
20
  - `tone`을 임의 색상으로 확장하지 않는다.
21
+ - **[HARD] `variant="outline"` 금지** — 색 테두리 + 색 배경 조합(outlined Callout류) 영구 금지. "안 연한 배경 + 진한 스트로크" 는 UI 를 싸구려·시끄럽게 만듦 ([[PRINCIPLES]] §5b, [[feedback_no_outlined_callout]]). 과거 outline variant 는 폐기 — 쓰지 말 것. 비권장 메시지는 Callout 자체 대신 muted 인라인 텍스트로.
19
22
 
20
23
  ### Fallback
21
- `variant` 애매하면 `fill`. `tone` 애매하면 `brand`. `actionsPlacement` 애매하면 `bottom`.
24
+ `variant` 는 `fill` 하나뿐. `tone` 애매하면 `brand`. `actionsPlacement` 애매하면 `bottom`.
22
25
 
23
26
  ### 관련
24
27
  - [[recipes/page-banner]] — 페이지 폭 전면 경고. Callout은 국소 카드.
@@ -31,7 +34,6 @@
31
34
  <Callout
32
35
  title={ReactNode} // required
33
36
  icon={IconName}
34
- variant="fill | outline" // default: fill
35
37
  tone="neutral | brand | success | warning | destructive" // default: brand
36
38
  actions={ReactNode}
37
39
  actionsPlacement="bottom | right" // default: bottom
@@ -41,16 +43,20 @@
41
43
  >{children}</Callout>
42
44
  ```
43
45
 
46
+ > `variant` prop 은 폐기됨. 과거 코드는 자동으로 `fill` 로 동작하지만, 신규 작성 시 명시하지 않는다.
47
+
44
48
  ### Variant 매트릭스
45
- variant × tone 축이 독립 조합. 표는 tone 기준으로 정렬, 각 tone에 variant 두 종을 보여준다.
49
+ `variant` 는 `fill` 하나로 고정. tone 별 표면 색만 바뀐다.
50
+
51
+ | tone | fill (유일 variant) | 용도 |
52
+ |---|---|---|
53
+ | `neutral` | 회색 표면(`bg-subtle`) + `fg-muted` 아이콘 | 톤 없는 기본 안내·중립 컨텍스트 |
54
+ | `brand` | tone 8% tint 표면 + brand 아이콘 | 신기능·프로모션·마이그레이션 유도 |
55
+ | `success` | success 8% tint + success 아이콘 | 완료·다음 스텝 제안 |
56
+ | `warning` | warning 8% tint + warning 아이콘 | 만료·용량 임박·설정 누락 |
57
+ | `destructive` | destructive 8% tint + destructive 아이콘 | 삭제 예정·비가역 상태 |
46
58
 
47
- | tone | fill | outline | 용도 |
48
- |---|---|---|---|
49
- | `neutral` | 회색 표면(`bg-subtle`) + `fg-muted` 아이콘 | 표면 투명 + `border` 경계 | 톤 없는 기본 안내·중립 컨텍스트 |
50
- | `brand` | tone 8% tint 표면 + brand 아이콘·경계 | 표면 투명 + brand 경계 | 신기능·프로모션·마이그레이션 유도 |
51
- | `success` | success 8% tint + success 아이콘·경계 | 표면 투명 + success 경계 | 완료·다음 스텝 제안 |
52
- | `warning` | warning 8% tint + warning 아이콘·경계 | 표면 투명 + warning 경계 | 만료·용량 임박·설정 누락 |
53
- | `destructive` | destructive 8% tint + destructive 아이콘·경계 | 표면 투명 + destructive 경계 | 삭제 예정·비가역 상태 |
59
+ 모든 tone 에서 border 는 `transparent` — 색 테두리는 fill 표면과 신호가 겹쳐 소음만 더한다. 영역 분리가 필요하면 외부 레이아웃 gap 으로 해결, Callout 자체는 테두리를 그리지 않음.
54
60
 
55
61
  **금지 조합**
56
62
  tone이 톤 신호를 쥐고 있으므로, 그 안의 요소들(액션 버튼·아이콘)도 같은 신호를 유지해야 한다. 색이 어긋나면 사용자는 어느 쪽을 믿어야 할지 결정하지 못한다.
@@ -5,6 +5,7 @@ AI 채팅의 입력 영역. **첨부물 바 → 인용 바 → 입력 본문**(t
5
5
 
6
6
  ### Do
7
7
  - Shape은 두 가지 — `multi`(기본, 긴 입력·오버레이 UI), `single`(토픽바·검색봉 느낌).
8
+ - Variant는 두 가지 — `framed`(기본, radius·border·shadow 가진 떠있는 블록), `flush`(radius·border·shadow 모두 제거, shell 바닥에 edge-to-edge).
8
9
  - 외곽 radius는 Shape 축 토큰이 직접 결정 — prop으로 받지 않음.
9
10
  - `models` + `selectedModel` + `onSelectModel` 셋이 다 있을 때만 모델 selector 노출.
10
11
  - 전송은 `Enter`로 — multi에선 `Shift+Enter`가 줄바꿈. 스트리밍 중엔 `Enter`나 send 클릭이 `onStop`을 호출.
@@ -16,9 +17,10 @@ AI 채팅의 입력 영역. **첨부물 바 → 인용 바 → 입력 본문**(t
16
17
  - 모델 선택 메뉴를 내부에서 열지 않는다 — 현재는 라벨 클릭 시 다음 모델로 토글만. 실제 ContextMenu는 소비처에서 조립 (ChatInput의 책임 과잉 방지).
17
18
  - SingleLine에서 모델 라벨을 끝까지 노출하지 않는다 — 10자 넘으면 자동 compact(아이콘만).
18
19
  - Send 버튼 색을 변경하지 않는다 — 비활성=`bg-subtle` / 활성·스트리밍=`primary`로 고정.
20
+ - `flush`를 떠있는 자리(페이지 중앙·오버레이)에 쓰지 않는다 — 외곽선 없이 공중에 뜨면 영역이 흐려짐. 반드시 shell 바닥·바텀바처럼 바깥 경계가 이미 존재하는 자리에만.
19
21
 
20
22
  ### Fallback
21
- shape 애매하면 `multi`. maxRows 애매하면 `8`.
23
+ shape 애매하면 `multi`. variant 애매하면 `framed`. maxRows 애매하면 `8`.
22
24
 
23
25
  ### 관련
24
26
  - [[components/file-icon]] / [[components/attachment-thumb]] / [[components/attachment-item]] / [[components/quotation-bar]] — 조립 요소
@@ -30,6 +32,7 @@ shape 애매하면 `multi`. maxRows 애매하면 `8`.
30
32
  ```tsx
31
33
  <ChatInput
32
34
  shape="multi | single" // default: multi
35
+ variant="framed | flush" // default: framed
33
36
 
34
37
  value={string}
35
38
  onChange={(v) => void}
@@ -64,7 +67,8 @@ type ChatAttachment =
64
67
  - `kind: "file"`은 AttachmentItem이 filename 확장자로 FileIcon을 자동 결정.
65
68
 
66
69
  ### 레이아웃
67
- - 외곽: hairline border, `var(--bg)`, radius=Shape 축 (`--radius-sm`)
70
+ - 외곽(framed): hairline border, `var(--bg)`, radius=Shape 축, `--shadow-md`
71
+ - 외곽(flush): border·radius·shadow 전부 0 — bg만 유지. 바깥 shell(사이드바 바닥·바텀바)이 경계를 책임지는 자리 전용
68
72
  - **스크롤 룰**: root `padding: 0; overflow: hidden`. textarea에 상·하·좌·우 padding 전부 → 긴 글 스크롤 시 글이 root border까지 흘러 자연스레 clip. (wrapper에 padding 두면 scroll 시 "빈 띠"가 남아 어색 — [[primitives/scroll]])
69
73
  - focus-within에서 ring
70
74
  - 내부 세로 gap: `--space-2`, padding: `--space-3`
@@ -146,6 +146,7 @@
146
146
  - **leading에 inline `<svg>` 금지**: 반드시 `<Icon />`/Badge/Avatar 경유. [[feedback_no_inline_svg]].
147
147
  - **한 리스트 안 size 혼용 금지**: sm 리스트는 전부 sm.
148
148
  - **자리마다 세로 간격 손 조정 금지**: 균형 안 맞으면 `size` 또는 리듬 토큰(`--li-gap-title`·`--li-gap-body`)으로.
149
+ - **[HARD] `border-bottom` 있는 li 에 세로 padding 생략 금지** ([[feedback_no_touching_edges]]) — 콘텐츠가 선에 붙으면 무조건 문제. 최소 `--space-3` 유지. border + padding 을 다른 레이어가 공급해도 결과적으로 접촉 금지는 소비처가 책임.
149
150
 
150
151
  ## Fallback
151
152
  leading 애매하면 Icon. size 애매하면 md. align 애매하면 center(한 줄)·top(2줄 이상).
@@ -160,6 +161,27 @@ leading 애매하면 Icon. size 애매하면 md. align 애매하면 center(한
160
161
  | `--li-gap-body` | 큰 블록 간 간격 | `var(--space-2)` |
161
162
  | `--li-gap-meta` | Meta 라인 아이템 간 가로 gap | `var(--space-2)` |
162
163
 
164
+ ### MetaDivider 스펙
165
+
166
+ 가운뎃점·bullet·파이프 대신 쓰는 짧고 연한 세로 hairline. 가운뎃점·`|`·`•` 전부 금지 ([[feedback_no_bullet_separator]]).
167
+
168
+ ```css
169
+ .metaDivider {
170
+ display: inline-block;
171
+ width: 1px;
172
+ height: calc(var(--text-xs) * 0.8); /* Meta 텍스트 높이 대비 약간 짧게 */
173
+ background: var(--fg);
174
+ opacity: 0.2; /* hairline 톤 */
175
+ margin-inline: var(--space-1); /* 좌우 작게 — Meta 토큰 간격은 --li-gap-meta 가 담당 */
176
+ vertical-align: baseline;
177
+ }
178
+ ```
179
+
180
+ 핵심 원칙:
181
+ - 색은 `--fg` × 낮은 알파 (fg 톤 유지, bg 와 섞이지 않음) — 다크/라이트 모드 전환 자동 반응.
182
+ - 높이는 Meta 폰트 크기(`--text-xs`) 기준 상대값 — Density 축 스왑에 자동 반응.
183
+ - 좌우 margin 은 `--space-1` 로 고정, Meta 토큰 간 가로 gap 은 상위 Meta wrapper 의 `gap` (`--li-gap-meta`)이 담당.
184
+
163
185
  ## Props 시그니처
164
186
  ```tsx
165
187
  <ListItem
@@ -17,6 +17,7 @@
17
17
  - zone 밖에 **자식을 바로 넣지 않는다**. `<Topbar><Breadcrumb/>...</Topbar>` 같은 구조 금지 — 반드시 Leading/Center/Trailing 중 하나로 감싼다.
18
18
  - `h1`·큰 hero head·환영 메시지("good morning, …")·페이지 섹션 타이틀을 Topbar 안에 넣지 않는다 — Topbar는 **얇은 축**이고 큰 타이포가 들어가는 순간 `align-items` 전쟁이 벌어진다. 이걸 로컬 CSS로 때우는 순간(`align-items: flex-start`, `padding-bottom` override 등) Topbar의 "축" 약속이 깨지고, 아래 body와의 수직 리듬도 어긋난다. hero는 body의 첫 섹션으로 꺼낸다.
19
19
  - 수직 스택(eyebrow + title 세로 두 줄 같은 조합)을 Topbar 안에 넣지 않는다. 수직 리듬은 body의 섹션 head에서.
20
+ - **[HARD] Topbar 안 IdentityButton 에 `sub` prop 금지** ([[feedback_topbar_single_line_identity]]) — 2줄 모드가 Topbar "얇은 축" 리듬을 깬다. 2줄이 필요하다 느껴지면 자리 자체가 틀림: 콘텐츠 소속감이 필요한 자리면 PageHeader(body 안), 네비게이션 소속감이면 Sidebar.Footer 로 옮긴다.
20
21
  - 알림·검색·Breadcrumb 외의 **콘텐츠**(KPI·요약·공지 문구)를 Topbar에 끼우지 않는다. 그건 body hero 섹션 또는 Callout의 자리.
21
22
  - Topbar에 로컬 `padding`·`align-items`를 override해서 "큰 요소 끼운 걸 수습"하지 않는다 — 수습이 필요한 순간 그 요소가 Topbar의 자리가 아니라는 신호.
22
23
  - 자체 bg/border/shadow 추가 금지 — AppFrame variant가 쥐고 있는 자리.
@@ -38,12 +38,19 @@
38
38
 
39
39
  ### 스크롤바 시각 정책 (전역, globals.css)
40
40
 
41
+ 핵심은 **overlay 방식 전용** — 스크롤바는 콘텐츠 위에 뜨기만 하고 **공간을 먹지 않는다**. 공간을 먹는 "고정 스크롤바" 는 다크 모드에서 OS·브라우저 조합이 트랙·코너를 흰색으로 강제 렌더해 그 자리가 하얗게 뜨는 함정이 있어 영구 금지 ([[feedback_scrollbar_no_fixed_track]]).
42
+
41
43
  - 평상시: thumb 투명 → 스크롤바 자리는 있지만 안 보임 (overlay)
42
44
  - 부모 `:hover` 또는 `:focus-within`: thumb에 `--border-strong` 페이드 인
43
45
  - thumb `:hover`: `--fg-muted`로 진해짐
44
- - 두께: `--scrollbar-size` 토큰 (Density 축 영향 없음, 고정)
46
+ - 두께: `--scrollbar-size` 토큰 — overlay 두께이지 공간 할당 아님. 커지면 overlay 가 더 두껍게 뜰 뿐, 콘텐츠는 안 밀림.
45
47
  - macOS 방식(사용 중일 때만 얇게 노출) — Notion/Linear/Figma 스타일
46
48
 
49
+ #### Don't (스크롤바)
50
+ - `scrollbar-gutter: stable` 로 트랙 공간 미리 확보하기 — 그게 "고정 스크롤바" 다.
51
+ - 컴포넌트/페이지에서 `::-webkit-scrollbar-*` 또는 `scrollbar-*` 를 재선언해 트랙 폭을 만들기.
52
+ - 평상시에도 트랙·thumb 이 보이는 "always visible" 스타일. 특히 다크 모드에서 흰 띠가 뜸.
53
+
47
54
  ### 스크롤 페이드 (edge fade mask)
48
55
 
49
56
  스크롤로 잘려나가는 콘텐츠 경계를 부드럽게 처리. 사용자에게 "더 있음"을 암시하면서 카드 벽에 텍스트가 딱 붙는 느낌을 없앤다.
@@ -14,7 +14,7 @@
14
14
 
15
15
  - eyebrow와 title 사이: `space-1`
16
16
  - title과 description 사이: `space-2`
17
- - 블록 전체 아래 (다음 섹션까지): `space-6` (앱 리듬) / `space-10` (랜딩 리듬)
17
+ - **블록 전체 아래 (다음 섹션까지)의 간격은 상위 wrapper 의 `gap` 이 담당**. `space-6` (앱 리듬) / `space-10` (랜딩 리듬). PageHeader 자체는 `margin-bottom` 을 걸지 않는다 ([[feedback_page_header_no_bottom_margin]]).
18
18
  - actions는 title과 같은 row — `align-items: center`, `flex: 1 1 auto` title / `flex: 0 0 auto` actions
19
19
 
20
20
  ### Do
@@ -34,29 +34,43 @@
34
34
  서브텍스트가 애매하면 생략. 설명이 1줄 미만이면 쓰지 않는다. actions도 없으면 h1 단독으로.
35
35
 
36
36
  ### 조립 예시
37
+
38
+ PageHeader → 다음 섹션 사이 간격은 **외부 wrapper 가 담당**. PageHeader 자체는 `margin-bottom` 을 걸지 않음.
39
+
37
40
  ```tsx
38
- <header className="page-header">
39
- {/* eyebrow 옵션 */}
40
- <div className="eyebrow">프로젝트</div>
41
- <div className="title-row">
42
- <h1>개발 계획·실행</h1>
43
- <div className="actions">
44
- <Button variant="outline" size="md">가져오기</Button>
45
- <Button variant="primary" size="md">새 계획</Button>
41
+ <div className="page-shell">
42
+ <header className="page-header">
43
+ {/* eyebrow 옵션 */}
44
+ <div className="eyebrow">프로젝트</div>
45
+ <div className="title-row">
46
+ <h1>개발 계획·실행</h1>
47
+ <div className="actions">
48
+ <Button variant="outline" size="md">가져오기</Button>
49
+ <Button variant="primary" size="md">새 계획</Button>
50
+ </div>
46
51
  </div>
47
- </div>
48
- <p className="description">
49
- 확정본을 선택해 개발 계획을 요청하고, 계획과 작업 범위를 승인한 뒤 실행하세요.
50
- </p>
51
- </header>
52
+ <p className="description">
53
+ 확정본을 선택해 개발 계획을 요청하고, 계획과 작업 범위를 승인한 뒤 실행하세요.
54
+ </p>
55
+ </header>
56
+
57
+ {/* 다음 섹션 — gap 이 PageHeader 와 섹션 사이 간격을 공급 */}
58
+ <section>...</section>
59
+ </div>
52
60
  ```
53
61
 
54
62
  ```css
63
+ .page-shell {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: var(--space-6); /* 앱 리듬. 랜딩이면 --space-10 */
67
+ }
68
+
55
69
  .page-header {
56
70
  display: flex;
57
71
  flex-direction: column;
58
72
  gap: var(--space-1); /* eyebrow→title */
59
- margin-bottom: var(--space-6);
73
+ /* margin-bottom 금지 — 상위 .page-shell 의 gap 이 담당 */
60
74
  }
61
75
  .page-header .title-row {
62
76
  display: flex;
@@ -99,6 +99,11 @@ description: 와이어프레임·목업·기존 페이지를 CTDS로 번역·교
99
99
  - **그룹핑 재편**: 역할이 같은 것은 하나의 Section/Card에, 다른 것은 분리.
100
100
  - **원본에 없던 CTDS 패턴 추가**: EmptyState·Toolbar·Breadcrumb 등 원본엔 없지만 CTDS 페이지에 필요한 요소는 **추가하는 게 기본**.
101
101
  - **벤더에 없는 자리는 레시피 참조**: 페이지 헤더·섹션 헤더·카드 row·메타 라인·폼 푸터·안내 배너 등 자주 등장하는 자리는 `prompts/recipes/<자리>.md`를 **반드시 열어** 토큰·구조 조합을 확인. 수제 조립로 가지 않는다 ([[ASSEMBLY]]).
102
+ - **Sidebar 패딩 공급 계획 — 조립 코드 작성 전에** ⭐ ([[feedback_wireframe_sidebar_padding_check]]) 번역 결과에 Sidebar 가 포함되면 글로 적어서 확정하고 넘어간다:
103
+ - `Sidebar.Header` 안쪽 패딩은 누가 공급하는가? (벤더는 공급 안 함)
104
+ - `Sidebar.Body` 안 섹션 패딩은 누가 공급하는가? preset slot(`data-sidebar-slot`) vs 콘텐츠 래퍼 vs 바깥 flex 조립 중 하나로 결정.
105
+ - `Sidebar.Footer` 가 있으면 안쪽 패딩은 누가 공급하는가?
106
+ - 결정 없이 바로 조립 들어가면 메뉴·검색이 가장자리에 붙음.
102
107
 
103
108
  ## 체크리스트 — 번역 완료 전 전부 통과
104
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conscience-technology/ctds",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "CTDS — 하이브리드 디자인 시스템 설치 · 온보딩 CLI (shadcn식 코드 생성기)",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -48,9 +48,9 @@
48
48
  "directory": "packages/ctds-cli"
49
49
  },
50
50
  "peerDependencies": {
51
- "react": ">=18.2.0",
52
- "react-dom": ">=18.2.0",
53
- "next": ">=14.0.0",
51
+ "react": ">=18.2.0 <20.0.0",
52
+ "react-dom": ">=18.2.0 <20.0.0",
53
+ "next": ">=14.0.0 <17.0.0",
54
54
  "@phosphor-icons/react": "^2.1.10",
55
55
  "solar-icon-set": "^2.0.1",
56
56
  "lucide-react": "^1.47.0",
@@ -23,7 +23,13 @@ import { checkMissingDeps, renderInstallCmd } from "../utils/deps.js";
23
23
  import { c, step, success, warn, info, kv } from "../utils/log.js";
24
24
  import { computeFilesMap, writeMeta } from "../utils/meta.js";
25
25
  import { hasDevScript, startDevAndOpen } from "../utils/launch.js";
26
- import { patchGlobalsCss, patchLayoutTsx, inspectNextConfig } from "../utils/patch.js";
26
+ import {
27
+ patchGlobalsCss,
28
+ patchLayoutTsx,
29
+ patchNextConfig,
30
+ patchPostCssConfig,
31
+ hasPkgDep,
32
+ } from "../utils/patch.js";
27
33
 
28
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
35
  const CLI_PKG_PATH = join(__dirname, "..", "..", "package.json");
@@ -58,9 +64,20 @@ export async function run(args) {
58
64
  kv("경로", cwd);
59
65
  kv("app router", project.appDir);
60
66
  kv("src/ 사용", project.useSrcDir ? "yes" : "no");
67
+ if (project.nextVersion) kv("next", project.nextVersion);
61
68
  if (dry) kv("모드", c.yellow("dry run (쓰기 없음)"));
62
69
  console.log("");
63
70
 
71
+ // Next 16+는 create-next-app이 `@tailwindcss/turbopack` + `turbopack.rules["*.css"]`를
72
+ // 자동 생성하는데, 그 룰이 built-in Lightning CSS CSS Modules 경로를 bypass해버려
73
+ // `.module.css`의 class export가 전부 undefined로 깨짐. 아래 자동 패치로 Tailwind를
74
+ // PostCSS 경로로 옮기면 Next 15/16 모두 안전하게 작동. (아래 patchNextConfig/
75
+ // patchPostCssConfig가 그 역할.)
76
+ if (project.nextMajor != null && project.nextMajor >= 16) {
77
+ info(`Next.js ${project.nextMajor} 감지 — Tailwind를 PostCSS 경로로 자동 전환합니다.`);
78
+ console.log("");
79
+ }
80
+
64
81
  const assets = getAssetSources();
65
82
  info(`자산 소스: ${assets.mode === "bundled" ? "번들" : "로컬 레포"}`);
66
83
  console.log("");
@@ -175,15 +192,53 @@ export async function run(args) {
175
192
  });
176
193
  }
177
194
 
178
- // 3-3) next.config의 Turbopack *.css 전역 룰 감지
179
- // (Tailwind create-next-app 등으로 생성된 설정에서 .module.css가 undefined로 깨지는 함정)
180
- const nextCheck = inspectNextConfig(cwd);
181
- if (nextCheck.status === "risk") {
195
+ // 3-3) next.config의 turbopack.rules["*.css"] 삭제 + Tailwind를 PostCSS로 전환.
196
+ // Next 15 사용자는 next.config가 영향 없고 (룰이 없음), Next 16은 자동 복구.
197
+ const nextResult = patchNextConfig(cwd, { dry });
198
+ if (nextResult.status === "applied") {
199
+ const name = (nextResult.filePath ?? "next.config").split("/").pop();
200
+ step(dry ? `[dry] 패치` : `패치`, `${name} — turbopack.rules의 *.css 삭제`);
201
+ } else if (nextResult.status === "failed") {
202
+ warn(`next.config 자동 패치 실패: ${nextResult.reason}`);
182
203
  manualFallbacks.push({
183
- title: `${c.cyan(nextCheck.name)}의 turbopack.rules가 *.css를 전역 처리하고 있어요. CSS Modules가 깨질 수 있으니 *.module.css 제외 룰을 추가:`,
204
+ title: `${c.cyan("next.config.ts")}에서 ${c.bold(`turbopack.rules["*.css"]`)} 블록을 통째로 삭제하세요.`,
184
205
  lang: "ts",
185
- body: `turbopack: {\n rules: {\n "*.css": { loaders: [/* 기존 설정 */], as: "*.css" },\n "*.module.css": {}, // ← CSS Modules는 Next 기본 처리로 넘김\n },\n},`,
186
- note: "증상: styles.foo가 undefined로 import됨 → 레이아웃 전부 깨짐.",
206
+ body: `// 삭제 전\nturbopack: {\n rules: {\n "*.css": { loaders: ["@tailwindcss/turbopack"], as: "*.css" },\n },\n},\n\n// 삭제 후 — turbopack 블록 자체 제거`,
207
+ note: "이 룰이 Lightning CSS CSS Modules 경로를 bypass해 .module.css class를 global로 처리하게 만듦.",
208
+ });
209
+ }
210
+
211
+ // 3-4) PostCSS 설정 — Tailwind 사용 플젝에만. postcss.config이 없으면 신규 생성해
212
+ // Next 16 native PostCSS 처리로 Tailwind를 돌리게. @tailwindcss/turbopack 대체.
213
+ const hasTailwind = hasPkgDep(cwd, "tailwindcss");
214
+ const postCssResult = patchPostCssConfig(cwd, { dry, hasTailwind });
215
+ if (postCssResult.status === "applied") {
216
+ step(dry ? `[dry] 생성` : `생성`, `postcss.config.mjs — @tailwindcss/postcss plugin`);
217
+ } else if (postCssResult.status === "failed") {
218
+ warn(`postcss.config 자동 패치 실패: ${postCssResult.reason}`);
219
+ manualFallbacks.push({
220
+ title: `${c.cyan(postCssResult.filePath?.split("/").pop() ?? "postcss.config.mjs")}에 ${c.bold("@tailwindcss/postcss")} 플러그인을 추가:`,
221
+ lang: "js",
222
+ body: `export default {\n plugins: { "@tailwindcss/postcss": {} },\n};`,
223
+ });
224
+ }
225
+
226
+ // 3-5) @tailwindcss/postcss 미설치 안내 (Tailwind 쓰는데 플러그인 없는 경우)
227
+ if (hasTailwind && !hasPkgDep(cwd, "@tailwindcss/postcss")) {
228
+ manualFallbacks.push({
229
+ title: `${c.bold("@tailwindcss/postcss")}를 설치하세요 (Tailwind가 PostCSS 경로로 작동):`,
230
+ lang: "bash",
231
+ body: `npm install -D @tailwindcss/postcss`,
232
+ });
233
+ }
234
+
235
+ // 3-6) @tailwindcss/turbopack이 설치돼 있으면 제거 안내 (자동 삭제는 npm 호출이 리스크)
236
+ if (hasTailwind && hasPkgDep(cwd, "@tailwindcss/turbopack")) {
237
+ manualFallbacks.push({
238
+ title: `${c.bold("@tailwindcss/turbopack")}은 더이상 필요 없어요. 제거하세요:`,
239
+ lang: "bash",
240
+ body: `npm uninstall @tailwindcss/turbopack`,
241
+ note: "@tailwindcss/postcss가 Turbopack native PostCSS 경로로 Tailwind를 처리합니다.",
187
242
  });
188
243
  }
189
244
 
@@ -19,10 +19,10 @@ export function detectProject(cwd) {
19
19
  return { ok: false, reason: `package.json 파싱 실패: ${e.message}` };
20
20
  }
21
21
 
22
- const hasNext =
22
+ const nextRange =
23
23
  (pkg.dependencies && pkg.dependencies.next) ||
24
24
  (pkg.devDependencies && pkg.devDependencies.next);
25
- if (!hasNext) {
25
+ if (!nextRange) {
26
26
  return {
27
27
  ok: false,
28
28
  reason: "package.json에 next 의존성이 없습니다. Next.js 플젝이 아닙니다.",
@@ -38,10 +38,22 @@ export function detectProject(cwd) {
38
38
  };
39
39
  }
40
40
 
41
+ // Next major 버전 감지. "^16.4.1" / "16.0.0" / ">=15.0.0 <17.0.0" 등 느슨히 커버.
42
+ // 못 뽑으면 null — 경고 없이 통과.
43
+ const nextMajor = parseMajor(nextRange);
44
+
41
45
  return {
42
46
  ok: true,
43
47
  name: pkg.name ?? "(unnamed)",
44
48
  useSrcDir,
45
49
  appDir: useSrcDir ? "src/app" : "app",
50
+ nextVersion: nextRange,
51
+ nextMajor,
46
52
  };
47
53
  }
54
+
55
+ /** semver range 문자열에서 유의미한 major 숫자 하나 추출. 범위면 하한 기준. */
56
+ function parseMajor(range) {
57
+ const m = String(range).match(/\d+/);
58
+ return m ? Number(m[0]) : null;
59
+ }
@@ -145,41 +145,151 @@ export function patchLayoutTsx(
145
145
  }
146
146
 
147
147
  /**
148
- * next.config.(ts|mjs|js) 감지:
149
- * turbopack.rules가 `*.css` 또는 `**\/*.css` 전역에 loader를 걸고 있는데
150
- * `*.module.css` 제외 규칙이 없으면 CSS Modules import가 undefined로 깨짐.
151
- * (Next create 시 Tailwind 옵션으로 생성된 설정에서 자주 발생)
148
+ * next.config.(ts|mjs|js) 자동 패치:
149
+ * `turbopack.rules["*.css"]` 블록을 **삭제**.
152
150
  *
153
- * 자동 패치는 AST가 필요해 깨지기 쉬우므로 감지만 하고 수동 fallback으로 안내.
151
+ * 왜: Turbopack glob 매칭은 `.module.css`도 `.css`에 포함시킴.
152
+ * 사용자 룰이 등록되면 built-in Lightning CSS CSS Modules 경로가 bypass되고
153
+ * 결과가 global CSS로 처리돼 `.module.css`의 class export가 전부 undefined.
154
+ * create-next-app이 `@tailwindcss/turbopack` 옵션으로 뽑은 설정이 이 함정에 걸림.
155
+ * Next 16은 PostCSS를 네이티브 지원하므로 Tailwind는 postcss.config로 옮기면 됨.
154
156
  *
155
- * 리턴:
156
- * { status: "ok" } — 설정 안전 or 파일 없음
157
- * { status: "risk", filePath, snippet } — 전역 .css 룰은 있는데 .module.css 제외 없음
157
+ * 전략:
158
+ * 1) `"*.css":` 룰 블록을 짝맞춤 `}`로 추적해 통째 삭제 (앞뒤 콤마·공백 포함)
159
+ * 2) `rules: {}` 가 비어지면 `rules` 자체 제거
160
+ * 3) `turbopack: {}` 가 비어지면 `turbopack` 자체 제거
161
+ * 4) 매칭 실패하면 fail → 수동 fallback
158
162
  */
159
- export function inspectNextConfig(cwd) {
163
+ export function patchNextConfig(cwd, { dry = false } = {}) {
164
+ const filePath = findNextConfig(cwd);
165
+ if (!filePath) return { status: "skipped", reason: "next.config.(ts|mjs|js) 없음." };
166
+
167
+ const before = readFileSync(filePath, "utf8");
168
+ const cssRuleMatch = before.match(/(["'`])(\*\*\/)?\*\.css\1\s*:\s*\{/);
169
+ if (!cssRuleMatch) {
170
+ return { status: "skipped", reason: "turbopack.rules의 *.css 룰이 없음." };
171
+ }
172
+
173
+ // "*.css": { ... }, ← 블록 전체 범위 추적
174
+ const keyStart = cssRuleMatch.index;
175
+ const openBrace = cssRuleMatch.index + cssRuleMatch[0].length - 1;
176
+ let depth = 1;
177
+ let closeBrace = -1;
178
+ for (let i = openBrace + 1; i < before.length; i++) {
179
+ const ch = before[i];
180
+ if (ch === "{") depth += 1;
181
+ else if (ch === "}") {
182
+ depth -= 1;
183
+ if (depth === 0) {
184
+ closeBrace = i;
185
+ break;
186
+ }
187
+ }
188
+ }
189
+ if (closeBrace === -1) {
190
+ return { status: "failed", reason: "`*.css` 룰 블록 짝 `}`를 찾지 못함 — 수동 설정 필요." };
191
+ }
192
+
193
+ // 블록 뒤의 선택적 콤마까지 삭제 범위에 포함
194
+ let endExclusive = closeBrace + 1;
195
+ if (before[endExclusive] === ",") endExclusive += 1;
196
+
197
+ // 블록 앞의 라인 선두 공백도 함께 삭제해서 빈 라인이 남지 않게
198
+ let startInclusive = keyStart;
199
+ while (startInclusive > 0 && /[ \t]/.test(before[startInclusive - 1])) {
200
+ startInclusive -= 1;
201
+ }
202
+
203
+ let next =
204
+ before.slice(0, startInclusive) +
205
+ before.slice(endExclusive);
206
+
207
+ // 룰 삭제 결과 생긴 공백 라인 하나 정리 (연속 \n\n\n → \n\n)
208
+ next = next.replace(/\n[ \t]*\n[ \t]*\n/g, "\n\n");
209
+
210
+ // `rules: {}` 가 비어졌으면 `rules` 자체 제거
211
+ next = next.replace(/\s*rules\s*:\s*\{\s*\}\s*,?/g, "");
212
+
213
+ // `turbopack: {}` 가 비어졌으면 `turbopack` 자체 제거
214
+ next = next.replace(/\s*turbopack\s*:\s*\{\s*\}\s*,?/g, "");
215
+
216
+ if (next === before) {
217
+ return { status: "skipped", reason: "변경 사항 없음." };
218
+ }
219
+ if (dry) {
220
+ return { status: "applied", reason: "(dry) turbopack.rules *.css 삭제", before, after: next };
221
+ }
222
+ copyFileSync(filePath, filePath + ".bak");
223
+ writeFileSync(filePath, next, "utf8");
224
+ return {
225
+ status: "applied",
226
+ reason: "turbopack.rules의 *.css 룰 삭제 (CSS Modules 복구)",
227
+ filePath,
228
+ };
229
+ }
230
+
231
+ function findNextConfig(cwd) {
160
232
  const candidates = ["next.config.ts", "next.config.mjs", "next.config.js"];
161
- let filePath = null;
233
+ for (const name of candidates) {
234
+ const abs = `${cwd}/${name}`;
235
+ if (existsSync(abs)) return abs;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * postcss.config.{mjs,js,cjs,json} 자동 패치/생성:
242
+ * `@tailwindcss/postcss` 플러그인을 추가해 Turbopack native PostCSS 경로로 Tailwind 처리.
243
+ * `@tailwindcss/turbopack`을 쓰던 자리를 대체. 멱등.
244
+ *
245
+ * 1) 설정 파일이 하나도 없고 Tailwind가 설치돼 있으면 postcss.config.mjs 신규 생성
246
+ * 2) 설정 파일이 있고 `@tailwindcss/postcss` 플러그인이 이미 있으면 skip
247
+ * 3) 그 외 (설정 파일 있는데 플러그인 없음) → fail (사용자 수정이 안전)
248
+ *
249
+ * Tailwind 미사용 플젝에서는 skip.
250
+ */
251
+ export function patchPostCssConfig(cwd, { dry = false, hasTailwind = false } = {}) {
252
+ if (!hasTailwind) {
253
+ return { status: "skipped", reason: "Tailwind 미사용 — PostCSS 설정 불필요." };
254
+ }
255
+
256
+ const candidates = ["postcss.config.mjs", "postcss.config.js", "postcss.config.cjs", "postcss.config.json"];
162
257
  for (const name of candidates) {
163
258
  const abs = `${cwd}/${name}`;
164
259
  if (existsSync(abs)) {
165
- filePath = abs;
166
- break;
260
+ const src = readFileSync(abs, "utf8");
261
+ if (/@tailwindcss\/postcss/.test(src)) {
262
+ return { status: "skipped", reason: `${name}에 @tailwindcss/postcss 이미 등록됨.` };
263
+ }
264
+ return {
265
+ status: "failed",
266
+ reason: `${name}이 있는데 @tailwindcss/postcss 없음 — 수동 추가 필요.`,
267
+ filePath: abs,
268
+ };
167
269
  }
168
270
  }
169
- if (!filePath) return { status: "ok" };
170
-
171
- const src = readFileSync(filePath, "utf8");
172
- // turbopack 블록 안에 .css 전역 룰이 있는지 (loaders 지정)
173
- const hasTurbopackCssRule =
174
- /turbopack\s*:\s*\{[\s\S]*?rules\s*:\s*\{[\s\S]*?["'`](?:\*\*\/)?\*\.css["'`]\s*:/.test(
175
- src,
176
- );
177
- if (!hasTurbopackCssRule) return { status: "ok" };
178
271
 
179
- const hasModuleExclusion = /["'`](?:\*\*\/)?\*\.module\.css["'`]\s*:\s*(?:\[\s*\]|false)/.test(src);
180
- if (hasModuleExclusion) return { status: "ok" };
272
+ // 설정 없음 → 신규 생성
273
+ const newPath = `${cwd}/postcss.config.mjs`;
274
+ const content = `export default {\n plugins: { "@tailwindcss/postcss": {} },\n};\n`;
275
+ if (dry) {
276
+ return { status: "applied", reason: "(dry) postcss.config.mjs 신규 생성 예정", filePath: newPath };
277
+ }
278
+ writeFileSync(newPath, content, "utf8");
279
+ return { status: "applied", reason: "postcss.config.mjs 신규 생성", filePath: newPath };
280
+ }
181
281
 
182
- return { status: "risk", filePath, name: filePath.split("/").pop() };
282
+ /** `pkg.dependencies`·`pkg.devDependencies`에서 특정 이름 체크. */
283
+ export function hasPkgDep(cwd, name) {
284
+ try {
285
+ const pkg = JSON.parse(readFileSync(`${cwd}/package.json`, "utf8"));
286
+ return Boolean(
287
+ (pkg.dependencies && pkg.dependencies[name]) ||
288
+ (pkg.devDependencies && pkg.devDependencies[name]),
289
+ );
290
+ } catch {
291
+ return false;
292
+ }
183
293
  }
184
294
 
185
295
  /** 파일 상단의 마지막 import 바로 아래에 line 삽입. import가 없으면 파일 최상단. */
@@ -1,72 +0,0 @@
1
- # Focus
2
-
3
- ### 원칙
4
- 데이터 밀도 중심 — 관리자 대시보드, 분석 도구, 로그 뷰어, 트레이딩 콘솔처럼 **한 화면에 많은 정보를 올려야 하고 사용자가 매일 몇 시간씩 들여다보는** 자리의 Theme. 꾸밈을 걷어내고 밀도를 올려 "읽기 좋음"에 전부를 투자한다. `spare`가 "매일 쓰는 도구의 중앙값"이라면 `focus`는 그 극단 — 밀도를 한 단계 더 올리고 브랜드 액센트를 완전히 중성화한다.
5
-
6
- ### Do
7
- - 테이블·리스트·사이드바에 가능한 많은 행을 노출. 섹션 간 호흡은 Density가 결정하되 추가 margin은 금지.
8
- - 상태는 색이 아니라 **배지·형태·weight**로 먼저 전달. 색은 status(warning/danger/success/info)에만 남긴다.
9
- - 액션은 Toolbar·IconButton·DropdownMenu 중심. 큰 primary 버튼은 폼 제출·CTA 자리에만.
10
- - 모든 숫자·금액 셀은 좌측 정렬 유지 — [[feedback_no_right_align_numbers]] 그대로.
11
- - 사이드바·탑바는 작은 size(sm)로 밀도를 더 쪼은다.
12
-
13
- ### Don't
14
- - 마케팅·랜딩·온보딩에 쓰지 않는다. 액센트가 없어 전환이 안 터진다.
15
- - "비어 보여서" 간격이나 장식을 추가하지 않는다. compact density는 의도된 밀도.
16
- - `vivid`/`pastel` palette로 override하지 않는다 — 밀도가 높을수록 색 노이즈가 피로를 가속.
17
- - 브랜드 identity를 색으로 보여야 하는 자리엔 이 Theme이 맞지 않음. [[themes/spare]]나 [[themes/expressive]]로.
18
-
19
- ### Fallback
20
- 축 preset은 아래 표 값으로 고정. palette만 브랜드 선호에 따라 `mono` ↔ `standard` 사이 선택 가능.
21
-
22
- ### 관련
23
- - [[themes/_SCHEMA]] — Theme 스키마
24
- - [[themes/spare]] — "매일 쓰는 도구의 중앙값". 밀도를 한 단계 낮춘 버전.
25
- - [[axes/palette]] — `mono` 값과의 상호작용(아이콘·포커스·nav-active 전부 중성 fg 톤).
26
- - [[axes/density]] — `compact` 값 전용 프리셋.
27
-
28
- ---
29
-
30
- ### Signature
31
- 이 Theme IS "매일 몇 시간씩 머무는 작업실" UI. 데이터 밀도가 디자인의 1원칙이고, 꾸밈은 피로 가속기라는 전제 위에 있다. 터미널의 자제력을 웹 앱의 레이어에 끌어들인 결과물 — not minimalism, but engineered legibility.
32
-
33
- ### Preset
34
-
35
- | Axis | Value | Why |
36
- |---|---|---|
37
- | [[axes/mode]] | `dark` | 장시간 사용 환경에서 피로 최소화. light override는 소비자 선호. |
38
- | [[axes/palette]] | `mono` | 브랜드 액센트 완전 제거. 아이콘·포커스·nav-active 전부 fg 톤. status만 유색 유지. |
39
- | [[axes/shape]] | `crisp` | 격자 자체가 신호. soft radius는 밀도 환경에서 "뭉개짐"으로 읽힌다. |
40
- | [[axes/density]] | `compact` | 핵심 축. 행 간격·컨트롤 높이가 한 단계 좁아진다. |
41
- | [[axes/font]] | `lang=ko` | 한국어 기본. 폰트 스택은 Font 축이 담당. |
42
- | [[axes/icon]] | `phosphor` | regular/fill 매트릭 완전 매칭. 밀도 환경에선 아이콘 식별이 중요. |
43
- | [[axes/motion]] | `calm` | 애니메이션 최소화 — 눈이 머무는 시간이 길어 bounce/overshoot가 피로. none은 접근성 승인된 사용자용. |
44
-
45
- ### Layout
46
- 2-column 또는 3-pane이 디폴트 — 좌측 사이드바(sm size) + 중앙 작업 영역 + 우측 디테일 패널. 한 화면에 가능한 많은 정보를 수평으로 분할. 1-column 중심 레이아웃(블로그·마케팅)엔 맞지 않음.
47
-
48
- ### Imagery
49
- 없음. 데이터와 아이콘만. 사진·일러스트는 이 Theme의 성격과 어긋남 — 작업 중 시선을 분산시킴. 예외: 사용자 아바타·조직 로고 같은 identity 식별자.
50
-
51
- ### Decoration
52
- 없음. hairline과 bg-subtle의 톤 차이만으로 그룹 구분. gradient·noise·pattern 금지. 이 Theme에서 "장식은 피로"의 공리.
53
-
54
- ### Negative
55
- - no primary accent color — status 외 모든 색은 중성 fg 톤
56
- - no large hero section — "눈에 띄는 한 자리"가 없는 게 디폴트
57
- - no illustrations · no photography — 작업 영역에 아트는 노이즈
58
- - no soft radius — crisp 격자가 밀도의 전제
59
- - no bouncy motion — overshoot는 매일 보기 피곤
60
-
61
- ### Override
62
- 전부 자유. 사용자가 "focus에서 shape만 soft로"처럼 지시하면 그대로 적용. 책임은 사용자. Theme 선언을 벗어난 조합의 완성도는 보증하지 않는다 ([[PRINCIPLES]] §7).
63
-
64
- ### Known Gaps
65
- - 랜딩·마케팅 자리는 커버 안 함 — [[themes/expressive]]로.
66
- - 모바일 터치 중심 UX는 커버 안 함 — compact density는 데스크톱·태블릿 가로 전제.
67
- - 색으로 identity를 전달해야 하는 브랜드엔 맞지 않음 — mono가 디폴트이므로.
68
-
69
- ### Iteration Guide
70
- 1. 밀도를 더 올리고 싶으면 size prop(xs/sm)으로 가되, density 축 자체는 compact에서 더 내릴 수 없다 — compact이 최종 밀도.
71
- 2. palette를 `mono`에서 `standard` 또는 `calm`으로 올리는 override는 허용. 그 외(vivid/pastel)는 Theme과 상충.
72
- 3. motion을 bouncy로 올리는 override는 **금지** — focus의 핵심 자제가 깨짐.
File without changes