← 목록
기타 2026-06-14 63KB 읽기 85분

크라우니 3 deliverable 설계 (울트라 병렬, 2026-06-14)


[ai-knowledge]

현재 코드 구조를 충분히 파악했다. 이제 설계를 작성한다.


crowny.org AI 지식 주입 구현 설계

현재 상태 분석부터 시작한다. philosophy.js(철학 헌장), knowledge/*.txt(7개 파일), SERVICES_DIR(25개 하드코딩), TOPIC_KEYWORDS + detectTopics() + buildKnowledge()가 이미 있다. 문제는 126개 서비스가 knowledge/services.txtSERVICES_DIR 두 곳에 불일치 상태로 흩어져 있고, 서비스 조회 라우팅이 단순 키워드 매칭이라 "크라우니페이 뭐야?"같은 질문에 정확히 응답하지 못한다는 것이다.


(1) 지식을 어디에 담나 — 3계층 구조

┌─────────────────────────────────────────────────────────┐
│  LAYER 0: System Prompt (항상 주입, 불변)                │
│  philosophy.js → 철학헌장(~1500 토큰) + 응답형식          │
├─────────────────────────────────────────────────────────┤
│  LAYER 1: Slot (요청당 동적 주입, 0~2000 토큰)           │
│  knowledge/ 파일 → 토픽 감지 시만 해당 섹션 주입           │
│  서비스 카탈로그 → 서비스 질문 감지 시 matching 3~5개만   │
├─────────────────────────────────────────────────────────┤
│  LAYER 2: RAG (미래 확장 — 지금은 파일 인덱스로 충분)     │
│  knowledge/index.json → 키워드→섹션 역색인               │
│  복잡한 질문이 오면 섹션 단위 청크 검색                    │
└─────────────────────────────────────────────────────────┘

결론: 현재 규모(126서비스, 7지식파일)는 Layer 0 + Layer 1만으로 충분하다. Pro 모델 context window(8192 토큰) 안에 필요한 것만 선별 주입한다. RAG는 서비스가 300개+가 될 때 도입한다.

토큰 예산 배분:

  • Layer 0 철학: ~1500 토큰 (고정)
  • Layer 1 지식슬롯: 최대 1500 토큰 (동적)
  • 대화 히스토리(최근 20턴): ~2000 토큰
  • 응답 예비: 3000 토큰
  • 합계: 8000 토큰 ≤ Pro 8192

(2) 철학/헌법/세계관/서비스카탈로그 큐레이션 구조

파일 레이아웃 (신설 + 기존 통합)

knowledge/
├── services.txt          ← 기존(116개) → 126개로 확장 + 구조 정비
├── services-detail.json  ← 신설: id별 상세(설명/링크/태그/키워드)  ★핵심
├── philosophy.txt        ← 신설: philosophy.js 내용의 평문 버전(검색용)
├── world.txt             ← 신설: 6561 세계관 상세
├── constitution.txt      ← 신설: 크라우니 헌법(RPN/한선씨 원칙)
├── hanseon.txt           ← 기존 유지
├── isa729.txt            ← 기존 유지
├── selcore.txt           ← 기존 유지
├── crownycode.txt        ← 기존 유지
├── design.txt            ← 기존 유지
└── ai특징.txt             ← 기존 유지

knowledge/services-detail.json 구조

json{
  "version": "2026-06-14",
  "services": [
    {
      "id": "pay",
      "name": "크라우니페이",
      "domain": "pay.crowny.org",
      "port": 9866,
      "tags": ["금융", "결제", "P2P", "수수료0"],
      "keywords": ["페이", "결제", "pay", "수수료", "맘", "포네"],
      "cat": "finance",
      "desc": "PG 수수료 0% P2P 결제. 맘/포네 보상 연동. 4상 정산 구조.",
      "detail": "크라우니 자체 결제 인프라. 외부 PG사 없이 P2P 직접 결제. 특허 81건 선언."
    },
    ...
  ]
}

핵심: keywords 배열이 서비스 조회 라우팅의 역색인 키 역할을 한다.


(3) 토큰 한도 고려 주입 전략

현재 buildKnowledge()의 문제

현재는 detectTopics()가 매칭되면 KB[t] 파일 전체를 주입한다. services.txt가 통째로 들어오면 ~800 토큰이 낭비된다.

개선: 청크 단위 선택적 주입

javascript// engine/knowledge-loader.js (신설)
'use strict';

const fs = require('fs');
const path = require('path');

const KD = path.join(__dirname, '..', 'knowledge');

// 서비스 상세 DB (시작 시 1회 로드)
let _serviceDb = null;
function serviceDb() {
  if (!_serviceDb) {
    try {
      _serviceDb = JSON.parse(fs.readFileSync(path.join(KD, 'services-detail.json'), 'utf8'));
    } catch { _serviceDb = { services: [] }; }
  }
  return _serviceDb;
}

// 질문에서 서비스를 찾는다 — 키워드 역색인 매칭
function matchServices(text) {
  const lower = text.toLowerCase();
  const db = serviceDb();
  const scored = db.services.map(svc => {
    let score = 0;
    for (const kw of (svc.keywords || [])) {
      if (lower.includes(kw.toLowerCase())) score += 2;
    }
    if (lower.includes(svc.name)) score += 3;
    if (lower.includes(svc.id)) score += 2;
    if (lower.includes(svc.domain || '')) score += 3;
    return { svc, score };
  }).filter(x => x.score > 0).sort((a, b) => b.score - a.score);

  return scored.slice(0, 5).map(x => x.svc); // 최대 5개
}

// 서비스 조회 컨텍스트 생성 (~200 토큰 이내)
function buildServiceContext(matches) {
  if (!matches.length) return '';
  const lines = matches.map(s =>
    `- **${s.name}** (${s.domain}): ${s.detail || s.desc}`
  );
  return `\n\n=== 관련 크라우니 서비스 ===\n${lines.join('\n')}`;
}

// 지식 청크 선택적 주입 — topics + 토큰 예산
const CHUNK_BUDGET = 1500; // 토큰 추정 (문자 × 0.6)
const CHUNK_PRIORITY = ['services', 'hanseon', 'selcore', 'isa729', 'crownycode', 'design', 'ai특징'];

function buildKnowledgeSlot(topics, serviceMatches, KB) {
  let slot = '';
  let budget = CHUNK_BUDGET;

  // 서비스 매칭이 있으면 최우선
  if (serviceMatches.length) {
    const ctx = buildServiceContext(serviceMatches);
    slot += ctx;
    budget -= Math.ceil(ctx.length * 0.6);
  }

  // 나머지 토픽 파일 (예산 내에서)
  for (const topic of CHUNK_PRIORITY) {
    if (!topics.includes(topic)) continue;
    const chunk = KB[topic] || '';
    const cost = Math.ceil(chunk.length * 0.6);
    if (budget - cost < 0) {
      // 예산 초과 시 앞 700자만
      const partial = chunk.slice(0, 1100);
      slot += `\n\n=== ${topic.toUpperCase()} (요약) ===\n${partial}\n...(생략)`;
      break;
    }
    slot += `\n\n=== ${topic.toUpperCase()} ===\n${chunk}`;
    budget -= cost;
  }

  return slot;
}

module.exports = { matchServices, buildServiceContext, buildKnowledgeSlot, serviceDb };


(4) "X 서비스 뭐야?" 질문 라우팅

서비스 질문 감지 + 응답 흐름

사용자 입력: "크라우니페이 뭐야?" / "pay 설명해줘" / "결제 서비스 있어?"
         ↓
[1] semantic-filter.analyze() → intent='lookup', domain='finance'
         ↓
[2] matchServices(text) → [{ id:'pay', name:'크라우니페이', ... }]   ← 신설
         ↓
[3] isServiceQuery(intent, matches) → true
         ↓
[4] buildServiceContext(matches) → "=== 관련 크라우니 서비스 ===" 블록
         ↓
[5] system prompt에 slot으로 주입 → Gemini가 상세 답변
         ↓
[6] 답변 후 related 서비스 1~2개 자연스럽게 언급

server.js /api/chat 수정 포인트

javascript// 기존 detectTopics + buildKnowledge 대체
const { matchServices, buildKnowledgeSlot } = require('./engine/knowledge-loader');

// /api/chat 핸들러 내부 (streamChat 진입 전)
const analysis = semantic.analyze(message, history);
const topics = detectTopics(message);
const serviceMatches = matchServices(message);          // ← 신설

// 서비스 질문이면 knowledge slot에 서비스 컨텍스트 삽입
const knowledgeSlot = buildKnowledgeSlot(topics, serviceMatches, KB);

// system prompt 조립: philosophy + agentBase + knowledgeSlot
const system = philosophy.buildSystem(agent.system, knowledgeSlot);

philosophy.buildSystem() 시그니처 확장 (현재 extras 인자 활용)

javascript// engine/philosophy.js (기존 buildSystem 수정 없이 — extras로 전달)
function buildSystem(agentBase, extras) {
  const parts = [PHILOSOPHY, '', agentBase, '', FORMATTING];
  if (extras && extras.length) parts.push('', extras);  // knowledgeSlot이 여기 들어감
  return parts.join('\n');
}
// 호출부: philosophy.buildSystem(agent.system, knowledgeSlot)
// 이미 이 시그니처를 쓰고 있으므로 호환됨


구현 순서 (3단계)

Step 1 — knowledge/services-detail.json 생성

126개 서비스를 id/name/domain/port/tags/keywords/cat/desc/detail 스키마로 큐레이션. 기존 services.txtSERVICES_DIR 배열을 단일 JSON으로 통합.

Step 2 — engine/knowledge-loader.js 신설

matchServices(), buildServiceContext(), buildKnowledgeSlot() 구현. 토큰 예산 관리 포함.

Step 3 — server.js 수정

  • detectTopics + buildKnowledge 호출을 knowledge-loader로 교체
  • serviceMatches를 구해서 knowledgeSlot에 통합
  • buildSystem(agent.system, knowledgeSlot) 에 전달

파일별 역할 요약 (최종 아키텍처)

파일역할주입 시점
engine/philosophy.js철학헌장 + 형식규칙 (PHILOSOPHY + FORMATTING)모든 요청 — Layer 0
knowledge/services-detail.json126개 서비스 역색인 DB (keywords→매칭)서비스 질문 시 — Layer 1
knowledge/hanseon.txt한선씨 문법/내장함수code 토픽 감지 시
knowledge/isa729.txtISA729 opcode/3진isa729 토픽 감지 시
knowledge/selcore.txt셀코어 규칙엔진selcore 토픽 감지 시
knowledge/crownycode.txt크라우니코드 패턴crownycode 토픽 감지 시
knowledge/design.txt디자인 표준design 토픽 감지 시
engine/knowledge-loader.jsmatchServices + buildKnowledgeSlot매 요청 — 선택 주입
engine/semantic-filter.jsintent/domain/tier 분류매 요청 — 라우팅 결정
engine/gemini-router.js3모델 라우터 + 강등 체인매 요청 — LLM 호출

서비스 카탈로그 키워드 설계 원칙

각 서비스의 keywords 배열은 다음 4종을 포함한다:

  1. 고유명사: 서비스 이름 변형 ("크라우니페이", "pay", "페이")
  2. 기능어: 핵심 기능 ("결제", "수수료", "송금")
  3. 문제어: 사용자가 겪는 문제 ("돈 보내기", "카드 없이")
  4. 세계관어: 철학과 연결되는 단어 ("자립", "맘", "포네")
이렇게 하면 "카드 없이 결제 가능해?"같은 자연어 질문에도 pay 서비스가 매칭된다.


현재 코드 대비 변경 최소화 전략

기존 detectTopics() + buildKnowledge() + buildServiceIntro()그대로 유지한다. knowledge-loader.js는 서비스 질문에 특화된 보완 레이어로만 추가한다. 서버 수정은 /api/chat 핸들러 내 3줄 추가가 전부다:

javascript// 기존 코드 (유지)
const topics = detectTopics(message);
const knowledge = buildKnowledge(topics);

// 신설 (3줄 추가)
const { matchServices, buildKnowledgeSlot } = require('./engine/knowledge-loader');
const serviceMatches = matchServices(message);
const knowledgeSlot = serviceMatches.length
  ? buildServiceContext(serviceMatches) + knowledge
  : knowledge;
// buildSystem 호출 시 knowledge → knowledgeSlot으로 교체


[emoji-monotone]

이모지 현황과 디자인 표준을 파악했습니다. 이제 설계를 작성합니다.


크라우니 전 서비스 이모지 → 모노톤 SVG 라인아이콘 일괄 교체 구현 설계

0. 현황 요약

스캔 결과 126개 서비스 전반에 걸쳐 60+ 종류 이모지가 혼재. 주요 패턴:

카테고리대표 이모지빈도
상태✅ ❌ ⚠ ✓152+
데이터/차트📊 📈 📋 📅73+
사용자👤 👥 🤝37+
보안🔒 🔑 🔗46+
액션🔍 🔧 ⚙ 🛒 🗑55+
네비게이션❯ ⬡ ✦ ★54+
콘텐츠📚 📖 📦 📤47+
금융💰 💾 📈30+

1. 모노톤 SVG 아이콘 세트 (28개 — 공통 코어)

위치: /Users/ef/crowny-design-kit/icons/crowny-icons.svg (SVG sprite)

설계 원칙

  • stroke="currentColor" — CSS color로 색상 완전 제어
  • fill="none" — 라인 전용 (면 채움 없음)
  • stroke-width="1.5" — 디자인 표준 기준 (얇고 선명)
  • viewBox="0 0 20 20" — 20px 그리드 (16/24 확장 가능)
  • stroke-linecap="round" stroke-linejoin="round" — 크라우니 특유 부드러운 코너
xml<!-- /Users/ef/crowny-design-kit/icons/crowny-icons.svg -->
<!-- SVG Sprite — <use href="#ci-check"> 패턴으로 삽입 -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">

  <!-- ── 상태 ───────────────────────────────────────────── -->

  <!-- ci-check : ✅ ✓ (완료·성공) -->
  <symbol id="ci-check" viewBox="0 0 20 20" fill="none">
    <polyline points="3,10 8,15 17,5"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-x : ❌ ✕ (오류·닫기) -->
  <symbol id="ci-x" viewBox="0 0 20 20" fill="none">
    <line x1="4" y1="4" x2="16" y2="16"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <line x1="16" y1="4" x2="4" y2="16"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-warn : ⚠ (경고) -->
  <symbol id="ci-warn" viewBox="0 0 20 20" fill="none">
    <path d="M10 2 L18 17 H2 Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="10" y1="8" x2="10" y2="12"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <circle cx="10" cy="15" r="0.5"
      stroke="currentColor" stroke-width="1.5"/>
  </symbol>

  <!-- ci-info : (안내·정보) -->
  <symbol id="ci-info" viewBox="0 0 20 20" fill="none">
    <circle cx="10" cy="10" r="8"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="10" y1="9" x2="10" y2="14"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <circle cx="10" cy="6.5" r="0.75"
      stroke="currentColor" stroke-width="1"/>
  </symbol>

  <!-- ── 액션 ───────────────────────────────────────────── -->

  <!-- ci-search : 🔍 (검색) -->
  <symbol id="ci-search" viewBox="0 0 20 20" fill="none">
    <circle cx="8.5" cy="8.5" r="5.5"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="13" y1="13" x2="17.5" y2="17.5"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-settings : ⚙ (설정) -->
  <symbol id="ci-settings" viewBox="0 0 20 20" fill="none">
    <circle cx="10" cy="10" r="2.5"
      stroke="currentColor" stroke-width="1.5"/>
    <path d="M10 2v2M10 16v2M2 10h2M16 10h2
      M4.22 4.22l1.42 1.42M14.36 14.36l1.42 1.42
      M4.22 15.78l1.42-1.42M14.36 5.64l1.42-1.42"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-filter : (필터) -->
  <symbol id="ci-filter" viewBox="0 0 20 20" fill="none">
    <polyline points="2,4 18,4 12,11 12,17 8,15 8,11 2,4"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-trash : 🗑 (삭제) -->
  <symbol id="ci-trash" viewBox="0 0 20 20" fill="none">
    <polyline points="3,5 5,5 17,5"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <path d="M7 5V3h6v2"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <rect x="5" y="7" width="10" height="10" rx="2"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="8" y1="10" x2="8" y2="14"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <line x1="12" y1="10" x2="12" y2="14"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-edit : (수정) -->
  <symbol id="ci-edit" viewBox="0 0 20 20" fill="none">
    <path d="M13 3l4 4L7 17H3v-4L13 3Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-upload : 📤 (업로드·전송) -->
  <symbol id="ci-upload" viewBox="0 0 20 20" fill="none">
    <polyline points="13,8 10,5 7,8"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="10" y1="5" x2="10" y2="14"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <path d="M4 15v1a1 1 0 001 1h10a1 1 0 001-1v-1"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-download : (다운로드·저장) -->
  <symbol id="ci-download" viewBox="0 0 20 20" fill="none">
    <polyline points="7,11 10,14 13,11"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="10" y1="5" x2="10" y2="14"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <path d="M4 15v1a1 1 0 001 1h10a1 1 0 001-1v-1"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-save : 💾 (저장) -->
  <symbol id="ci-save" viewBox="0 0 20 20" fill="none">
    <path d="M4 3h10l3 3v11a1 1 0 01-1 1H4a1 1 0 01-1-1V4a1 1 0 011-1Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <rect x="7" y="3" width="6" height="5"
      stroke="currentColor" stroke-width="1.5"/>
    <rect x="6" y="11" width="8" height="6" rx="1"
      stroke="currentColor" stroke-width="1.5"/>
  </symbol>

  <!-- ── 사용자 ─────────────────────────────────────────── -->

  <!-- ci-user : 👤 (단일 사용자) -->
  <symbol id="ci-user" viewBox="0 0 20 20" fill="none">
    <circle cx="10" cy="7" r="3.5"
      stroke="currentColor" stroke-width="1.5"/>
    <path d="M3 18c0-3.87 3.13-7 7-7s7 3.13 7 7"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-users : 👥 (다중 사용자·커뮤니티) -->
  <symbol id="ci-users" viewBox="0 0 20 20" fill="none">
    <circle cx="7.5" cy="7" r="3"
      stroke="currentColor" stroke-width="1.5"/>
    <path d="M1 18c0-3.31 2.91-6 6.5-6"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <circle cx="13.5" cy="7" r="3"
      stroke="currentColor" stroke-width="1.5"/>
    <path d="M13.5 12c3.59 0 6.5 2.69 6.5 6"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ── 보안 ───────────────────────────────────────────── -->

  <!-- ci-lock : 🔒 (잠금·보안) -->
  <symbol id="ci-lock" viewBox="0 0 20 20" fill="none">
    <rect x="4" y="9" width="12" height="9" rx="2"
      stroke="currentColor" stroke-width="1.5"/>
    <path d="M7 9V6a3 3 0 016 0v3"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <circle cx="10" cy="14" r="1.2"
      stroke="currentColor" stroke-width="1.2"/>
  </symbol>

  <!-- ci-key : 🔑 (인증·키) -->
  <symbol id="ci-key" viewBox="0 0 20 20" fill="none">
    <circle cx="7.5" cy="9.5" r="4"
      stroke="currentColor" stroke-width="1.5"/>
    <polyline points="10.5,12.5 17,19 17,16 19,16 19,13 17,13 17,12 14,12"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-link : 🔗 (링크·연결) -->
  <symbol id="ci-link" viewBox="0 0 20 20" fill="none">
    <path d="M8 12a4 4 0 005.66.09l2-2a4 4 0 00-5.66-5.66l-1 1"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <path d="M12 8a4 4 0 00-5.66-.09l-2 2a4 4 0 005.66 5.66l1-1"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ── 데이터·콘텐츠 ──────────────────────────────────── -->

  <!-- ci-chart : 📊 (차트·통계) -->
  <symbol id="ci-chart" viewBox="0 0 20 20" fill="none">
    <polyline points="2,15 6,9 10,12 14,6 18,9"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="2" y1="18" x2="18" y2="18"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-bar-chart : (막대 차트) -->
  <symbol id="ci-bar-chart" viewBox="0 0 20 20" fill="none">
    <rect x="3" y="10" width="4" height="7" rx="1"
      stroke="currentColor" stroke-width="1.5"/>
    <rect x="8" y="6" width="4" height="11" rx="1"
      stroke="currentColor" stroke-width="1.5"/>
    <rect x="13" y="3" width="4" height="14" rx="1"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="2" y1="18" x2="18" y2="18"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-doc : 📋 📖 (문서·노트) -->
  <symbol id="ci-doc" viewBox="0 0 20 20" fill="none">
    <path d="M5 3h7l4 4v11a1 1 0 01-1 1H5a1 1 0 01-1-1V4a1 1 0 011-1Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <polyline points="12,3 12,7 16,7"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="7" y1="11" x2="13" y2="11"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <line x1="7" y1="14" x2="11" y2="14"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-calendar : 📅 (날짜·일정) -->
  <symbol id="ci-calendar" viewBox="0 0 20 20" fill="none">
    <rect x="3" y="4" width="14" height="14" rx="2"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="3" y1="9" x2="17" y2="9"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="7" y1="2" x2="7" y2="6"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <line x1="13" y1="2" x2="13" y2="6"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-package : 📦 (패키지·배포) -->
  <symbol id="ci-package" viewBox="0 0 20 20" fill="none">
    <path d="M10 2L18 6.5V13.5L10 18L2 13.5V6.5Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="10" y1="2" x2="10" y2="18"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <polyline points="2,6.5 10,11 18,6.5"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-globe : 🌐 🌍 (글로벌·인터넷) -->
  <symbol id="ci-globe" viewBox="0 0 20 20" fill="none">
    <circle cx="10" cy="10" r="8"
      stroke="currentColor" stroke-width="1.5"/>
    <ellipse cx="10" cy="10" rx="3.5" ry="8"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="2" y1="10" x2="18" y2="10"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="2.5" y1="6.5" x2="17.5" y2="6.5"
      stroke="currentColor" stroke-width="1.2"/>
    <line x1="2.5" y1="13.5" x2="17.5" y2="13.5"
      stroke="currentColor" stroke-width="1.2"/>
  </symbol>

  <!-- ci-chat : 💬 (채팅·메시지) -->
  <symbol id="ci-chat" viewBox="0 0 20 20" fill="none">
    <path d="M3 3h14a1 1 0 011 1v9a1 1 0 01-1 1H6l-4 3V4a1 1 0 011-1Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ── 비즈니스·금융 ──────────────────────────────────── -->

  <!-- ci-money : 💰 (금융·결제) -->
  <symbol id="ci-money" viewBox="0 0 20 20" fill="none">
    <rect x="2" y="5" width="16" height="11" rx="2"
      stroke="currentColor" stroke-width="1.5"/>
    <circle cx="10" cy="10.5" r="2.5"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="2" y1="8" x2="5" y2="8"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
    <line x1="15" y1="13" x2="18" y2="13"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

  <!-- ci-building : 🏢 (기업·빌딩) -->
  <symbol id="ci-building" viewBox="0 0 20 20" fill="none">
    <rect x="3" y="3" width="14" height="15" rx="1"
      stroke="currentColor" stroke-width="1.5"/>
    <line x1="3" y1="8" x2="17" y2="8"
      stroke="currentColor" stroke-width="1.2"/>
    <line x1="3" y1="13" x2="17" y2="13"
      stroke="currentColor" stroke-width="1.2"/>
    <rect x="8.5" y="14" width="3" height="4"
      stroke="currentColor" stroke-width="1.2"/>
    <rect x="5.5" y="10" width="2" height="2"
      stroke="currentColor" stroke-width="1.2"/>
    <rect x="12.5" y="10" width="2" height="2"
      stroke="currentColor" stroke-width="1.2"/>
    <rect x="5.5" y="5" width="2" height="2"
      stroke="currentColor" stroke-width="1.2"/>
    <rect x="12.5" y="5" width="2" height="2"
      stroke="currentColor" stroke-width="1.2"/>
  </symbol>

  <!-- ci-tool : 🔧 🔬 (도구·개발·분석) -->
  <symbol id="ci-tool" viewBox="0 0 20 20" fill="none">
    <path d="M13 3a4 4 0 01-4.5 6L4 14a1.41 1.41 0 002 2l4.5-4.5A4 4 0 0117 7a4 4 0 01-.44 1.83L14 7.17a1.5 1.5 0 00-1.17-1.17L12.17 4A4 4 0 0113 3Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-star : ★ ⭐ (즐겨찾기·평점) -->
  <symbol id="ci-star" viewBox="0 0 20 20" fill="none">
    <polygon points="10,2 12.5,7.5 18.5,8 14,12.5 15.5,18.5 10,15.5 4.5,18.5 6,12.5 1.5,8 7.5,7.5"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-arrow-right : ❯ → (다음·이동) -->
  <symbol id="ci-arrow-right" viewBox="0 0 20 20" fill="none">
    <polyline points="6,4 14,10 6,16"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
  </symbol>

  <!-- ci-external : (외부 링크) -->
  <symbol id="ci-external" viewBox="0 0 20 20" fill="none">
    <path d="M11 3h6v6M17 3L9 11"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <path d="M9 5H4a1 1 0 00-1 1v10a1 1 0 001 1h10a1 1 0 001-1v-5"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round"/>
  </symbol>

  <!-- ci-brain : 🧠 (AI·지능) -->
  <symbol id="ci-brain" viewBox="0 0 20 20" fill="none">
    <path d="M10 4C7.5 4 5.5 5.5 5.5 8c0 .7.2 1.3.5 1.8C4.5 10.3 3.5 11.6 3.5 13c0 2 1.8 3.5 4 3.5h5c2.2 0 4-1.5 4-3.5 0-1.4-1-2.7-2.5-3.2C14.3 9.3 14.5 8.7 14.5 8c0-2.5-2-4-4.5-4Z"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="10" y1="4" x2="10" y2="16.5"
      stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
    <path d="M7 8.5c1 .5 2 .5 3 0"
      stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
    <path d="M7 12c1 .5 2 .5 3 0"
      stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
  </symbol>

  <!-- ci-crown : 👑 (크라우니·프리미엄) -->
  <symbol id="ci-crown" viewBox="0 0 20 20" fill="none">
    <polyline points="2,14 4,7 8,11 10,4 12,11 16,7 18,14"
      stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round"/>
    <line x1="2" y1="17" x2="18" y2="17"
      stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  </symbol>

</svg>


2. 이모지 매핑 테이블 (SSOT)

위치: /Users/ef/crowny-design-kit/icons/emoji-map.json

json{
  "_comment": "Crowny 이모지 → SVG 아이콘 매핑 v1 — SSOT",
  "_version": "1.0.0",
  "_icon_base": "/icons/crowny-icons.svg",

  "map": {
    "✅": { "icon": "ci-check",       "color": "var(--c-success)" },
    "✓":  { "icon": "ci-check",       "color": "var(--c-success)" },
    "❌": { "icon": "ci-x",           "color": "var(--c-danger)"  },
    "✕":  { "icon": "ci-x",           "color": "currentColor"     },
    "⚠":  { "icon": "ci-warn",        "color": "var(--c-warn)"    },
    "⚡": { "icon": "ci-warn",        "color": "var(--c-warn)"    },
    "🔍": { "icon": "ci-search",      "color": "currentColor"     },
    "⚙":  { "icon": "ci-settings",    "color": "currentColor"     },
    "🔧": { "icon": "ci-tool",        "color": "currentColor"     },
    "🔬": { "icon": "ci-tool",        "color": "currentColor"     },
    "🗑":  { "icon": "ci-trash",       "color": "var(--c-danger)"  },
    "🔒": { "icon": "ci-lock",        "color": "currentColor"     },
    "🔑": { "icon": "ci-key",         "color": "currentColor"     },
    "🔗": { "icon": "ci-link",        "color": "currentColor"     },
    "📊": { "icon": "ci-chart",       "color": "currentColor"     },
    "📈": { "icon": "ci-chart",       "color": "var(--c-success)" },
    "📋": { "icon": "ci-doc",         "color": "currentColor"     },
    "📖": { "icon": "ci-doc",         "color": "currentColor"     },
    "📚": { "icon": "ci-doc",         "color": "currentColor"     },
    "📅": { "icon": "ci-calendar",    "color": "currentColor"     },
    "📦": { "icon": "ci-package",     "color": "currentColor"     },
    "📤": { "icon": "ci-upload",      "color": "currentColor"     },
    "💾": { "icon": "ci-save",        "color": "currentColor"     },
    "🌐": { "icon": "ci-globe",       "color": "currentColor"     },
    "🌍": { "icon": "ci-globe",       "color": "currentColor"     },
    "💬": { "icon": "ci-chat",        "color": "currentColor"     },
    "💰": { "icon": "ci-money",       "color": "currentColor"     },
    "🏢": { "icon": "ci-building",    "color": "currentColor"     },
    "👤": { "icon": "ci-user",        "color": "currentColor"     },
    "👥": { "icon": "ci-users",       "color": "currentColor"     },
    "🤝": { "icon": "ci-users",       "color": "currentColor"     },
    "★":  { "icon": "ci-star",        "color": "var(--c-accent)"  },
    "⭐": { "icon": "ci-star",        "color": "var(--c-accent)"  },
    "❯":  { "icon": "ci-arrow-right", "color": "currentColor"     },
    "🧠": { "icon": "ci-brain",       "color": "currentColor"     },
    "👑": { "icon": "ci-crown",       "color": "var(--c-accent)"  },
    "🎨": { "icon": "ci-settings",    "color": "currentColor"     },
    "🎯": { "icon": "ci-arrow-right", "color": "currentColor"     },
    "🌱": { "icon": "ci-chart",       "color": "var(--c-success)" },
    "🛒": { "icon": "ci-package",     "color": "currentColor"     },
    "🔊": { "icon": "ci-tool",        "color": "currentColor"     },
    "🎉": { "icon": "ci-star",        "color": "var(--c-accent)"  },
    "💪": { "icon": "ci-crown",       "color": "currentColor"     }
  },

  "size_presets": {
    "xs": "14",
    "sm": "16",
    "md": "20",
    "lg": "24",
    "xl": "32"
  }
}


3. 공유 아이콘 라이브러리 (crowny-icons.js)

위치: /Users/ef/crowny-design-kit/icons/crowny-icons.js

javascript/**
 * crowny-icons.js v1.0.0
 * 크라우니 공유 모노톤 SVG 라인아이콘 라이브러리
 *
 * 사용:
 *   <script src="/icons/crowny-icons.js"></script>
 *   <!-- 또는 ES module -->
 *   import { CrownyIcons } from '/icons/crowny-icons.js';
 *
 * 렌더:
 *   CrownyIcons.render('ci-check', { size: 20, color: 'currentColor' })
 *   CrownyIcons.replaceEmoji(document.body)
 */

