@hanmariyang/drafting 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/api/dist/db/index.js +107 -0
- package/api/dist/db/repos.js +670 -0
- package/api/dist/index.js +89 -0
- package/api/dist/lib/ai.js +314 -0
- package/api/dist/lib/config.js +57 -0
- package/api/dist/lib/crypto.js +71 -0
- package/api/dist/lib/design-system-gen.js +332 -0
- package/api/dist/lib/fixtures.js +150 -0
- package/api/dist/lib/gateway.js +55 -0
- package/api/dist/lib/handoff.js +283 -0
- package/api/dist/lib/items-gen.js +211 -0
- package/api/dist/lib/lint-service.js +118 -0
- package/api/dist/lib/lint.js +141 -0
- package/api/dist/lib/mockup-gen.js +136 -0
- package/api/dist/lib/numbering.js +75 -0
- package/api/dist/lib/provider-errors.js +31 -0
- package/api/dist/lib/render.js +154 -0
- package/api/dist/lib/style-guide.js +47 -0
- package/api/dist/lib/templates.js +85 -0
- package/api/dist/lib/types.js +1 -0
- package/api/dist/lib/wireframes.js +137 -0
- package/api/dist/providers/byok/anthropic.js +75 -0
- package/api/dist/providers/byok/openai-compat.js +95 -0
- package/api/dist/providers/cli.js +391 -0
- package/api/dist/providers/index.js +68 -0
- package/api/dist/providers/managed.js +22 -0
- package/api/dist/providers/sse.js +37 -0
- package/api/dist/providers/stub.js +55 -0
- package/api/dist/providers/types.js +1 -0
- package/api/dist/routes/backup.js +24 -0
- package/api/dist/routes/deliverables.js +588 -0
- package/api/dist/routes/documents.js +205 -0
- package/api/dist/routes/helpers.js +43 -0
- package/api/dist/routes/interview.js +141 -0
- package/api/dist/routes/keys.js +70 -0
- package/api/dist/routes/projects.js +103 -0
- package/api/dist/routes/settings.js +134 -0
- package/api/dist/routes/share.js +39 -0
- package/api/dist/routes/suggestions.js +144 -0
- package/api/templates/design-system.json +17 -0
- package/api/templates/feature-spec.json +73 -0
- package/api/templates/ia.json +52 -0
- package/api/templates/prd.json +60 -0
- package/api/templates/user-flow.json +61 -0
- package/bin/drafting.mjs +79 -0
- package/db/schema.sql +154 -0
- package/package.json +62 -0
- package/web/dist/assets/index-CS06cWP3.js +125 -0
- package/web/dist/assets/index-DWoYeaZU.css +1 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// 디자인 시스템 생성 — 인터뷰 답변 → 풍부한 DesignSystem(색 역할+상태색, 타입 스케일,
|
|
2
|
+
// 여백 체계, 형태, 접근성 필드, 라이트+다크 세트) + 설계 근거 + 스타일 타일.
|
|
3
|
+
// AI_STUB/오프라인이면 답변 키워드 결정적 매핑. 아니면 provider 로 JSON 제안(결정적 위에 덮어씀).
|
|
4
|
+
// 스타일 타일은 시스템에서 결정적으로 렌더 — 항상 토큰과 일치.
|
|
5
|
+
import { config } from "./config.js";
|
|
6
|
+
import { resolveProvider } from "../providers/index.js";
|
|
7
|
+
import { getModelConfig } from "./ai.js";
|
|
8
|
+
import { PRESETS, DEFAULT_GUIDE, saveStyleGuide } from "./style-guide.js";
|
|
9
|
+
import * as repo from "../db/repos.js";
|
|
10
|
+
const key = (pid) => `design_system:${pid}`;
|
|
11
|
+
const candKey = (pid) => `design_system_candidates:${pid}`;
|
|
12
|
+
const HEX_RE = /#[0-9a-fA-F]{6}\b/;
|
|
13
|
+
export function getDesignSystem(projectId) {
|
|
14
|
+
return repo.getSetting(key(projectId));
|
|
15
|
+
}
|
|
16
|
+
function answersOf(documentId) {
|
|
17
|
+
const s = repo.getSessionByDocument(documentId);
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const a of s?.answers ?? [])
|
|
20
|
+
out[a.questionId] = a.answer;
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
const mix = (a, b, t) => {
|
|
24
|
+
const h = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
|
|
25
|
+
const [r1, g1, b1] = h(a), [r2, g2, b2] = h(b);
|
|
26
|
+
const m = (x, y) => Math.round(x + (y - x) * t).toString(16).padStart(2, '0');
|
|
27
|
+
return `#${m(r1, r2)}${m(g1, g2)}${m(b1, b2)}`;
|
|
28
|
+
};
|
|
29
|
+
/** 답변 → 풍부한 DesignSystem (결정적). stub/오프라인·폴백·탐색 공용. */
|
|
30
|
+
export function buildSystem(ans, presetOverride) {
|
|
31
|
+
const all = Object.values(ans).join(' ');
|
|
32
|
+
const has = (...ks) => ks.some((k) => all.includes(k));
|
|
33
|
+
let preset = presetOverride ?? 'clean';
|
|
34
|
+
if (!presetOverride) {
|
|
35
|
+
if (has('활기', '경쾌', '친근', '발랄', '재미', '귀여'))
|
|
36
|
+
preset = 'vivid';
|
|
37
|
+
else if (has('클래식', '우아', '따뜻', '감성', '편안'))
|
|
38
|
+
preset = 'warm';
|
|
39
|
+
else if (has('미니멀', '기능', '단정', '실용', '툴'))
|
|
40
|
+
preset = 'mono';
|
|
41
|
+
}
|
|
42
|
+
const base = PRESETS[preset] ?? DEFAULT_GUIDE;
|
|
43
|
+
// 강조색
|
|
44
|
+
let accent = base.accent;
|
|
45
|
+
const hex = all.match(HEX_RE)?.[0];
|
|
46
|
+
if (hex)
|
|
47
|
+
accent = hex;
|
|
48
|
+
else if (has('그린', '초록', '녹색'))
|
|
49
|
+
accent = '#0e7b62';
|
|
50
|
+
else if (has('블루', '파랑', '남색'))
|
|
51
|
+
accent = '#2563eb';
|
|
52
|
+
else if (has('퍼플', '보라'))
|
|
53
|
+
accent = '#7c3aed';
|
|
54
|
+
else if (has('오렌지', '주황', '테라코타'))
|
|
55
|
+
accent = '#c2603f';
|
|
56
|
+
const light = {
|
|
57
|
+
bg: base.bg, surface: base.surface, surfaceAlt: mix(base.bg, base.surface, 0.5),
|
|
58
|
+
ink: base.ink, sub: base.sub, line: base.line, accent, accentText: '#ffffff',
|
|
59
|
+
};
|
|
60
|
+
const darkP = PRESETS.dark;
|
|
61
|
+
const dark = {
|
|
62
|
+
bg: darkP.bg, surface: darkP.surface, surfaceAlt: mix(darkP.bg, darkP.surface, 0.5),
|
|
63
|
+
ink: darkP.ink, sub: darkP.sub, line: darkP.line, accent, accentText: '#0b0d10',
|
|
64
|
+
};
|
|
65
|
+
const wantsDark = has('다크', '어두', 'dark', '야간');
|
|
66
|
+
const wantsBoth = has('둘 다', '둘다', '라이트', 'light', '양쪽', '모두');
|
|
67
|
+
const hasDark = wantsDark || wantsBoth;
|
|
68
|
+
const primary = wantsDark && !wantsBoth ? 'dark' : 'light';
|
|
69
|
+
// 접근성
|
|
70
|
+
const highContrast = has('고대비', '대비 높', '대비높', '고 대비');
|
|
71
|
+
const colorblindSafe = has('색맹', '색약', '색만', '색상만', '색으로만');
|
|
72
|
+
const a11yNotes = ans.accessibility && ans.accessibility.trim() && !/^없음$/.test(ans.accessibility.trim()) ? ans.accessibility.trim() : '';
|
|
73
|
+
if (highContrast) {
|
|
74
|
+
light.ink = '#0a0b0c';
|
|
75
|
+
light.sub = mix(light.sub, light.ink, 0.35);
|
|
76
|
+
light.line = mix(light.line, light.ink, 0.25);
|
|
77
|
+
}
|
|
78
|
+
// 상태색 — 빨강 회피 시 danger 를 앰버-브라운으로
|
|
79
|
+
const avoidRed = has('빨강 피', '빨강은 피', '레드 피', '빨간색 피');
|
|
80
|
+
const state = {
|
|
81
|
+
success: '#1b7f4b', warning: '#8a6a1f', danger: avoidRed ? '#b45309' : '#c0392b', info: '#2563eb',
|
|
82
|
+
};
|
|
83
|
+
// 타이포
|
|
84
|
+
// ⚠️ "산세리프"가 "세리프"에 걸리지 않게 sans 를 먼저 판정.
|
|
85
|
+
const family = has('산세리프', '산 세리프', 'sans', '고딕', '산스', '모던') ? 'sans'
|
|
86
|
+
: has('세리프', '명조', '바탕') ? 'serif'
|
|
87
|
+
: has('라운드', '둥근 서체', '동글') ? 'rounded'
|
|
88
|
+
: has('모노', '고정폭') ? 'mono'
|
|
89
|
+
: 'sans';
|
|
90
|
+
const density = has('촘촘', '밀집', '조밀', '많이') ? 'compact' : has('여유', '넓', '시원') ? 'spacious' : base.density;
|
|
91
|
+
const scale = density === 'compact'
|
|
92
|
+
? { display: 26, title: 18, body: 13, caption: 11 }
|
|
93
|
+
: density === 'spacious'
|
|
94
|
+
? { display: 34, title: 24, body: 15, caption: 13 }
|
|
95
|
+
: { display: 30, title: 20, body: 14, caption: 12 };
|
|
96
|
+
// 형태
|
|
97
|
+
let radiusCard = base.radius;
|
|
98
|
+
if (has('각진', '샤프', '직각', '딱딱'))
|
|
99
|
+
radiusCard = Math.min(radiusCard, 4);
|
|
100
|
+
else if (has('둥근', '부드럽', '라운드'))
|
|
101
|
+
radiusCard = Math.max(radiusCard, 14);
|
|
102
|
+
const shadow = has('그림자 없', '플랫', '납작') ? 'none' : has('입체', '그림자 강', '떠 있') ? 'strong' : 'soft';
|
|
103
|
+
return {
|
|
104
|
+
color: { primary, light, dark: hasDark ? dark : null, state },
|
|
105
|
+
type: { family, scale, weightBold: 800 },
|
|
106
|
+
space: { unit: 4, density, scale: [4, 8, 12, 16, 24, 32] },
|
|
107
|
+
shape: { radiusCard, radiusBtn: Math.max(4, Math.round(radiusCard * 0.7)), radiusPill: 999, border: 1, shadow },
|
|
108
|
+
a11y: { highContrast, colorblindSafe, notes: a11yNotes },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** 시스템 → StyleGuide (B/A 구동용). primary 모드 색을 쓴다. */
|
|
112
|
+
export function deriveGuide(sys) {
|
|
113
|
+
const c = sys.color[sys.color.primary] ?? sys.color.light;
|
|
114
|
+
return {
|
|
115
|
+
preset: 'custom', accent: c.accent, bg: c.bg, surface: c.surface, ink: c.ink, sub: c.sub, line: c.line,
|
|
116
|
+
radius: sys.shape.radiusCard, density: sys.space.density, font: sys.type.family, mode: sys.color.primary,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function rationaleFor(ans, sys) {
|
|
120
|
+
const modeTxt = sys.color.dark ? (sys.color.primary === 'dark' ? '다크 기본 + 라이트 세트' : '라이트 기본 + 다크 세트') : `${sys.color.primary} 단일`;
|
|
121
|
+
return (`성격(“${ans.personality ?? '—'}”)에서 강조색 ${sys.color.light.accent} 를 주요 액션에만 절제해서 씁니다. ` +
|
|
122
|
+
`색은 배경·표면·본문·보조·경계·강조의 역할 체계 + 상태색(성공/경고/위험/정보)까지 정의(${modeTxt}). ` +
|
|
123
|
+
`서체 ${sys.type.family}, 타입 스케일 ${sys.type.scale.display}/${sys.type.scale.title}/${sys.type.scale.body}/${sys.type.scale.caption}px, ` +
|
|
124
|
+
`여백 4px 그리드(${sys.space.density}), 모서리 ${sys.shape.radiusCard}px, 그림자 ${sys.shape.shadow}. ` +
|
|
125
|
+
(sys.a11y.highContrast || sys.a11y.colorblindSafe || sys.a11y.notes
|
|
126
|
+
? `접근성: ${[sys.a11y.highContrast ? '고대비' : '', sys.a11y.colorblindSafe ? '색맹 안전(상태는 색+글리프)' : '', sys.a11y.notes].filter(Boolean).join(' · ')}.`
|
|
127
|
+
: ''));
|
|
128
|
+
}
|
|
129
|
+
function buildMessages(ans) {
|
|
130
|
+
const answerBlock = Object.entries(ans).map(([k, v]) => `- ${k}: ${v}`).join('\n');
|
|
131
|
+
const system = `너는 시니어 디자인 시스템 설계자다. 아래 인터뷰 답변으로 이 제품의 디자인 시스템 토큰을 설계한다.\n` +
|
|
132
|
+
`오직 JSON 하나만 출력(코드펜스·설명 없이). 스키마:\n` +
|
|
133
|
+
`{"colorLight":{"bg","surface","surfaceAlt","ink","sub","line","accent","accentText"},` +
|
|
134
|
+
`"colorDark":{...같은 키...}|null,"primaryMode":"light|dark",` +
|
|
135
|
+
`"state":{"success","warning","danger","info"},` +
|
|
136
|
+
`"font":"sans|serif|rounded|mono","scale":{"display","title","body","caption"(px정수)},` +
|
|
137
|
+
`"density":"compact|cozy|spacious","radiusCard":정수,"shadow":"none|soft|strong",` +
|
|
138
|
+
`"highContrast":bool,"colorblindSafe":bool,"a11yNotes":"문자열",` +
|
|
139
|
+
`"rationale":"설계 근거 3~4문장(한국어)"}\n` +
|
|
140
|
+
`모든 색은 #RRGGBB. accent 는 주요 액션·활성에만. 답변의 HEX·금지색·다크 요구·접근성 요구를 반드시 반영. ` +
|
|
141
|
+
`라이트+다크 둘 다 요구되면 colorDark 를 채워라.`;
|
|
142
|
+
const user = `인터뷰 답변:\n${answerBlock}\n\n위 스키마대로 JSON 을 출력하라.`;
|
|
143
|
+
return [{ role: 'system', content: system }, { role: 'user', content: user }];
|
|
144
|
+
}
|
|
145
|
+
function extractJson(text) {
|
|
146
|
+
let t = text.trim().replace(/^```[a-zA-Z]*\s*/, '').replace(/\s*```$/, '').trim();
|
|
147
|
+
const a = t.indexOf('{'), b = t.lastIndexOf('}');
|
|
148
|
+
if (a >= 0 && b > a)
|
|
149
|
+
t = t.slice(a, b + 1);
|
|
150
|
+
return JSON.parse(t);
|
|
151
|
+
}
|
|
152
|
+
const okHex = (v) => typeof v === 'string' && HEX_RE.test(v);
|
|
153
|
+
function mergeAiSystem(sys, j) {
|
|
154
|
+
const cs = (o, base) => {
|
|
155
|
+
const x = (o ?? {});
|
|
156
|
+
const p = (k) => (okHex(x[k]) ? x[k] : base[k]);
|
|
157
|
+
return { bg: p('bg'), surface: p('surface'), surfaceAlt: p('surfaceAlt'), ink: p('ink'), sub: p('sub'), line: p('line'), accent: p('accent'), accentText: p('accentText') };
|
|
158
|
+
};
|
|
159
|
+
const st = (j.state ?? {});
|
|
160
|
+
const sc = (j.scale ?? {});
|
|
161
|
+
return {
|
|
162
|
+
color: {
|
|
163
|
+
primary: j.primaryMode === 'dark' ? 'dark' : 'light',
|
|
164
|
+
light: cs(j.colorLight, sys.color.light),
|
|
165
|
+
dark: j.colorDark ? cs(j.colorDark, sys.color.dark ?? sys.color.light) : sys.color.dark,
|
|
166
|
+
state: {
|
|
167
|
+
success: okHex(st.success) ? st.success : sys.color.state.success,
|
|
168
|
+
warning: okHex(st.warning) ? st.warning : sys.color.state.warning,
|
|
169
|
+
danger: okHex(st.danger) ? st.danger : sys.color.state.danger,
|
|
170
|
+
info: okHex(st.info) ? st.info : sys.color.state.info,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
type: {
|
|
174
|
+
family: ['sans', 'serif', 'rounded', 'mono'].includes(j.font) ? j.font : sys.type.family,
|
|
175
|
+
scale: {
|
|
176
|
+
display: typeof sc.display === 'number' ? sc.display : sys.type.scale.display,
|
|
177
|
+
title: typeof sc.title === 'number' ? sc.title : sys.type.scale.title,
|
|
178
|
+
body: typeof sc.body === 'number' ? sc.body : sys.type.scale.body,
|
|
179
|
+
caption: typeof sc.caption === 'number' ? sc.caption : sys.type.scale.caption,
|
|
180
|
+
},
|
|
181
|
+
weightBold: sys.type.weightBold,
|
|
182
|
+
},
|
|
183
|
+
space: { ...sys.space, density: ['compact', 'cozy', 'spacious'].includes(j.density) ? j.density : sys.space.density },
|
|
184
|
+
shape: {
|
|
185
|
+
...sys.shape,
|
|
186
|
+
radiusCard: typeof j.radiusCard === 'number' ? Math.max(0, Math.min(24, j.radiusCard)) : sys.shape.radiusCard,
|
|
187
|
+
radiusBtn: typeof j.radiusCard === 'number' ? Math.max(4, Math.round(j.radiusCard * 0.7)) : sys.shape.radiusBtn,
|
|
188
|
+
shadow: ['none', 'soft', 'strong'].includes(j.shadow) ? j.shadow : sys.shape.shadow,
|
|
189
|
+
},
|
|
190
|
+
a11y: {
|
|
191
|
+
highContrast: typeof j.highContrast === 'boolean' ? j.highContrast : sys.a11y.highContrast,
|
|
192
|
+
colorblindSafe: typeof j.colorblindSafe === 'boolean' ? j.colorblindSafe : sys.a11y.colorblindSafe,
|
|
193
|
+
notes: typeof j.a11yNotes === 'string' ? j.a11yNotes : sys.a11y.notes,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function recordFrom(ans, sys, aiRationale) {
|
|
198
|
+
const rationale = aiRationale?.trim() || rationaleFor(ans, sys);
|
|
199
|
+
return { system: sys, guide: deriveGuide(sys), rationale, styleTileHtml: styleTileHtml(sys), status: 'proposed' };
|
|
200
|
+
}
|
|
201
|
+
export async function generateDesignSystem(documentId) {
|
|
202
|
+
const doc = repo.getDocument(documentId);
|
|
203
|
+
if (!doc)
|
|
204
|
+
throw new Error('document not found');
|
|
205
|
+
const ans = answersOf(documentId);
|
|
206
|
+
let sys = buildSystem(ans);
|
|
207
|
+
let aiRationale;
|
|
208
|
+
if (!(config.aiStub || config.managedTier)) {
|
|
209
|
+
try {
|
|
210
|
+
const cfg = getModelConfig('design-system');
|
|
211
|
+
const provider = resolveProvider(cfg.provider);
|
|
212
|
+
let text = '';
|
|
213
|
+
for await (const d of provider.streamChat({ model: cfg.model, maxTokens: Math.max(cfg.maxTokens, 2500), messages: buildMessages(ans) }))
|
|
214
|
+
text += d;
|
|
215
|
+
const j = extractJson(text);
|
|
216
|
+
sys = mergeAiSystem(sys, j);
|
|
217
|
+
if (typeof j.rationale === 'string')
|
|
218
|
+
aiRationale = j.rationale;
|
|
219
|
+
}
|
|
220
|
+
catch { /* 결정적 결과 유지 */ }
|
|
221
|
+
}
|
|
222
|
+
const rec = recordFrom(ans, sys, aiRationale);
|
|
223
|
+
repo.setSetting(key(doc.project_id), rec);
|
|
224
|
+
return rec;
|
|
225
|
+
}
|
|
226
|
+
// ── P2: 방향 탐색 — 성격만 다른 3방향안(강조색·상태색·모드 유지) ──────────────
|
|
227
|
+
export function exploreDesignSystems(documentId) {
|
|
228
|
+
const doc = repo.getDocument(documentId);
|
|
229
|
+
if (!doc)
|
|
230
|
+
throw new Error('document not found');
|
|
231
|
+
const ans = answersOf(documentId);
|
|
232
|
+
const primary = buildSystem(ans); // 답변 그대로 = 균형안
|
|
233
|
+
const acc = primary.color.light.accent;
|
|
234
|
+
// 강조색·상태색·모드·접근성은 3안 공통 유지, 성격(프리셋)만 다르게.
|
|
235
|
+
const norm = (s) => {
|
|
236
|
+
s.color.light.accent = acc;
|
|
237
|
+
if (s.color.dark)
|
|
238
|
+
s.color.dark.accent = acc;
|
|
239
|
+
s.color.state = primary.color.state;
|
|
240
|
+
s.color.primary = primary.color.primary;
|
|
241
|
+
s.color.dark = primary.color.dark;
|
|
242
|
+
s.a11y = primary.a11y;
|
|
243
|
+
return s;
|
|
244
|
+
};
|
|
245
|
+
const recs = [
|
|
246
|
+
recordFrom(ans, primary, '균형 — 강조색 유지, 중립적 균형.'),
|
|
247
|
+
recordFrom(ans, norm(buildSystem(ans, 'warm')), '부드러움 — 강조색 유지, 여유·둥근 모서리·따뜻한 표면.'),
|
|
248
|
+
recordFrom(ans, norm(buildSystem(ans, 'mono')), '선명함 — 강조색 유지, 촘촘·각진.'),
|
|
249
|
+
];
|
|
250
|
+
repo.setSetting(candKey(doc.project_id), recs);
|
|
251
|
+
return recs;
|
|
252
|
+
}
|
|
253
|
+
export function selectDesignSystemCandidate(documentId, index) {
|
|
254
|
+
const doc = repo.getDocument(documentId);
|
|
255
|
+
if (!doc)
|
|
256
|
+
throw new Error('document not found');
|
|
257
|
+
const recs = repo.getSetting(candKey(doc.project_id)) ?? [];
|
|
258
|
+
const rec = recs[index];
|
|
259
|
+
if (!rec)
|
|
260
|
+
throw new Error('candidate not found');
|
|
261
|
+
const chosen = { ...rec, status: 'proposed' };
|
|
262
|
+
repo.setSetting(key(doc.project_id), chosen);
|
|
263
|
+
return chosen;
|
|
264
|
+
}
|
|
265
|
+
export function acceptDesignSystem(documentId) {
|
|
266
|
+
const doc = repo.getDocument(documentId);
|
|
267
|
+
if (!doc)
|
|
268
|
+
throw new Error('document not found');
|
|
269
|
+
const rec = getDesignSystem(doc.project_id);
|
|
270
|
+
if (!rec)
|
|
271
|
+
throw new Error('no design system to accept');
|
|
272
|
+
saveStyleGuide(doc.project_id, rec.guide);
|
|
273
|
+
const next = { ...rec, status: 'accepted' };
|
|
274
|
+
repo.setSetting(key(doc.project_id), next);
|
|
275
|
+
return next;
|
|
276
|
+
}
|
|
277
|
+
/** 스타일 타일 — 시스템을 색 역할·상태색·타입 스케일·여백·컴포넌트(미디어 그리드 포함)로 결정적 렌더. */
|
|
278
|
+
export function styleTileHtml(s) {
|
|
279
|
+
const c = s.color[s.color.primary] ?? s.color.light;
|
|
280
|
+
const font = s.type.family === 'serif' ? 'Georgia, serif' : s.type.family === 'mono' ? 'ui-monospace, Menlo, monospace' : '-apple-system, system-ui, sans-serif';
|
|
281
|
+
const sh = s.shape.shadow === 'none' ? 'none' : s.shape.shadow === 'strong' ? '0 6px 20px rgba(0,0,0,.14)' : '0 1px 3px rgba(0,0,0,.07)';
|
|
282
|
+
const roles = [['bg', c.bg], ['surface', c.surface], ['ink', c.ink], ['sub', c.sub], ['line', c.line], ['accent', c.accent]];
|
|
283
|
+
const states = [['성공', s.color.state.success, '✓'], ['경고', s.color.state.warning, '⚠'], ['위험', s.color.state.danger, '✕'], ['정보', s.color.state.info, 'ℹ']];
|
|
284
|
+
const swatch = (n, col) => `<div class="sw"><span class="chip" style="background:${col}"></span><b>${n}</b><code>${col}</code></div>`;
|
|
285
|
+
const stateBadge = ([n, col, g]) => `<span class="badge" style="color:${col};border-color:${col}"><i style="background:${col}"></i>${g} ${n}</span>`;
|
|
286
|
+
const spaceBar = (v) => `<div class="spx"><span style="width:${v}px"></span><code>${v}</code></div>`;
|
|
287
|
+
const thumb = (on) => `<div class="thumb${on ? ' on' : ''}"><div class="thumb-img"></div><div class="thumb-cap">시안 ${on ? '●' : '○'}</div></div>`;
|
|
288
|
+
const darkStrip = s.color.dark
|
|
289
|
+
? `<h4>다크 세트</h4><div class="darkstrip" style="background:${s.color.dark.bg}">${['bg', 'surface', 'ink', 'sub', 'line', 'accent'].map((k) => `<span style="background:${s.color.dark[k]}"></span>`).join('')}<b style="color:${s.color.dark.ink}">Aa 가나다</b></div>`
|
|
290
|
+
: '';
|
|
291
|
+
const a11yLine = (s.a11y.highContrast || s.a11y.colorblindSafe || s.a11y.notes)
|
|
292
|
+
? `<h4>접근성</h4><p class="a11y">${[s.a11y.highContrast ? '고대비' : '', s.a11y.colorblindSafe ? '색맹 안전 — 상태는 색+글리프(●✓⚠)로' : '', s.a11y.notes].filter(Boolean).join(' · ')}</p>`
|
|
293
|
+
: '';
|
|
294
|
+
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
|
295
|
+
:root{--ac:${c.accent};--act:${c.accentText};--bg:${c.bg};--sf:${c.surface};--sf2:${c.surfaceAlt};--ink:${c.ink};--sub:${c.sub};--ln:${c.line};--rc:${s.shape.radiusCard}px;--rb:${s.shape.radiusBtn}px;--sh:${sh}}
|
|
296
|
+
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--ink);font-family:${font};padding:22px;line-height:1.5}
|
|
297
|
+
h4{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--sub);margin:22px 0 9px;font-weight:700}h4:first-child{margin-top:0}
|
|
298
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px}
|
|
299
|
+
.sw{background:var(--sf);border:1px solid var(--ln);border-radius:var(--rc);padding:9px}.sw .chip{height:30px;border-radius:calc(var(--rc) - 2px);border:1px solid var(--ln);display:block;margin-bottom:6px}.sw b{font-size:12px}.sw code{font-size:10px;color:var(--sub);display:block}
|
|
300
|
+
.badges{display:flex;flex-wrap:wrap;gap:8px}.badge{display:inline-flex;align-items:center;gap:6px;border:1px solid;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600}.badge i{width:8px;height:8px;border-radius:50%;display:block}
|
|
301
|
+
.type .row{display:flex;align-items:baseline;gap:10px;border-bottom:1px solid var(--ln);padding:7px 0}.type .row code{font-size:10px;color:var(--sub);width:64px;flex:none}
|
|
302
|
+
.d{font-size:${s.type.scale.display}px;font-weight:${s.type.weightBold}}.t{font-size:${s.type.scale.title}px;font-weight:700}.b{font-size:${s.type.scale.body}px}.cap{font-size:${s.type.scale.caption}px;color:var(--sub)}
|
|
303
|
+
.space{display:flex;flex-direction:column;gap:5px}.spx{display:flex;align-items:center;gap:8px}.spx span{height:12px;background:var(--ac);opacity:.8;border-radius:3px;display:block}.spx code{font-size:10px;color:var(--sub);font-family:ui-monospace,monospace}
|
|
304
|
+
.comp{display:flex;flex-wrap:wrap;gap:9px;align-items:center}
|
|
305
|
+
.btn{border:0;border-radius:var(--rb);padding:9px 15px;font-size:13px;font-weight:700;font-family:inherit;box-shadow:var(--sh)}
|
|
306
|
+
.btn.p{background:var(--ac);color:var(--act)}.btn.s{background:var(--sf);color:var(--ink);border:1px solid var(--ln)}.btn.g{background:transparent;color:var(--ac)}
|
|
307
|
+
.input{background:var(--sf);border:1px solid var(--ln);border-radius:var(--rb);padding:9px 12px;color:var(--sub);font-size:13px;min-width:160px}
|
|
308
|
+
.chip2{border:1px solid var(--ln);border-radius:999px;padding:4px 12px;font-size:12px;color:var(--sub)}.chip2.on{background:var(--ac);color:var(--act);border-color:var(--ac)}
|
|
309
|
+
.sw2{width:42px;height:24px;border-radius:999px;background:var(--ac);position:relative}.sw2::after{content:'';position:absolute;top:3px;right:3px;width:18px;height:18px;border-radius:50%;background:#fff}
|
|
310
|
+
.card{background:var(--sf);border:1px solid var(--ln);border-radius:var(--rc);padding:13px;box-shadow:var(--sh);min-width:170px}.card b{font-size:14px}.card p{font-size:12px;color:var(--sub);margin-top:4px}
|
|
311
|
+
.media{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
|
|
312
|
+
.thumb{background:var(--sf);border:1px solid var(--ln);border-radius:var(--rc);overflow:hidden}.thumb.on{border-color:var(--ac);box-shadow:0 0 0 2px var(--ac)}
|
|
313
|
+
.thumb-img{height:56px;background:linear-gradient(135deg,var(--sf2),color-mix(in srgb,var(--ac) 22%,var(--sf)))}.thumb-cap{font-size:10px;color:var(--sub);padding:5px 7px}
|
|
314
|
+
.darkstrip{display:flex;align-items:center;gap:6px;border-radius:var(--rc);padding:10px}.darkstrip span{width:26px;height:26px;border-radius:6px;display:block}.darkstrip b{margin-left:auto;font-size:14px}
|
|
315
|
+
.a11y{font-size:12px;color:var(--sub);background:var(--sf2);border:1px solid var(--ln);border-radius:var(--rc);padding:8px 11px}
|
|
316
|
+
</style></head><body>
|
|
317
|
+
<h4>색 시스템 (역할)</h4><div class="grid">${roles.map(([n, col]) => swatch(n, col)).join('')}</div>
|
|
318
|
+
<h4>상태색</h4><div class="badges">${states.map(stateBadge).join('')}</div>
|
|
319
|
+
${darkStrip}
|
|
320
|
+
<h4>타입 스케일</h4><div class="type">
|
|
321
|
+
<div class="row"><code>display</code><span class="d">화면 제목 Aa</span></div>
|
|
322
|
+
<div class="row"><code>title</code><span class="t">섹션 제목 가나다</span></div>
|
|
323
|
+
<div class="row"><code>body</code><span class="b">본문은 이 크기·서체로 읽힙니다. 색은 역할 체계로.</span></div>
|
|
324
|
+
<div class="row"><code>caption</code><span class="cap">캡션 · 보조 정보</span></div>
|
|
325
|
+
</div>
|
|
326
|
+
<h4>여백 체계 (4px 그리드)</h4><div class="space">${s.space.scale.map(spaceBar).join('')}</div>
|
|
327
|
+
<h4>컴포넌트</h4><div class="comp"><button class="btn p">주요 액션</button><button class="btn s">보조</button><button class="btn g">텍스트</button><span class="chip2 on">활성</span><span class="chip2">기본</span><span class="sw2"></span></div>
|
|
328
|
+
<div class="comp" style="margin-top:9px"><div class="card"><b>카드</b><p>표면·경계·라운드·그림자(${s.shape.shadow}).</p></div><div class="input">입력 필드</div></div>
|
|
329
|
+
<h4>미디어 · 시안 그리드 (여러 안 비교)</h4><div class="media">${thumb(true)}${thumb(false)}${thumb(false)}${thumb(false)}</div>
|
|
330
|
+
${a11yLine}
|
|
331
|
+
</body></html>`;
|
|
332
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Deterministic demo data (§2 stub, §sample seed) — the "회의실 예약" example.
|
|
2
|
+
// Content only, NO ref_ids: the server numbers items in this exact order, so the
|
|
3
|
+
// refs it assigns (F-01, F-01-1, PG-01, FLOW-01, FLOW-01.1 …) match the cross-
|
|
4
|
+
// links written below. Same input → same output (AI·cost 0).
|
|
5
|
+
// PRD sections seed → REQ-01..REQ-04 (§1.2 derivation order).
|
|
6
|
+
export const PRD_SECTIONS = [
|
|
7
|
+
{
|
|
8
|
+
heading: '문제 정의',
|
|
9
|
+
body: '회의실 예약이 캘린더·메신저·구두로 흩어져 겹침과 노쇼가 잦다. 회의가 늘어져 다음 팀이 못 들어간다.',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
heading: '목표',
|
|
13
|
+
body: '겹침 없는 즉시 예약과 종료 10분 전 알림·자동 반납으로 회의실 회전율을 높인다.',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
heading: '자동 반납',
|
|
17
|
+
body: '연장하지 않으면 종료 시각에 자동으로 반납되고, 노쇼는 5분 후 자동 해제된다.',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
heading: '운영 가시성',
|
|
21
|
+
body: '관리자는 이용률·노쇼·자동 반납 추이를 주간으로 본다. 대시보드 UI 신규 개발은 비범위.',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
export const SPEC_FIXTURE = {
|
|
25
|
+
groups: [
|
|
26
|
+
{
|
|
27
|
+
title: '회의실 예약 생성',
|
|
28
|
+
features: [
|
|
29
|
+
{
|
|
30
|
+
title: '시간대 선택 후 즉시 예약 확정',
|
|
31
|
+
body: '· 시간대 선택 즉시 확정\n· 확정 시 내 예약(PG-04)에 기록',
|
|
32
|
+
priority: 'P0',
|
|
33
|
+
source: 'REQ-01',
|
|
34
|
+
links: { reqs: ['REQ-01'], pages: ['PG-02', 'PG-03'], flows: ['FLOW-01'] },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
title: '겹침 검사 후 대안 시간 3개 제시',
|
|
38
|
+
body: '· 겹침 감지 시 대안 3개 제시\n· 재선택 후 겹침 재판정',
|
|
39
|
+
priority: 'P0',
|
|
40
|
+
source: 'Q2',
|
|
41
|
+
links: { reqs: ['REQ-01'], pages: ['PG-02'], flows: ['FLOW-01'] },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: '종료 10분 전 알림, 미연장 시 자동 반납',
|
|
45
|
+
body: '· 종료 10분 전 알림 발송, 수신 채널은 PG-06에서 설정\n· 연장 없이 종료 시각 도달 → 자동 반납 + PG-04에 기록',
|
|
46
|
+
priority: 'P1',
|
|
47
|
+
source: 'Q3',
|
|
48
|
+
links: { reqs: ['REQ-03'], pages: ['PG-06', 'PG-04'], flows: ['FLOW-02'] },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
title: '노쇼 처리',
|
|
54
|
+
features: [
|
|
55
|
+
{
|
|
56
|
+
title: '시작 5분 내 체크인 없으면 예약 해제',
|
|
57
|
+
body: '· 시작 5분 내 체크인 없으면 자동 해제\n· 해제 이력은 PG-04에 표시',
|
|
58
|
+
priority: 'P1',
|
|
59
|
+
source: 'REQ-02',
|
|
60
|
+
links: { reqs: ['REQ-02'], pages: ['PG-04'], flows: ['FLOW-03'] },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
title: '반복 노쇼 사용자 주간 리포트',
|
|
64
|
+
body: '· 반복 노쇼 사용자를 주간으로 집계',
|
|
65
|
+
priority: 'P2',
|
|
66
|
+
source: 'PRD §4',
|
|
67
|
+
links: { reqs: ['REQ-02'], pages: ['PG-04'] },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
title: '관리자 통계',
|
|
73
|
+
features: [
|
|
74
|
+
{
|
|
75
|
+
// Intentionally orphaned (no reqs link) → seeds a W-ORPHAN-SPEC in the demo.
|
|
76
|
+
title: '이용률·노쇼·자동반납 집계',
|
|
77
|
+
body: '· 이용률·노쇼·자동 반납을 주간 집계해 PG-05에 표시',
|
|
78
|
+
priority: 'P2',
|
|
79
|
+
source: 'PRD §4',
|
|
80
|
+
links: { pages: ['PG-05'] },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
export const IA_FIXTURE = {
|
|
87
|
+
pages: [
|
|
88
|
+
{ title: '예약 홈', page_type: 'LIST', section: '예약', links: { features: ['F-01-1'] } },
|
|
89
|
+
{ title: '회의실 상세', page_type: 'DETAIL', section: '예약', links: { features: ['F-01-1', 'F-01-2'] } },
|
|
90
|
+
{ title: '예약 확인', page_type: 'FORM', section: '예약', links: { features: ['F-01-1'] } },
|
|
91
|
+
{ title: '내 예약', page_type: 'LIST', section: '내 정보', links: { features: ['F-01-3', 'F-02-1', 'F-02-2'] } },
|
|
92
|
+
{ title: '알림 설정', page_type: 'SETTINGS', section: '내 정보', links: { features: ['F-01-3'] } },
|
|
93
|
+
// Reached by no flow step → seeds a W-UNREACHED-PAGE in the demo.
|
|
94
|
+
{ title: '관리자 통계', page_type: 'DASH', section: '관리자', links: { features: ['F-03-1'] } },
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
export const FLOW_FIXTURE = {
|
|
98
|
+
flows: [
|
|
99
|
+
{
|
|
100
|
+
title: '예약 생성',
|
|
101
|
+
source: 'F-01 · 해피 패스 + 겹침 분기',
|
|
102
|
+
links: { features: ['F-01', 'F-01-1', 'F-01-2'] },
|
|
103
|
+
steps: [
|
|
104
|
+
{ title: '예약 필요', node: 'start', page: null },
|
|
105
|
+
{ title: '예약 홈', node: 'screen', page: 'PG-01' },
|
|
106
|
+
{ title: '시간대 선택', node: 'screen', page: 'PG-02' },
|
|
107
|
+
{ title: '겹침?', node: 'decision', page: null },
|
|
108
|
+
{ title: '예약 확정', node: 'screen', page: 'PG-03' },
|
|
109
|
+
{ title: '예약됨', node: 'end', page: null },
|
|
110
|
+
{
|
|
111
|
+
title: '대안 3개 재선택',
|
|
112
|
+
node: 'screen',
|
|
113
|
+
page: 'PG-02',
|
|
114
|
+
branch: { label: '예', from_step: 'FLOW-01.4' },
|
|
115
|
+
note: '겹침? 재판정 (F-01-2)',
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
title: '자동 반납',
|
|
121
|
+
source: 'F-01-3',
|
|
122
|
+
links: { features: ['F-01-3'] },
|
|
123
|
+
steps: [
|
|
124
|
+
{ title: '종료 10분 전', node: 'start', page: null },
|
|
125
|
+
{ title: '알림 수신', node: 'screen', page: 'PG-06' },
|
|
126
|
+
{ title: '연장?', node: 'decision', page: null },
|
|
127
|
+
{ title: '자동 반납 기록', node: 'screen', page: 'PG-04' },
|
|
128
|
+
{ title: '반납됨', node: 'end', page: null },
|
|
129
|
+
{
|
|
130
|
+
title: '시간 연장',
|
|
131
|
+
node: 'screen',
|
|
132
|
+
page: 'PG-02',
|
|
133
|
+
branch: { label: '예', from_step: 'FLOW-02.3' },
|
|
134
|
+
note: '예약 연장 후 종료',
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
title: '노쇼 처리',
|
|
140
|
+
source: 'F-02',
|
|
141
|
+
links: { features: ['F-02', 'F-02-1'] },
|
|
142
|
+
steps: [
|
|
143
|
+
{ title: '시작 5분 경과', node: 'start', page: null },
|
|
144
|
+
{ title: '체크인?', node: 'decision', page: null },
|
|
145
|
+
{ title: '예약 해제 기록', node: 'screen', page: 'PG-04' },
|
|
146
|
+
{ title: '해제됨', node: 'end', page: null },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI 호환 게이트웨이(LiteLLM·Azure·사내 프록시) 자동 감지 유틸.
|
|
3
|
+
* - 사용자가 `/v1` 을 붙였는지 몰라도 되게 경로를 자동 탐지한다.
|
|
4
|
+
* - 게이트웨이의 `/models` 를 조회해 실제 사용 가능한 모델 목록을 돌려준다(드롭다운·테스트용).
|
|
5
|
+
* 특정 회사에 종속되지 않는다 — 넣은 base URL 의 게이트웨이를 그대로 조회한다.
|
|
6
|
+
*/
|
|
7
|
+
/** rawBase 에서 `/models` 가 응답하는 경로를 찾아 chatBase 와 모델 목록을 반환. 실패 시 null. */
|
|
8
|
+
export async function probeGateway(rawBase, key, headers = {}) {
|
|
9
|
+
const b = rawBase.trim().replace(/\/+$/, '');
|
|
10
|
+
if (!b)
|
|
11
|
+
return null;
|
|
12
|
+
// (models 조회 URL, 그때의 chat base) 후보 — 이미 /v1 이면 그대로, 아니면 /v1 우선 후 루트.
|
|
13
|
+
const candidates = b.endsWith('/v1')
|
|
14
|
+
? [[`${b}/models`, b]]
|
|
15
|
+
: [
|
|
16
|
+
[`${b}/v1/models`, `${b}/v1`],
|
|
17
|
+
[`${b}/models`, b],
|
|
18
|
+
];
|
|
19
|
+
for (const [url, chatBase] of candidates) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
headers: { authorization: `Bearer ${key}`, ...headers },
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok)
|
|
25
|
+
continue;
|
|
26
|
+
const j = (await res.json());
|
|
27
|
+
const models = Array.isArray(j?.data)
|
|
28
|
+
? j.data.map((m) => m?.id).filter((x) => typeof x === 'string' && x.length > 0)
|
|
29
|
+
: [];
|
|
30
|
+
return { chatBase, models };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// 다음 후보로
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** OpenRouter 공개 모델 목록 — 표준 OpenRouter(BYOK) 사용 시 드롭다운 채우기용.
|
|
39
|
+
* listing 은 인증 불필요(키 있으면 계정 반영). 실패 시 빈 배열. */
|
|
40
|
+
export async function fetchOpenRouterModels(key) {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch('https://openrouter.ai/api/v1/models', {
|
|
43
|
+
headers: key ? { authorization: `Bearer ${key}` } : {},
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
return [];
|
|
47
|
+
const j = (await res.json());
|
|
48
|
+
return Array.isArray(j?.data)
|
|
49
|
+
? j.data.map((m) => m?.id).filter((x) => typeof x === 'string' && x.length > 0)
|
|
50
|
+
: [];
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|