크라우니 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.txt와 SERVICES_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.txt와 SERVICES_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.json | 126개 서비스 역색인 DB (keywords→매칭) | 서비스 질문 시 — Layer 1 |
knowledge/hanseon.txt | 한선씨 문법/내장함수 | code 토픽 감지 시 |
knowledge/isa729.txt | ISA729 opcode/3진 | isa729 토픽 감지 시 |
knowledge/selcore.txt | 셀코어 규칙엔진 | selcore 토픽 감지 시 |
knowledge/crownycode.txt | 크라우니코드 패턴 | crownycode 토픽 감지 시 |
knowledge/design.txt | 디자인 표준 | design 토픽 감지 시 |
engine/knowledge-loader.js | matchServices + buildKnowledgeSlot | 매 요청 — 선택 주입 |
engine/semantic-filter.js | intent/domain/tier 분류 | 매 요청 — 라우팅 결정 |
engine/gemini-router.js | 3모델 라우터 + 강등 체인 | 매 요청 — LLM 호출 |
서비스 카탈로그 키워드 설계 원칙
각 서비스의 keywords 배열은 다음 4종을 포함한다:
- 고유명사: 서비스 이름 변형 (
"크라우니페이","pay","페이") - 기능어: 핵심 기능 (
"결제","수수료","송금") - 문제어: 사용자가 겪는 문제 (
"돈 보내기","카드 없이") - 세계관어: 철학과 연결되는 단어 (
"자립","맘","포네")
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"— CSScolor로 색상 완전 제어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.svg | SVG 스프라이트 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/아이콘교체.한선 | 한선씨 동반 구현 |
- SVG sprite
<use>— 파일 하나로 28개 아이콘 서빙, HTTP 요청 최소화 currentColor단일 제어 — CSScolor한 줄로 라이트/다크 모드 자동 대응, 의미색(성공/위험/경고)은 매핑에서 오버라이드- 이중 전략 (런타임 + 빌드타임) — 기존 서비스는
<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-textareamax-height 200px 고정 — 모바일 키보드 팝업 시 레이아웃 붕괴 가능- 터치 타겟:
.sb-conv패딩 8px,.tb-btn6px → 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.collapsed는 position: 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. 구현 우선순위 및 파일 위치
| 순위 | 항목 | 위치 | 효과 |
|---|---|---|---|
| 1 | env(safe-area-inset-*) CSS 전면 적용 | index.html <style> | iOS 노치/홈바 잘림 즉시 해결 |
| 2 | 100dvh + --vh JS 폴백 | index.html | iOS 주소창 겹침 해결 |
| 3 | 모바일 드로어 CSS/JS (fixed + transform) | index.html | 사이드바 열기/닫기 완성 |
| 4 | 터치 타겟 44px 패딩 | index.html | 모바일 탭 오인식 해결 |
| 5 | font-size: 16px on textarea | index.html | iOS 자동 줌 방지 |
| 6 | visualViewport 키보드 대응 JS | index.html | 키보드 팝업 시 입력창 유지 |
| 7 | 태블릿 아이콘 사이드바 (64px) | index.html | 태블릿 공간 효율화 |
| 8 | 가로모드 압축 레이아웃 | index.html | landscape 사용성 |
/Users/ef/crowny-ai/public/index.html 의 <style> 블록 끝에 /* === RESPONSIVE v2 === */ 섹션을 추가하는 방식으로 기존 코드를 건드리지 않고 적용할 수 있다. JS 변경은 기존 DOMContentLoaded 블록 안에 삽입한다.요약
- 브레이크포인트: 639 / 767 / 1023px 3개 — 각각 모바일 전용 / 드로어 전환 / 태블릿 아이콘 사이드바
- 사이드바: 모바일은 fixed + transform 드로어, 태블릿은 64px 아이콘 전용 + hover 툴팁
- 입력창:
font-size:16px(자동줌 방지) +visualViewportresize 이벤트 +env(safe-area-inset-bottom)패딩 - 터치 타겟: 모바일에서
min-height:44px전역 강제 - 뷰포트:
100dvh+--vhJS 폴백으로 iOS Safari 주소창 버그 해결 - 가로모드: 500px 이하 높이에서 사이드바 자동 숨김 + 입력창 압축