(function (global) {
  'use strict';

  // ── 이모지 → 아이콘 ID 매핑 ──────────────────────────────
  const EMOJI_MAP = {
    '✅':'ci-check','✓':'ci-check','❌':'ci-x','✕':'ci-x',
    '⚠':'ci-warn','⚡':'ci-warn','🔍':'ci-search','⚙':'ci-settings',
    '🔧':'ci-tool','🔬':'ci-tool','🗑':'ci-trash','🔒':'ci-lock',
    '🔑':'ci-key','🔗':'ci-link','📊':'ci-chart','📈':'ci-chart',
    '📋':'ci-doc','📖':'ci-doc','📚':'ci-doc','📅':'ci-calendar',
    '📦':'ci-package','📤':'ci-upload','💾':'ci-save','🌐':'ci-globe',
    '🌍':'ci-globe','💬':'ci-chat','💰':'ci-money','🏢':'ci-building',
    '👤':'ci-user','👥':'ci-users','🤝':'ci-users','★':'ci-star',
    '⭐':'ci-star','❯':'ci-arrow-right','🧠':'ci-brain','👑':'ci-crown',
    '🎨':'ci-settings','🎯':'ci-arrow-right','🌱':'ci-chart',
    '🛒':'ci-package','🎉':'ci-star','💪':'ci-crown','🔊':'ci-tool',
    '💡':'ci-brain','🏠':'ci-building','📌':'ci-doc','✦':'ci-star',
    '✨':'ci-star',
  };

  // ── 색상 오버라이드 매핑 (이모지별 의미색) ───────────────
  const COLOR_MAP = {
    '✅':'var(--c-success,#22A06B)','📈':'var(--c-success,#22A06B)',
    '🌱':'var(--c-success,#22A06B)','❌':'var(--c-danger,#D93025)',
    '🗑':'var(--c-danger,#D93025)','⚠':'var(--c-warn,#D4743A)',
    '⚡':'var(--c-warn,#D4743A)',
    '★':'var(--c-accent,#C9A961)','⭐':'var(--c-accent,#C9A961)',
    '👑':'var(--c-accent,#C9A961)','🎉':'var(--c-accent,#C9A961)',
  };

  // ── SVG <use> 태그 생성 ───────────────────────────────────
  function makeSVG(iconId, size, color, extraClass) {
    const resolvedColor = color || 'currentColor';
    const sz = size || 20;
    return `<svg class="ci${extraClass ? ' ' + extraClass : ''}" ` +
      `width="${sz}" height="${sz}" viewBox="0 0 20 20" ` +
      `aria-hidden="true" focusable="false" ` +
      `style="color:${resolvedColor};flex-shrink:0;vertical-align:middle">` +
      `<use href="/icons/crowny-icons.svg#${iconId}"/>` +
      `</svg>`;
  }

  // ── 텍스트 노드에서 이모지 탐지 + 교체 ──────────────────
  const EMOJI_REGEX = new RegExp(
    Object.keys(EMOJI_MAP)
      .map(e => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
      .join('|'),
    'g'
  );

  function replaceInTextNode(node, size) {
    const text = node.nodeValue;
    if (!EMOJI_REGEX.test(text)) return;
    EMOJI_REGEX.lastIndex = 0;

    const parent = node.parentNode;
    const frag = document.createDocumentFragment();
    let lastIdx = 0;
    let match;

    EMOJI_REGEX.lastIndex = 0;
    while ((match = EMOJI_REGEX.exec(text)) !== null) {
      // 매치 이전 텍스트
      if (match.index > lastIdx) {
        frag.appendChild(
          document.createTextNode(text.slice(lastIdx, match.index))
        );
      }
      // SVG 교체
      const emoji = match[0];
      const iconId = EMOJI_MAP[emoji];
      const color  = COLOR_MAP[emoji] || 'currentColor';
      const span = document.createElement('span');
      span.className = 'ci-wrap';
      span.style.cssText = 'display:inline-flex;align-items:center;line-height:1';
      span.innerHTML = makeSVG(iconId, size || 18, color);
      frag.appendChild(span);

      lastIdx = match.index + match[0].length;
    }
    if (lastIdx < text.length) {
      frag.appendChild(document.createTextNode(text.slice(lastIdx)));
    }
    parent.replaceChild(frag, node);
  }

  // ── DOM 트리 순회 ────────────────────────────────────────
  function walk(root, size, skipTags) {
    const skip = new Set(skipTags || ['SCRIPT','STYLE','TEXTAREA','INPUT','CODE','PRE']);
    const iter = document.createTreeWalker(
      root,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode(node) {
          const p = node.parentElement;
          if (!p || skip.has(p.tagName)) return NodeFilter.FILTER_REJECT;
          if (p.closest('[data-no-icon]')) return NodeFilter.FILTER_REJECT;
          return NodeFilter.FILTER_ACCEPT;
        }
      }
    );
    const nodes = [];
    while (iter.nextNode()) nodes.push(iter.currentNode);
    nodes.forEach(n => replaceInTextNode(n, size));
  }

  // ── Public API ────────────────────────────────────────────
  const CrownyIcons = {
    /**
     * SVG 마크업 문자열 반환
     * @param {string} iconId  예: 'ci-check'
     * @param {object} opts    { size, color, class }
     */
    render(iconId, opts = {}) {
      return makeSVG(iconId, opts.size || 20, opts.color, opts.class);
    },

    /**
     * 엘리먼트 하위의 이모지를 아이콘으로 교체
     * @param {Element} root  대상 엘리먼트 (기본: document.body)
     * @param {object}  opts  { size: 18, skipTags: [...] }
     */
    replaceEmoji(root, opts = {}) {
      const target = root || document.body;
      walk(target, opts.size || 18, opts.skipTags);
    },

    /**
     * 이모지 → 아이콘 ID 반환 (테스트용)
     */
    resolve(emoji) {
      return EMOJI_MAP[emoji] || null;
    },

    EMOJI_MAP,
    COLOR_MAP,
  };

  // ── 자동 초기화 (data-crowny-icons="auto") ──────────────
  function autoInit() {
    const targets = document.querySelectorAll('[data-crowny-icons]');
    if (targets.length) {
      targets.forEach(el => {
        const size = parseInt(el.dataset.crownyIconsSize || '18');
        CrownyIcons.replaceEmoji(el, { size });
      });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', autoInit);
  } else {
    autoInit();
  }

  // ── 모듈 내보내기 ────────────────────────────────────────
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = CrownyIcons;                    // CommonJS
  } else if (typeof define === 'function' && define.amd) {
    define([], () => CrownyIcons);                   // AMD
  } else {
    global.CrownyIcons = CrownyIcons;                // 전역
  }

})(typeof globalThis !== 'undefined' ? globalThis : this);


