@mandujs/core 0.18.22 → 0.19.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.
Files changed (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -1,244 +1,374 @@
1
- /**
2
- * Mandu Kitchen DevTools - Guard Panel
3
- * @version 1.0.3
4
- */
5
-
6
- import React, { useMemo } from 'react';
7
- import type { DevToolsGuardViolation } from '../../../types';
8
- import { colors, typography, spacing, borderRadius, animation } from '../../../design-tokens';
9
-
10
- // ============================================================================
11
- // Styles
12
- // ============================================================================
13
-
14
- const styles = {
15
- container: {
16
- padding: spacing.md,
17
- height: '100%',
18
- display: 'flex',
19
- flexDirection: 'column' as const,
20
- gap: spacing.md,
21
- },
22
- header: {
23
- display: 'flex',
24
- alignItems: 'center',
25
- justifyContent: 'space-between',
26
- },
27
- clearButton: {
28
- padding: `${spacing.xs} ${spacing.sm}`,
29
- borderRadius: borderRadius.sm,
30
- backgroundColor: colors.background.light,
31
- border: 'none',
32
- color: colors.text.secondary,
33
- fontSize: typography.fontSize.xs,
34
- cursor: 'pointer',
35
- transition: `all ${animation.duration.fast}`,
36
- },
37
- list: {
38
- flex: 1,
39
- overflow: 'auto',
40
- display: 'flex',
41
- flexDirection: 'column' as const,
42
- gap: spacing.sm,
43
- },
44
- emptyState: {
45
- flex: 1,
46
- display: 'flex',
47
- flexDirection: 'column' as const,
48
- alignItems: 'center',
49
- justifyContent: 'center',
50
- gap: spacing.md,
51
- padding: spacing.xl,
52
- color: colors.text.muted,
53
- fontSize: typography.fontSize.sm,
54
- textAlign: 'center' as const,
55
- },
56
- violationItem: {
57
- padding: spacing.md,
58
- borderRadius: borderRadius.md,
59
- backgroundColor: colors.background.medium,
60
- borderLeft: '3px solid',
61
- transition: `all ${animation.duration.fast}`,
62
- },
63
- violationHeader: {
64
- display: 'flex',
65
- alignItems: 'flex-start',
66
- justifyContent: 'space-between',
67
- gap: spacing.sm,
68
- marginBottom: spacing.xs,
69
- },
70
- ruleName: {
71
- display: 'flex',
72
- alignItems: 'center',
73
- gap: spacing.xs,
74
- },
75
- badge: {
76
- padding: `2px ${spacing.xs}`,
77
- borderRadius: borderRadius.sm,
78
- fontSize: typography.fontSize.xs,
79
- fontWeight: typography.fontWeight.medium,
80
- },
81
- message: {
82
- fontSize: typography.fontSize.sm,
83
- color: colors.text.primary,
84
- lineHeight: typography.lineHeight.normal,
85
- marginBottom: spacing.sm,
86
- },
87
- location: {
88
- display: 'flex',
89
- alignItems: 'center',
90
- gap: spacing.sm,
91
- fontSize: typography.fontSize.xs,
92
- fontFamily: typography.fontFamily.mono,
93
- color: colors.text.muted,
94
- },
95
- arrow: {
96
- color: colors.text.muted,
97
- },
98
- suggestion: {
99
- marginTop: spacing.sm,
100
- padding: spacing.sm,
101
- borderRadius: borderRadius.sm,
102
- backgroundColor: `${colors.semantic.info}10`,
103
- borderLeft: `2px solid ${colors.semantic.info}`,
104
- fontSize: typography.fontSize.xs,
105
- color: colors.text.secondary,
106
- lineHeight: typography.lineHeight.normal,
107
- },
108
- suggestionLabel: {
109
- fontWeight: typography.fontWeight.semibold,
110
- color: colors.semantic.info,
111
- marginBottom: '4px',
112
- },
113
- };
114
-
115
- const severityStyles: Record<string, { border: string; bg: string; color: string }> = {
116
- error: {
117
- border: colors.semantic.error,
118
- bg: `${colors.semantic.error}20`,
119
- color: colors.semantic.error,
120
- },
121
- warning: {
122
- border: colors.semantic.warning,
123
- bg: `${colors.semantic.warning}20`,
124
- color: colors.semantic.warning,
125
- },
126
- };
127
-
128
- // ============================================================================
129
- // Props
130
- // ============================================================================
131
-
132
- export interface GuardPanelProps {
133
- violations: DevToolsGuardViolation[];
134
- onClear: () => void;
135
- }
136
-
137
- // ============================================================================
138
- // Component
139
- // ============================================================================
140
-
141
- export function GuardPanel({ violations, onClear }: GuardPanelProps): React.ReactElement {
142
- // Group by rule
143
- const groupedByRule = useMemo(() => {
144
- const groups = new Map<string, DevToolsGuardViolation[]>();
145
- for (const v of violations) {
146
- const existing = groups.get(v.ruleId) ?? [];
147
- groups.set(v.ruleId, [...existing, v]);
148
- }
149
- return groups;
150
- }, [violations]);
151
-
152
- if (violations.length === 0) {
153
- return (
154
- <div style={styles.container}>
155
- <div style={styles.emptyState}>
156
- 🛡️
157
- <p>
158
- 아키텍처 위반이 없어요!<br />
159
- 코드가 규칙을 잘 따르고 있어요.
160
- </p>
161
- </div>
162
- </div>
163
- );
164
- }
165
-
166
- return (
167
- <div style={styles.container}>
168
- {/* Header */}
169
- <div style={styles.header}>
170
- <span style={{ fontSize: typography.fontSize.sm, color: colors.text.secondary }}>
171
- {violations.length}개의 위반 ({groupedByRule.size}개 규칙)
172
- </span>
173
- <button style={styles.clearButton} onClick={onClear}>
174
- 모두 지우기
175
- </button>
176
- </div>
177
-
178
- {/* Violation List */}
179
- <div style={styles.list}>
180
- {violations.map((violation) => {
181
- const severity = severityStyles[violation.severity] ?? severityStyles.warning;
182
-
183
- return (
184
- <div
185
- key={violation.id}
186
- style={{
187
- ...styles.violationItem,
188
- borderLeftColor: severity.border,
189
- }}
190
- >
191
- <div style={styles.violationHeader}>
192
- <div style={styles.ruleName}>
193
- <span
194
- style={{
195
- ...styles.badge,
196
- backgroundColor: severity.bg,
197
- color: severity.color,
198
- }}
199
- >
200
- {violation.severity}
201
- </span>
202
- <span
203
- style={{
204
- ...styles.badge,
205
- backgroundColor: colors.background.light,
206
- color: colors.text.secondary,
207
- }}
208
- >
209
- {violation.ruleName}
210
- </span>
211
- </div>
212
- </div>
213
-
214
- <p style={styles.message}>{violation.message}</p>
215
-
216
- <div style={styles.location}>
217
- <span>
218
- {violation.source.file}
219
- {violation.source.line && `:${violation.source.line}`}
220
- </span>
221
- {violation.target && (
222
- <>
223
- <span style={styles.arrow}>→</span>
224
- <span>
225
- {violation.target.file}
226
- {violation.target.line && `:${violation.target.line}`}
227
- </span>
228
- </>
229
- )}
230
- </div>
231
-
232
- {violation.suggestion && (
233
- <div style={styles.suggestion}>
234
- <div style={styles.suggestionLabel}>💡 제안</div>
235
- {violation.suggestion}
236
- </div>
237
- )}
238
- </div>
239
- );
240
- })}
241
- </div>
242
- </div>
243
- );
244
- }
1
+ /**
2
+ * Mandu Kitchen DevTools - Guard Panel
3
+ * @version 1.0.3
4
+ */
5
+
6
+ import React, { useMemo, useState, useCallback } from 'react';
7
+ import type { DevToolsGuardViolation } from '../../../types';
8
+ import { colors, typography, spacing, borderRadius, animation } from '../../../design-tokens';
9
+
10
+ // ============================================================================
11
+ // Styles
12
+ // ============================================================================
13
+
14
+ const styles = {
15
+ container: {
16
+ padding: spacing.md,
17
+ height: '100%',
18
+ display: 'flex',
19
+ flexDirection: 'column' as const,
20
+ gap: spacing.md,
21
+ },
22
+ header: {
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ justifyContent: 'space-between',
26
+ },
27
+ clearButton: {
28
+ padding: `${spacing.xs} ${spacing.sm}`,
29
+ borderRadius: borderRadius.sm,
30
+ backgroundColor: colors.background.light,
31
+ border: 'none',
32
+ color: colors.text.secondary,
33
+ fontSize: typography.fontSize.xs,
34
+ cursor: 'pointer',
35
+ transition: `all ${animation.duration.fast}`,
36
+ },
37
+ list: {
38
+ flex: 1,
39
+ overflow: 'auto',
40
+ display: 'flex',
41
+ flexDirection: 'column' as const,
42
+ gap: spacing.sm,
43
+ },
44
+ emptyState: {
45
+ flex: 1,
46
+ display: 'flex',
47
+ flexDirection: 'column' as const,
48
+ alignItems: 'center',
49
+ justifyContent: 'center',
50
+ gap: spacing.md,
51
+ padding: spacing.xl,
52
+ color: colors.text.muted,
53
+ fontSize: typography.fontSize.sm,
54
+ textAlign: 'center' as const,
55
+ },
56
+ violationItem: {
57
+ padding: spacing.md,
58
+ borderRadius: borderRadius.md,
59
+ backgroundColor: colors.background.medium,
60
+ borderLeft: '3px solid',
61
+ transition: `all ${animation.duration.fast}`,
62
+ },
63
+ violationHeader: {
64
+ display: 'flex',
65
+ alignItems: 'flex-start',
66
+ justifyContent: 'space-between',
67
+ gap: spacing.sm,
68
+ marginBottom: spacing.xs,
69
+ },
70
+ ruleName: {
71
+ display: 'flex',
72
+ alignItems: 'center',
73
+ gap: spacing.xs,
74
+ },
75
+ badge: {
76
+ padding: `2px ${spacing.xs}`,
77
+ borderRadius: borderRadius.sm,
78
+ fontSize: typography.fontSize.xs,
79
+ fontWeight: typography.fontWeight.medium,
80
+ },
81
+ message: {
82
+ fontSize: typography.fontSize.sm,
83
+ color: colors.text.primary,
84
+ lineHeight: typography.lineHeight.normal,
85
+ marginBottom: spacing.sm,
86
+ },
87
+ location: {
88
+ display: 'flex',
89
+ alignItems: 'center',
90
+ gap: spacing.sm,
91
+ fontSize: typography.fontSize.xs,
92
+ fontFamily: typography.fontFamily.mono,
93
+ color: colors.text.muted,
94
+ },
95
+ arrow: {
96
+ color: colors.text.muted,
97
+ },
98
+ suggestion: {
99
+ marginTop: spacing.sm,
100
+ padding: spacing.sm,
101
+ borderRadius: borderRadius.sm,
102
+ backgroundColor: `${colors.semantic.info}10`,
103
+ borderLeft: `2px solid ${colors.semantic.info}`,
104
+ fontSize: typography.fontSize.xs,
105
+ color: colors.text.secondary,
106
+ lineHeight: typography.lineHeight.normal,
107
+ },
108
+ suggestionLabel: {
109
+ fontWeight: typography.fontWeight.semibold,
110
+ color: colors.semantic.info,
111
+ marginBottom: '4px',
112
+ },
113
+ };
114
+
115
+ const severityStyles: Record<string, { border: string; bg: string; color: string }> = {
116
+ error: {
117
+ border: colors.semantic.error,
118
+ bg: `${colors.semantic.error}20`,
119
+ color: colors.semantic.error,
120
+ },
121
+ warning: {
122
+ border: colors.semantic.warning,
123
+ bg: `${colors.semantic.warning}20`,
124
+ color: colors.semantic.warning,
125
+ },
126
+ };
127
+
128
+ // ============================================================================
129
+ // Props
130
+ // ============================================================================
131
+
132
+ export interface GuardDecisionState {
133
+ violationKey: string;
134
+ action: 'approve' | 'reject';
135
+ }
136
+
137
+ export interface GuardPanelProps {
138
+ violations: DevToolsGuardViolation[];
139
+ onClear: () => void;
140
+ }
141
+
142
+ // ============================================================================
143
+ // Component
144
+ // ============================================================================
145
+
146
+ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.ReactElement {
147
+ const [scanning, setScanning] = useState(false);
148
+ const [scanResult, setScanResult] = useState<{ total: number; errors: number; warnings: number } | null>(null);
149
+ const [decisions, setDecisions] = useState<Map<string, 'approve' | 'reject'>>(new Map());
150
+ const [showApproved, setShowApproved] = useState(false);
151
+
152
+ const handleDecision = useCallback(async (ruleId: string, filePath: string, action: 'approve' | 'reject') => {
153
+ try {
154
+ const endpoint = action === 'approve' ? '/__kitchen/api/guard/approve' : '/__kitchen/api/guard/reject';
155
+ const res = await fetch(endpoint, {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({ ruleId, filePath }),
159
+ });
160
+ if (res.ok) {
161
+ setDecisions(prev => {
162
+ const next = new Map(prev);
163
+ next.set(`${ruleId}::${filePath}`, action);
164
+ return next;
165
+ });
166
+ }
167
+ } catch {
168
+ // Kitchen API not available
169
+ }
170
+ }, []);
171
+
172
+ const handleScan = useCallback(async () => {
173
+ setScanning(true);
174
+ setScanResult(null);
175
+ try {
176
+ const res = await fetch('/__kitchen/api/guard/scan', { method: 'POST' });
177
+ if (res.ok) {
178
+ const data = await res.json();
179
+ if (data.report) {
180
+ setScanResult({
181
+ total: data.report.totalViolations,
182
+ errors: data.report.bySeverity?.error ?? 0,
183
+ warnings: data.report.bySeverity?.warn ?? data.report.bySeverity?.warning ?? 0,
184
+ });
185
+ }
186
+ }
187
+ } catch {
188
+ // Kitchen API not available (non-dev or older version)
189
+ } finally {
190
+ setScanning(false);
191
+ }
192
+ }, []);
193
+
194
+ // Group by rule
195
+ const groupedByRule = useMemo(() => {
196
+ const groups = new Map<string, DevToolsGuardViolation[]>();
197
+ for (const v of violations) {
198
+ const existing = groups.get(v.ruleId) ?? [];
199
+ groups.set(v.ruleId, [...existing, v]);
200
+ }
201
+ return groups;
202
+ }, [violations]);
203
+
204
+ if (violations.length === 0) {
205
+ return (
206
+ <div style={styles.container}>
207
+ <div style={styles.emptyState}>
208
+ 🛡️
209
+ <p>
210
+ 아키텍처 위반이 없어요!<br />
211
+ 코드가 규칙을 잘 따르고 있어요.
212
+ </p>
213
+ <button
214
+ style={{
215
+ ...styles.clearButton,
216
+ padding: `${spacing.sm} ${spacing.md}`,
217
+ marginTop: spacing.sm,
218
+ opacity: scanning ? 0.5 : 1,
219
+ }}
220
+ onClick={handleScan}
221
+ disabled={scanning}
222
+ >
223
+ {scanning ? '스캔 중...' : '🔍 전체 스캔'}
224
+ </button>
225
+ {scanResult && (
226
+ <p style={{ marginTop: spacing.sm, fontSize: typography.fontSize.xs }}>
227
+ 스캔 결과: {scanResult.total === 0
228
+ ? '위반 없음 ✅'
229
+ : `${scanResult.total}개 위반 (에러 ${scanResult.errors}, 경고 ${scanResult.warnings})`
230
+ }
231
+ </p>
232
+ )}
233
+ </div>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ return (
239
+ <div style={styles.container}>
240
+ {/* Header */}
241
+ <div style={styles.header}>
242
+ <span style={{ fontSize: typography.fontSize.sm, color: colors.text.secondary }}>
243
+ {violations.length}개의 위반 ({groupedByRule.size}개 규칙)
244
+ </span>
245
+ <div style={{ display: 'flex', gap: spacing.xs }}>
246
+ <button
247
+ style={styles.clearButton}
248
+ onClick={() => setShowApproved(!showApproved)}
249
+ >
250
+ {showApproved ? '승인 숨기기' : '승인 보기'}
251
+ </button>
252
+ <button
253
+ style={{ ...styles.clearButton, opacity: scanning ? 0.5 : 1 }}
254
+ onClick={handleScan}
255
+ disabled={scanning}
256
+ >
257
+ {scanning ? '스캔 중...' : '🔍 스캔'}
258
+ </button>
259
+ <button style={styles.clearButton} onClick={onClear}>
260
+ 모두 지우기
261
+ </button>
262
+ </div>
263
+ </div>
264
+
265
+ {/* Violation List */}
266
+ <div style={styles.list}>
267
+ {violations.map((violation) => {
268
+ const severity = severityStyles[violation.severity] ?? severityStyles.warning;
269
+ const decisionKey = `${violation.ruleId}::${violation.source.file}`;
270
+ const decision = decisions.get(decisionKey);
271
+ const isApproved = decision === 'approve';
272
+
273
+ if (isApproved && !showApproved) return null;
274
+
275
+ return (
276
+ <div
277
+ key={violation.id}
278
+ style={{
279
+ ...styles.violationItem,
280
+ borderLeftColor: severity.border,
281
+ opacity: isApproved ? 0.5 : 1,
282
+ }}
283
+ >
284
+ <div style={styles.violationHeader}>
285
+ <div style={styles.ruleName}>
286
+ <span
287
+ style={{
288
+ ...styles.badge,
289
+ backgroundColor: severity.bg,
290
+ color: severity.color,
291
+ }}
292
+ >
293
+ {violation.severity}
294
+ </span>
295
+ <span
296
+ style={{
297
+ ...styles.badge,
298
+ backgroundColor: colors.background.light,
299
+ color: colors.text.secondary,
300
+ }}
301
+ >
302
+ {violation.ruleName}
303
+ </span>
304
+ {isApproved && (
305
+ <span
306
+ style={{
307
+ ...styles.badge,
308
+ backgroundColor: `${colors.semantic.success}20`,
309
+ color: colors.semantic.success,
310
+ }}
311
+ >
312
+ Approved
313
+ </span>
314
+ )}
315
+ </div>
316
+ <div style={{ display: 'flex', gap: '4px' }}>
317
+ <button
318
+ style={{
319
+ ...styles.clearButton,
320
+ fontSize: typography.fontSize.xs,
321
+ padding: '2px 6px',
322
+ backgroundColor: isApproved ? `${colors.semantic.success}20` : colors.background.light,
323
+ color: isApproved ? colors.semantic.success : colors.text.secondary,
324
+ }}
325
+ onClick={() => handleDecision(violation.ruleId, violation.source.file, 'approve')}
326
+ >
327
+
328
+ </button>
329
+ <button
330
+ style={{
331
+ ...styles.clearButton,
332
+ fontSize: typography.fontSize.xs,
333
+ padding: '2px 6px',
334
+ backgroundColor: decision === 'reject' ? `${colors.semantic.error}20` : colors.background.light,
335
+ color: decision === 'reject' ? colors.semantic.error : colors.text.secondary,
336
+ }}
337
+ onClick={() => handleDecision(violation.ruleId, violation.source.file, 'reject')}
338
+ >
339
+
340
+ </button>
341
+ </div>
342
+ </div>
343
+
344
+ <p style={styles.message}>{violation.message}</p>
345
+
346
+ <div style={styles.location}>
347
+ <span>
348
+ {violation.source.file}
349
+ {violation.source.line && `:${violation.source.line}`}
350
+ </span>
351
+ {violation.target && (
352
+ <>
353
+ <span style={styles.arrow}>→</span>
354
+ <span>
355
+ {violation.target.file}
356
+ {violation.target.line && `:${violation.target.line}`}
357
+ </span>
358
+ </>
359
+ )}
360
+ </div>
361
+
362
+ {violation.suggestion && (
363
+ <div style={styles.suggestion}>
364
+ <div style={styles.suggestionLabel}>💡 제안</div>
365
+ {violation.suggestion}
366
+ </div>
367
+ )}
368
+ </div>
369
+ );
370
+ })}
371
+ </div>
372
+ </div>
373
+ );
374
+ }