4. CSS 보조 (인라인 크기·정렬 표준화)

위치: /Users/ef/crowny-design-kit/icons/crowny-icons.css

css/* crowny-icons.css — 아이콘 시스템 보조 스타일 */

/* SVG 기본 리셋 */
.ci {
  display: inline-block;
  vertical-align: middle;
  flex-shrink: 0;
  user-select: none;
  pointer-events: none;
  /* currentColor 상속 — 부모 color 그대로 따름 */
  color: currentColor;
}

/* 크기 유틸리티 */
.ci-xs  { width: 14px; height: 14px; }
.ci-sm  { width: 16px; height: 16px; }
.ci-md  { width: 20px; height: 20px; }   /* 기본 */
.ci-lg  { width: 24px; height: 24px; }
.ci-xl  { width: 32px; height: 32px; }

/* 색상 유틸리티 (크라우니 표준) */
.ci-mute    { color: var(--c-text-mute, #A0A0A0); }
.ci-dim     { color: var(--c-text-dim,  #6B6B6B); }
.ci-accent  { color: var(--c-accent,    #C9A961); }
.ci-success { color: var(--c-success,   #22A06B); }
.ci-danger  { color: var(--c-danger,    #D93025); }
.ci-warn    { color: var(--c-warn,      #D4743A); }

/* 인라인 텍스트와 정렬 맞춤 */
.ci-wrap {
  display: inline-flex;
  align-items: center;
  line-height: 1;
  vertical-align: middle;
}

/* 버튼 안 아이콘 */
.tc-btn .ci,
button .ci {
  pointer-events: none;
}


5. 일괄 탐지·교체 전략

5-A. 정적 치환 스크립트 (빌드타임)

위치: /Users/ef/crowny-design-kit/icons/replace-emoji.js

javascript/**
 * replace-emoji.js — 빌드타임 이모지 → SVG 일괄 교체
 *
 * 사용:
 *   node replace-emoji.js --dir /Users/ef/crowny-ai/public
 *   node replace-emoji.js --dir /Users/ef/crowny-aimed/public --dry-run
 *   node replace-emoji.js --all   (등록된 126개 서비스 전체)
 */

const fs   = require('fs');
const path = require('path');

// 서비스 루트 목록 (포트 레지스트리와 연동 가능)
const SERVICE_DIRS = [
  '/Users/ef/crowny-ai/public',
  '/Users/ef/crowny-aimed/public',
  '/Users/ef/crowny-amena/public',
  '/Users/ef/crowny-finance/public',
  '/Users/ef/crowny-invest/public',
  '/Users/ef/crowny-ays/public',
  '/Users/ef/crowny-org',
  '/Users/ef/crowny-market/public',
  '/Users/ef/crowny-cycle/public',
  // ... 126개 서비스 전체
];

// 매핑 로드
const emojiMap = require('./emoji-map.json').map;

// 이모지 → SVG <use> 교체 함수
function emojiToSVG(emoji, meta, textCtx) {
  const size = textCtx === 'heading' ? 24 : 18;
  const color = meta.color !== 'currentColor'
    ? ` style="color:${meta.color}"`
    : '';
  return `<svg class="ci ci-md" width="${size}" height="${size}" ` +
    `viewBox="0 0 20 20" aria-hidden="true"${color}>` +
    `<use href="/icons/crowny-icons.svg#${meta.icon}"/>` +
    `</svg>`;
}

// HTML 파일 내 이모지 교체
function processHTML(filePath, dryRun) {
  let html = fs.readFileSync(filePath, 'utf8');
  let changed = false;
  let count = 0;

  for (const [emoji, meta] of Object.entries(emojiMap)) {
    // 텍스트 노드 안 이모지만 교체 (attr 값·data-* 등은 보존)
    // 전략: >..이모지..< 패턴 대상
    const esc = emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const re  = new RegExp(`(>(?:[^<]*))${esc}((?:[^<]*)<)`, 'g');
    const newHtml = html.replace(re, (_, pre, post) => {
      count++;
      return pre + emojiToSVG(emoji, meta) + post;
    });
    if (newHtml !== html) { html = newHtml; changed = true; }
  }

  if (changed && !dryRun) {
    fs.writeFileSync(filePath, html, 'utf8');
  }
  return { changed, count };
}

// 디렉토리 재귀 처리
function processDir(dir, dryRun) {
  const results = [];
  const files = fs.readdirSync(dir, { withFileTypes: true });
  for (const f of files) {
    const fp = path.join(dir, f.name);
    if (f.isDirectory() && f.name !== 'node_modules') {
      results.push(...processDir(fp, dryRun));
    } else if (f.isFile() && /\.(html|htm)$/.test(f.name)) {
      const r = processHTML(fp, dryRun);
      if (r.changed) results.push({ file: fp, count: r.count });
    }
  }
  return results;
}

// CLI 실행
const args  = process.argv.slice(2);
const all   = args.includes('--all');
const dry   = args.includes('--dry-run');
const dirIdx = args.indexOf('--dir');
const dirs  = all
  ? SERVICE_DIRS
  : dirIdx >= 0
    ? [args[dirIdx + 1]]
    : [];

if (!dirs.length) {
  console.error('사용: node replace-emoji.js --dir <경로> | --all [--dry-run]');
  process.exit(1);
}

let total = 0;
for (const d of dirs) {
  if (!fs.existsSync(d)) { console.warn(`없음: ${d}`); continue; }
  const results = processDir(d, dry);
  results.forEach(r => {
    total += r.count;
    console.log(`[${dry ? 'DRY' : 'OK'}] ${r.file} (${r.count}개)`);
  });
}
console.log(`\n총 ${total}개 이모지 교체${dry ? ' (미적용)' : ''}`);

5-B. 런타임 자동 교체 (클라이언트사이드 — 기존 서비스 무수정 적용)

HTML <head>에 한 줄 추가로 기존 서비스 무수정 적용:

html<!-- 1) 아이콘 스프라이트 (숨겨진 SVG) -->
<script>
  fetch('/icons/crowny-icons.svg')
    .then(r => r.text())
    .then(svg => {
      const div = document.createElement('div');
      div.style.display = 'none';
      div.innerHTML = svg;
      document.body.prepend(div);
    });
</script>

<!-- 2) 라이브러리 로드 + 자동 실행 -->
<script src="/icons/crowny-icons.js"></script>
<link  rel="stylesheet" href="/icons/crowny-icons.css">

또는 data 속성으로 범위 지정:

html<!-- 이 div 안에서만 교체 -->
<div data-crowny-icons data-crowny-icons-size="20">
  ✅ 인증 완료 · ⚠ 주의 · 📊 분석 중
</div>


6. 공유 아이콘 서빙 전략

단일 서빙 포인트

crowny-design-kit 서버 (포트 8729 — design.crowny.org)
  └── /icons/crowny-icons.svg   ← SVG 스프라이트
  └── /icons/crowny-icons.js    ← JS 라이브러리
  └── /icons/crowny-icons.css   ← CSS 보조
  └── /icons/emoji-map.json     ← 매핑 SSOT

모든 서비스는 //design.crowny.org/icons/... 또는 gateway를 통해 /icons/* 경로를 참조. 별도 CDN 없음(Cloudflare 금지 규칙 준수).

gateway.yaml 라우트 추가

yaml# gateway.yaml 추가 항목
- host: "*.crowny.org"
  path: "/icons/"
  upstream: "http://localhost:8729"
  rewrite: "/icons/"
  cache: "7d"                     # 아이콘은 버전 해시로 관리

버전 관리

crowny-icons.svg?v=1.0.0   ← 파일명 아닌 쿼리 버전

아이콘 추가/수정 시 버전 올림 → 구 캐시 무효화. 한선씨 빌더로 버전 자동 증가:

javascript// 매니페스트.json에 현재 버전 기록
{ "icons_version": "1.0.0", "icon_count": 28, "updated": "2026-06-14" }


7. 단계별 롤아웃

단계대상방법검증
1단계design.crowny.org + crowny-org런타임 스크립트 1줄 삽입육안 확인
2단계finance, aimed, amena (신규 서비스 3개)빌드타임 replace-emoji.js --dry-run 후 적용diff 확인
3단계나머지 123개 서비스node replace-emoji.js --all 일괄자동 카운트 리포트
4단계한선씨 .한선 서버 파일템플릿 문자열 내 이모지 → 아이콘 함수 호출로 교체컴파일 검증

8. 한선씨 동반 파일 (헌법 준수)

/Users/ef/crowny-design-kit/icons/아이콘교체.한선:

; 크라우니 이모지→아이콘 교체 도구 (RPN 정통)
가져오기 "파일시스템.한선"
가져오기 "문자열.한선"
가져오기 "JSON.한선"

변수 매핑경로 = "/Users/ef/crowny-design-kit/icons/emoji-map.json"
변수 매핑 = JSON파싱(읽기(매핑경로))

함수 이모지SVG(아이콘ID, 크기) {
  반환 "<svg class=\"ci\" width=\"" + 문자열변환(크기) +
    "\" height=\"" + 문자열변환(크기) +
    "\" viewBox=\"0 0 20 20\" aria-hidden=\"true\">" +
    "<use href=\"/icons/crowny-icons.svg#" + 아이콘ID + "\"/>" +
    "</svg>"
}

함수 HTML교체(경로) {
  변수 내용 = 읽기(경로)
  변수 결과 = 내용
  ; 매핑 순회해서 교체 — 실제 구현은 문자열 교체 루프
  출력값("처리: " + 경로)
  반환 결과
}

출력값("아이콘 교체 도구 v1.0 — 크라우니 디자인 표준")


핵심 파일 경로 요약

파일역할
/Users/ef/crowny-design-kit/icons/crowny-icons.svgSVG 스프라이트 SSOT (28개 심볼)
/Users/ef/crowny-design-kit/icons/crowny-icons.js런타임 라이브러리 (교체 + API)
/Users/ef/crowny-design-kit/icons/crowny-icons.css크기·색상 유틸리티
/Users/ef/crowny-design-kit/icons/emoji-map.json이모지→아이콘 매핑 SSOT
/Users/ef/crowny-design-kit/icons/replace-emoji.js빌드타임 일괄 교체 스크립트
/Users/ef/crowny-design-kit/icons/아이콘교체.한선한선씨 동반 구현
이 설계의 핵심은 세 가지다:
  1. SVG sprite <use> — 파일 하나로 28개 아이콘 서빙, HTTP 요청 최소화
  2. currentColor 단일 제어 — CSS color 한 줄로 라이트/다크 모드 자동 대응, 의미색(성공/위험/경고)은 매핑에서 오버라이드
  3. 이중 전략 (런타임 + 빌드타임) — 기존 서비스는 <script> 1줄로 무수정 적용, 신규 서비스는 빌드타임에 HTML 정리

[responsive-ux]

현재 코드 구조를 충분히 파악했다. 기존 코드에서 모바일 반응형이 거의 없고, 사이드바가 transform으로만 숨겨지며, safe-area 처리도 viewport-fit=cover만 선언되어 실제 CSS 대응이 없다는 것을 확인했다.


크라우니 AI 채팅 반응형 UX 개선 설계

현황 진단

/Users/ef/crowny-ai/public/index.html 분석 결과:

  • viewport-fit=cover 선언은 있으나 실제 env(safe-area-inset-*) CSS 적용 없음
  • 사이드바 collapsed 처리가 transform + opacity지만 pointer-events:none 만으로는 터치 영역이 남아 있을 수 있음
  • .agent-cards-row / .eco-cards 는 각각 별도 미디어 쿼리가 있으나 기준점이 제각각 (900/600/380px 혼재)
  • .input-textarea max-height 200px 고정 — 모바일 키보드 팝업 시 레이아웃 붕괴 가능
  • 터치 타겟: .sb-conv 패딩 8px, .tb-btn 6px → WCAG 2.5.5(44px) 미충족
  • 태블릿(768~1024px) 전용 레이아웃 전략 없음

1. 브레이크포인트 전략

크라우니 채팅 특성(사이드바 + 주 콘텐츠 + 입력창)에 맞는 4단계 체계:

css/* ─── CSS 변수 선언 (index.html :root에 추가) ─── */
:root {
  --sb-w:         260px;   /* 사이드바 기본 너비 */
  --sb-w-tablet:  220px;   /* 태블릿 사이드바 너비 */
  --chat-max:     768px;   /* 채팅 최대 너비 */
  --input-pb-safe: env(safe-area-inset-bottom, 0px); /* iOS 홈버튼 */
}

/*
  BP-1 Mobile      ≤ 639px   사이드바 드로어(풀스크린 오버레이)
  BP-2 Tablet-S    640~767px 사이드바 드로어 + 레이아웃 중간
  BP-3 Tablet-L    768~1023px 사이드바 상시 표시(220px 좁힘) + 아이콘 전용 모드
  BP-4 Desktop     ≥ 1024px  현재 레이아웃 유지 (260px 사이드바)
*/

/* === BP-4: Desktop ≥ 1024px (기본값 — 현재 코드 그대로) === */

/* === BP-3: Tablet-L 768~1023px === */
@media (max-width: 1023px) and (min-width: 768px) {
  :root { --sb-w: var(--sb-w-tablet); } /* 220px */
  .sidebar { width: var(--sb-w); }

  /* 사이드바 좁혀서 텍스트 일부 생략 */
  .sb-brand-name { display: none; }      /* 로고만 */
  .sb-new-btn span { display: none; }    /* 아이콘만 */
  .sb-search { display: none; }         /* 검색 숨김 */
  .sb-footer-btn span { display: none; } /* 아이콘만 */

  .agent-cards-row { grid-template-columns: repeat(3, 1fr); }
  .eco-cards        { grid-template-columns: repeat(3, 1fr); }
  .welcome          { padding: 36px 20px 100px; }
}

/* === BP-2: Tablet-S 640~767px (드로어 전환점) === */
@media (max-width: 767px) {
  .sidebar {
    position: fixed;
    top: 0; left: 0;
    height: 100%;
    width: 280px;          /* 드로어는 조금 더 넓게 */
    z-index: 200;
    transform: translateX(-100%);
    transition: transform var(--t3) var(--ease);
    box-shadow: var(--shadow-xl);
    opacity: 1;            /* opacity 트릭 제거 — transform만 사용 */
    pointer-events: none;
  }
  .sidebar.mobile-open {
    transform: translateX(0);
    pointer-events: auto;
  }
  /* collapsed 클래스는 모바일에서 무효화 */
  .sidebar.collapsed {
    transform: translateX(-100%);
    opacity: 1;
    pointer-events: none;
    position: fixed;
  }

  /* 오버레이 활성 */
  .overlay { display: none; }
  .overlay.show { display: block; }
}

/* === BP-1: Mobile ≤ 639px === */
@media (max-width: 639px) {
  .agent-cards-row { grid-template-columns: repeat(2, 1fr); }
  .eco-cards        { grid-template-columns: repeat(2, 1fr); }

  /* 웰컴 타이틀 스케일 다운 */
  .w-title { font-size: 22px; }
  .w-sub   { font-size: 14px; }
}

@media (max-width: 380px) {
  .agent-cards-row { grid-template-columns: 1fr; }
}


2. 모바일 사이드바 / 입력창 / 터치 타겟

2-1. 사이드바 드로어 패턴

현재 .sidebar.collapsedposition: absolute로 전환되는데, 모바일에서는 처음부터 position: fixed로 두고 transform만 토글해야 한다.

css/* 모바일 드로어 — 핵심 변경 */
@media (max-width: 767px) {
  /* 앱 전체를 flex에서 단일 컬럼으로 */
  .app { display: block; position: relative; }

  /* 사이드바: fixed 드로어 */
  .sidebar {
    position: fixed;
    inset: 0 auto 0 0;     /* top:0 bottom:0 left:0 */
    width: min(80vw, 300px);
    z-index: 200;
    transform: translateX(-100%);
    will-change: transform;
    pointer-events: none;
    /* 상단 safe-area 대응 */
    padding-top: env(safe-area-inset-top, 0px);
  }
  .sidebar.mobile-open {
    transform: none;
    pointer-events: auto;
  }

  /* 메인 영역 풀너비 */
  .main { width: 100%; }

  /* 사이드바 내부 터치 타겟 44px 강제 */
  .sb-conv   { min-height: 44px; padding: 12px 10px; }
  .sb-new-btn { min-height: 44px; }
  .sb-footer-btn { min-height: 44px; }
  .sb-search-input { height: 40px; font-size: 16px; } /* 16px = iOS 자동줌 방지 */

  /* 대화 목록 스크롤 관성 */
  .sb-list { -webkit-overflow-scrolling: touch; overscroll-behavior: contain; }
}

javascript// JS: 모바일 드로어 토글 (기존 toggleSb() 함수 교체/보완)
function openMobileSidebar() {
  const sb = document.querySelector('.sidebar');
  const ov = document.querySelector('.overlay');
  sb.classList.add('mobile-open');
  ov.classList.add('show');
  document.body.style.overflow = 'hidden'; // 배경 스크롤 잠금
}
function closeMobileSidebar() {
  const sb = document.querySelector('.sidebar');
  const ov = document.querySelector('.overlay');
  sb.classList.remove('mobile-open');
  ov.classList.remove('show');
  document.body.style.overflow = '';
}
// 오버레이 탭으로 닫기
document.querySelector('.overlay').addEventListener('click', closeMobileSidebar);
// 스와이프로 닫기 (선택적)
let tsX = 0;
document.querySelector('.sidebar').addEventListener('touchstart', e => { tsX = e.touches[0].clientX; }, {passive:true});
document.querySelector('.sidebar').addEventListener('touchend', e => {
  if (tsX - e.changedTouches[0].clientX > 60) closeMobileSidebar();
}, {passive:true});

2-2. 입력창 — 키보드 대응

가장 중요한 부분. iOS Safari는 window.visualViewport로 실제 뷰포트를 추적해야 한다.

css/* 입력 영역 safe-area 패딩 */
.input-zone {
  padding: 0 16px 16px;
  /* iOS 홈 인디케이터 영역 */
  padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}

/* 모바일에서 textarea 자동줌 방지 */
@media (max-width: 639px) {
  .input-textarea { font-size: 16px; }  /* 16px 미만 = iOS 자동줌 */

  /* 입력창 하단 고정: 키보드 팝업 시 visualViewport로 재조정 */
  .input-zone {
    position: sticky;
    bottom: 0;
    background: var(--bg);
    /* 키보드 올라올 때 부드럽게 */
    transition: padding-bottom 0.1s ease;
  }
}

javascript// visualViewport로 iOS 키보드 팝업 대응
if (window.visualViewport) {
  const inputZone = document.querySelector('.input-zone');
  let rafId = null;

  const onViewportChange = () => {
    if (rafId) cancelAnimationFrame(rafId);
    rafId = requestAnimationFrame(() => {
      const vv = window.visualViewport;
      const offsetFromBottom = window.innerHeight - vv.height - vv.offsetTop;
      // 키보드가 올라온 높이만큼 input-zone을 위로
      inputZone.style.transform = offsetFromBottom > 50
        ? `translateY(-${offsetFromBottom}px)`
        : '';
    });
  };

  window.visualViewport.addEventListener('resize', onViewportChange);
  window.visualViewport.addEventListener('scroll', onViewportChange);
}

2-3. 터치 타겟 44px 강제

css/* 전역 터치 타겟 보정 — 모바일 전용 */
@media (max-width: 767px) {
  /* 상단 버튼들 */
  .toggle-sb-btn,
  .tb-btn,
  .tb-btn-new,
  .send-btn { min-width: 44px; min-height: 44px; }

  /* 에이전트 드롭다운 버튼 */
  .agent-dropdown-btn { min-height: 44px; padding: 0 12px; }

  /* send-btn 모바일에서 더 크게 */
  .send-btn { width: 44px; height: 44px; border-radius: 12px; font-size: 20px; }

  /* suggest chip 터치 타겟 */
  .suggest-chip { padding: 10px 16px; font-size: 14px; }

  /* apt-option 터치 타겟 */
  .apt-option { padding: 16px; }
}


3. 태블릿 레이아웃 (768~1023px)

태블릿에서는 사이드바를 유지하되 좁혀서(220px) 아이콘 중심으로 만들고, 채팅 영역은 넓게 가져간다.

css@media (max-width: 1023px) and (min-width: 768px) {
  :root { --sb-w: 64px; }  /* 아이콘 전용 모드 */

  .sidebar {
    width: 64px;
    overflow: visible;      /* hover시 툴팁 표시를 위해 */
  }

  /* 사이드바 내부: 텍스트 모두 숨기고 아이콘만 */
  .sb-brand-name,
  .sb-search,
  .sb-date-label,
  .sb-conv-title,
  .sb-conv-del,
  .sb-footer-btn span,
  .sb-new-btn span { display: none; }

  /* 아이콘만 남은 버튼 중앙정렬 */
  .sb-new-btn {
    justify-content: center;
    padding: 10px;
    min-height: 44px;
  }
  .sb-footer-btn {
    justify-content: center;
    padding: 10px;
  }
  .sb-conv {
    justify-content: center;
    padding: 10px;
    min-height: 44px;
  }
  .sb-conv-dot {
    width: 10px; height: 10px; /* 아이콘 역할로 키움 */
  }

  /* 사이드바 hover 시 텍스트 레이블 툴팁 */
  .sb-conv { position: relative; }
  .sb-conv:hover::after {
    content: attr(data-title);
    position: absolute;
    left: calc(100% + 8px);
    top: 50%;
    transform: translateY(-50%);
    background: var(--bg-dropdown);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 6px 10px;
    font-size: 12px;
    color: var(--text);
    white-space: nowrap;
    box-shadow: var(--shadow-md);
    z-index: 300;
    pointer-events: none;
  }

  /* 채팅 컨텐츠 좌우 패딩 줄임 */
  .chat-wrap { padding: 24px 16px 16px; }
  .input-zone { padding: 0 12px 12px; }

  /* 웰컴 화면 에이전트 카드 3열 */
  .agent-cards-row { grid-template-columns: repeat(3, 1fr); max-width: 100%; }

  /* 적성검사 카드 */
  .apt-card { padding: 28px 24px; }
}

/* 대안: 태블릿에서 사이드바를 260px 유지하되 toggle 버튼 제거 */
@media (min-width: 768px) and (max-width: 1023px) {
  /* topbar toggle 버튼 숨김 (사이드바 항상 표시) */
  /* .toggle-sb-btn { display: none; } */
  /* ← 선택 사항: 아이콘 모드 vs 항상표시 중 하나 선택 */
}


4. Safe-area / 뷰포트 / 키보드 대응

4-1. Safe-area (iOS 노치/홈바)

viewport-fit=cover는 이미 선언되어 있다. 빠진 CSS 적용:

css/* 기존 topbar에 추가 */
.topbar {
  padding-top: calc(10px + env(safe-area-inset-top, 0px));
}

/* 사이드바 상단 */
.sidebar {
  padding-top: env(safe-area-inset-top, 0px);
}
/* 사이드바 하단 */
.sb-footer {
  padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}

/* 입력창 하단 (핵심) */
.input-zone {
  padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}

/* 오버레이도 전체 커버 */
.overlay {
  /* inset: 0이 이미 있으므로 추가 불필요 */
  /* 단, iOS에서 fixed가 키보드 위에 안 올라오는 경우 */
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

4-2. 모바일 뷰포트 높이 (100vh 버그 대응)

iOS Safari에서 100vh는 주소창 포함 높이여서 스크롤바가 생긴다.

css/* ── dvh(dynamic viewport height) 사용 — 현대 브라우저 ── */
html, body { height: 100dvh; }   /* iOS 15.4+, Android Chrome 108+ */

.app {
  height: 100dvh;
  /* 폴백 */
  height: 100vh;
  height: 100dvh;
}

/* JS 폴백: --vh 커스텀 변수 */
/* document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); */
/* 그러면 height: calc(var(--vh, 1vh) * 100) 사용 가능 */

javascript// 뷰포트 높이 동적 보정 (dvh 미지원 기기 폴백)
function setVhVar() {
  document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`);
}
setVhVar();
window.addEventListener('resize', setVhVar, { passive: true });
// orientationchange도 처리
window.addEventListener('orientationchange', () => setTimeout(setVhVar, 200));

4-3. 채팅 스크롤 영역

css/* 채팅 스크롤 영역 — 키보드 팝업 시 overflow 유지 */
.chat-area {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-y: contain;
  /* 새 메시지 도착 시 smooth scroll */
  scroll-behavior: smooth;
}

/* 모바일에서 채팅 wrap 하단 패딩 (입력창 높이만큼) */
@media (max-width: 639px) {
  .chat-wrap { padding-bottom: 80px; }
}

4-4. 가로 모드(landscape) 대응

css/* 가로 모드: 높이 부족 → 사이드바 숨김 강제, 입력창 압축 */
@media (max-height: 500px) and (orientation: landscape) {
  .sidebar { display: none; }
  .w-title  { font-size: 18px; }
  .w-logo-ring { width: 36px; height: 36px; font-size: 16px; }
  .welcome  { padding: 20px 20px 80px; }
  .input-zone { padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); }
  .input-textarea { max-height: 80px; } /* 키보드+가로모드에서 공간 절약 */
  .toggle-sb-btn { display: flex; } /* 숨긴 사이드바 열기 버튼은 유지 */
}


5. HTML 구조 보완 사항

meta 태그 추가

html<!-- 기존 viewport에 interactive-widget 추가 (Android Chrome) -->
<meta name="viewport"
  content="width=device-width, initial-scale=1.0, viewport-fit=cover,
           interactive-widget=resizes-content">

interactive-widget=resizes-content: Android에서 키보드 팝업 시 레이아웃이 키보드 위로 올라가지 않고 뷰포트 자체를 줄이게 한다. visualViewport 이벤트와 함께 쓰면 iOS/Android 동작 통일 가능.

사이드바 아이콘 모드 data-title 추가

html<!-- 각 .sb-conv에 data-title 추가 (태블릿 tooltip용) -->
<div class="sb-conv active" data-title="이 대화 제목">
  <span class="sb-conv-dot butler"></span>
  <span class="sb-conv-title">이 대화 제목</span>
</div>


6. 구현 우선순위 및 파일 위치

순위항목위치효과
1env(safe-area-inset-*) CSS 전면 적용index.html <style>iOS 노치/홈바 잘림 즉시 해결
2100dvh + --vh JS 폴백index.htmliOS 주소창 겹침 해결
3모바일 드로어 CSS/JS (fixed + transform)index.html사이드바 열기/닫기 완성
4터치 타겟 44px 패딩index.html모바일 탭 오인식 해결
5font-size: 16px on textareaindex.htmliOS 자동 줌 방지
6visualViewport 키보드 대응 JSindex.html키보드 팝업 시 입력창 유지
7태블릿 아이콘 사이드바 (64px)index.html태블릿 공간 효율화
8가로모드 압축 레이아웃index.htmllandscape 사용성
모든 변경은 /Users/ef/crowny-ai/public/index.html<style> 블록 끝에 /* === RESPONSIVE v2 === */ 섹션을 추가하는 방식으로 기존 코드를 건드리지 않고 적용할 수 있다. JS 변경은 기존 DOMContentLoaded 블록 안에 삽입한다.


요약

  • 브레이크포인트: 639 / 767 / 1023px 3개 — 각각 모바일 전용 / 드로어 전환 / 태블릿 아이콘 사이드바
  • 사이드바: 모바일은 fixed + transform 드로어, 태블릿은 64px 아이콘 전용 + hover 툴팁
  • 입력창: font-size:16px(자동줌 방지) + visualViewport resize 이벤트 + env(safe-area-inset-bottom) 패딩
  • 터치 타겟: 모바일에서 min-height:44px 전역 강제
  • 뷰포트: 100dvh + --vh JS 폴백으로 iOS Safari 주소창 버그 해결
  • 가로모드: 500px 이하 높이에서 사이드바 자동 숨김 + 입력창 압축