@mandujs/core 0.19.2 → 0.20.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -11,11 +11,67 @@ import type {
11
11
  BundleManifest,
12
12
  BundleStats,
13
13
  BundlerOptions,
14
+ IslandFileEntry,
14
15
  } from "./types";
15
16
  import { HYDRATION } from "../constants";
16
17
  import path from "path";
17
18
  import fs from "fs/promises";
18
19
 
20
+ /** Scan for *.island.tsx / *.island.ts files across hydrated route directories. */
21
+ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<IslandFileEntry[]> {
22
+ const entries: IslandFileEntry[] = [];
23
+ const seenDirs = new Set<string>();
24
+
25
+ for (const route of routes) {
26
+ const dir = path.dirname(path.join(rootDir, route.componentModule ?? route.module));
27
+ if (seenDirs.has(dir)) continue;
28
+ seenDirs.add(dir);
29
+
30
+ let files: string[];
31
+ try { files = await fs.readdir(dir); } catch { continue; }
32
+
33
+ const priority = getRouteHydration(route)?.priority || HYDRATION.DEFAULT_PRIORITY;
34
+ for (const file of files) {
35
+ if (/\.island\.tsx?$/.test(file)) {
36
+ entries.push({
37
+ name: file.replace(/\.island\.tsx?$/, ""),
38
+ filePath: path.join(dir, file),
39
+ routeId: route.id,
40
+ priority,
41
+ });
42
+ }
43
+ }
44
+ }
45
+ return entries;
46
+ }
47
+
48
+ /** Build a single per-island bundle. */
49
+ async function buildPerIslandBundle(
50
+ entry: IslandFileEntry, outDir: string, options: BundlerOptions
51
+ ): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
52
+ const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
53
+ const outputName = `${entry.name}.island.js`;
54
+ try {
55
+ await Bun.write(entryPath, generateIslandEntry(entry.name, entry.filePath));
56
+ const result = await Bun.build({
57
+ entrypoints: [entryPath],
58
+ outdir: outDir,
59
+ naming: outputName,
60
+ minify: options.minify ?? process.env.NODE_ENV === "production",
61
+ sourcemap: options.sourcemap ? "external" : "none",
62
+ target: "browser",
63
+ external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
64
+ define: { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), ...options.define },
65
+ });
66
+ await fs.unlink(entryPath).catch(() => {});
67
+ if (!result.success) throw new Error(result.logs.map((l) => l.message).join("\n"));
68
+ return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
69
+ } catch (error) {
70
+ await fs.unlink(entryPath).catch(() => {});
71
+ throw error;
72
+ }
73
+ }
74
+
19
75
  /**
20
76
  * 빈 매니페스트 생성
21
77
  */
@@ -1292,7 +1348,8 @@ function createBundleManifest(
1292
1348
  runtimePath: string,
1293
1349
  vendorResult: VendorBuildResult,
1294
1350
  routerPath: string,
1295
- env: "development" | "production"
1351
+ env: "development" | "production",
1352
+ islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>
1296
1353
  ): BundleManifest {
1297
1354
  const bundles: BundleManifest["bundles"] = {};
1298
1355
 
@@ -1307,11 +1364,25 @@ function createBundleManifest(
1307
1364
  };
1308
1365
  }
1309
1366
 
1367
+ // Per-island bundles (code splitting)
1368
+ let islands: BundleManifest["islands"];
1369
+ if (islandBundles && islandBundles.length > 0) {
1370
+ islands = {};
1371
+ for (const ib of islandBundles) {
1372
+ islands[ib.name] = {
1373
+ js: ib.js,
1374
+ route: ib.route,
1375
+ priority: ib.priority,
1376
+ };
1377
+ }
1378
+ }
1379
+
1310
1380
  return {
1311
1381
  version: 1,
1312
1382
  buildTime: new Date().toISOString(),
1313
1383
  env,
1314
1384
  bundles,
1385
+ ...(islands ? { islands } : {}),
1315
1386
  shared: {
1316
1387
  runtime: runtimePath,
1317
1388
  vendor: vendorResult.react, // primary vendor for backwards compatibility
@@ -1544,6 +1615,26 @@ export async function buildClientBundles(
1544
1615
  }
1545
1616
  }
1546
1617
 
1618
+ // 5.5. Per-island code splitting: scan and build individual island bundles
1619
+ const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
1620
+ const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
1621
+
1622
+ if (islandFiles.length > 0) {
1623
+ const islandResults = await Promise.all(
1624
+ islandFiles.map(async (entry) => {
1625
+ try {
1626
+ return await buildPerIslandBundle(entry, outDir, options);
1627
+ } catch (error) {
1628
+ errors.push(`[island:${entry.name}] ${String(error)}`);
1629
+ return null;
1630
+ }
1631
+ })
1632
+ );
1633
+ for (const result of islandResults) {
1634
+ if (result) islandBundles.push(result);
1635
+ }
1636
+ }
1637
+
1547
1638
  // 6. 번들 매니페스트 생성
1548
1639
  const bundleManifest = createBundleManifest(
1549
1640
  outputs,
@@ -1551,7 +1642,8 @@ export async function buildClientBundles(
1551
1642
  runtimeResult.outputPath,
1552
1643
  vendorResult,
1553
1644
  routerResult.outputPath,
1554
- env
1645
+ env,
1646
+ islandBundles
1555
1647
  );
1556
1648
 
1557
1649
  await fs.writeFile(
@@ -53,6 +53,18 @@ export interface BundleManifest {
53
53
  priority: "immediate" | "visible" | "idle" | "interaction";
54
54
  }
55
55
  >;
56
+ /** Per-island bundles (code splitting: each island file gets its own JS bundle) */
57
+ islands?: Record<
58
+ string,
59
+ {
60
+ /** JavaScript bundle path */
61
+ js: string;
62
+ /** Route that owns this island */
63
+ route: string;
64
+ /** Hydration priority */
65
+ priority: "immediate" | "visible" | "idle" | "interaction";
66
+ }
67
+ >;
56
68
  /** 공유 청크 */
57
69
  shared: {
58
70
  /** Hydration 런타임 */
@@ -87,6 +99,14 @@ export interface BundleStats {
87
99
  bundleCount: number;
88
100
  }
89
101
 
102
+ /** Per-island code splitting entry (used by scanIslandFiles) */
103
+ export interface IslandFileEntry {
104
+ name: string;
105
+ filePath: string;
106
+ routeId: string;
107
+ priority: "immediate" | "visible" | "idle" | "interaction";
108
+ }
109
+
90
110
  /**
91
111
  * 번들러 옵션
92
112
  */
@@ -8,24 +8,34 @@ import type { ManduState } from '../../types';
8
8
  import { MANDU_CHARACTERS } from '../../types';
9
9
  import { colors, typography, animation, testIds } from '../../design-tokens';
10
10
 
11
- // ============================================================================
12
- // Styles
13
- // ============================================================================
14
-
15
11
  const styles = {
16
12
  container: {
17
13
  display: 'flex',
18
14
  alignItems: 'center',
19
15
  gap: '12px',
20
16
  padding: '12px 16px',
21
- borderRadius: '12px',
22
- backgroundColor: colors.background.medium,
17
+ borderRadius: '16px',
18
+ background: `linear-gradient(180deg, ${colors.background.medium}, ${colors.background.dark})`,
19
+ border: `1px solid ${colors.background.light}`,
20
+ boxShadow: '0 14px 32px rgba(7, 7, 12, 0.28)',
23
21
  transition: `all ${animation.duration.normal} ${animation.easing.easeOut}`,
24
22
  },
25
- emoji: {
26
- fontSize: '32px',
23
+ mark: {
24
+ display: 'flex',
25
+ alignItems: 'center',
26
+ justifyContent: 'center',
27
+ minWidth: '44px',
28
+ height: '44px',
29
+ padding: '0 12px',
30
+ borderRadius: '9999px',
31
+ fontSize: '11px',
27
32
  lineHeight: 1,
33
+ letterSpacing: '0.12em',
34
+ fontWeight: 700,
28
35
  userSelect: 'none' as const,
36
+ fontFamily: typography.fontFamily.mono,
37
+ textTransform: 'uppercase' as const,
38
+ border: `1px solid ${colors.background.light}`,
29
39
  },
30
40
  content: {
31
41
  display: 'flex',
@@ -35,7 +45,7 @@ const styles = {
35
45
  message: {
36
46
  fontSize: '14px',
37
47
  color: colors.text.primary,
38
- fontWeight: 500,
48
+ fontWeight: 600,
39
49
  },
40
50
  status: {
41
51
  fontSize: '12px',
@@ -51,10 +61,6 @@ const stateColors: Record<ManduState, string> = {
51
61
  hmr: colors.brand.accent,
52
62
  };
53
63
 
54
- // ============================================================================
55
- // Animation Keyframes (inline)
56
- // ============================================================================
57
-
58
64
  const bounceAnimation = `
59
65
  @keyframes mk-bounce {
60
66
  0%, 100% { transform: translateY(0); }
@@ -80,15 +86,11 @@ const shakeAnimation = `
80
86
  const sparkleAnimation = `
81
87
  @keyframes mk-sparkle {
82
88
  0% { transform: scale(1); }
83
- 50% { transform: scale(1.1); }
89
+ 50% { transform: scale(1.08); }
84
90
  100% { transform: scale(1); }
85
91
  }
86
92
  `;
87
93
 
88
- // ============================================================================
89
- // Props
90
- // ============================================================================
91
-
92
94
  export interface ManduCharacterProps {
93
95
  state: ManduState;
94
96
  errorCount?: number;
@@ -97,10 +99,6 @@ export interface ManduCharacterProps {
97
99
  onClick?: () => void;
98
100
  }
99
101
 
100
- // ============================================================================
101
- // Component
102
- // ============================================================================
103
-
104
102
  export function ManduCharacter({
105
103
  state,
106
104
  errorCount = 0,
@@ -126,25 +124,34 @@ export function ManduCharacter({
126
124
  }
127
125
  };
128
126
 
129
- const containerStyle = {
127
+ const containerStyle: React.CSSProperties = {
130
128
  ...styles.container,
131
129
  borderLeft: `4px solid ${stateColor}`,
132
130
  cursor: onClick ? 'pointer' : 'default',
133
131
  ...(compact && {
134
132
  padding: '8px 12px',
135
133
  gap: '8px',
134
+ borderRadius: '14px',
136
135
  }),
137
136
  };
138
137
 
139
- const emojiStyle = {
140
- ...styles.emoji,
138
+ const markStyle: React.CSSProperties = {
139
+ ...styles.mark,
141
140
  animation: getAnimation(),
142
- ...(compact && { fontSize: '24px' }),
141
+ color: stateColor,
142
+ backgroundColor: `${stateColor}18`,
143
+ borderColor: `${stateColor}45`,
144
+ boxShadow: `inset 0 1px 0 rgba(255, 255, 255, 0.03), 0 0 0 1px ${stateColor}10`,
145
+ ...(compact && {
146
+ minWidth: '36px',
147
+ height: '36px',
148
+ padding: '0 10px',
149
+ fontSize: '10px',
150
+ }),
143
151
  };
144
152
 
145
153
  return (
146
154
  <>
147
- {/* Inject keyframes */}
148
155
  <style>
149
156
  {bounceAnimation}
150
157
  {pulseAnimation}
@@ -166,8 +173,8 @@ export function ManduCharacter({
166
173
  }
167
174
  }}
168
175
  >
169
- <span style={emojiStyle} aria-hidden="true">
170
- {character.emoji}
176
+ <span style={markStyle} aria-hidden="true">
177
+ {character.mark}
171
178
  </span>
172
179
 
173
180
  {!compact && (
@@ -175,7 +182,7 @@ export function ManduCharacter({
175
182
  <span style={styles.message}>{character.message}</span>
176
183
  {errorCount > 0 && (
177
184
  <span style={styles.status}>
178
- {errorCount}개의 {state === 'error' ? '에러' : '경고'}가 있어요
185
+ {errorCount}개의 {state === 'error' ? '에러' : '경고'}가 있습니다
179
186
  </span>
180
187
  )}
181
188
  </div>
@@ -185,10 +192,6 @@ export function ManduCharacter({
185
192
  );
186
193
  }
187
194
 
188
- // ============================================================================
189
- // Badge Component (for mini display)
190
- // ============================================================================
191
-
192
195
  export interface ManduBadgeProps {
193
196
  state: ManduState;
194
197
  count?: number;
@@ -210,28 +213,28 @@ export function ManduBadge({
210
213
  display: 'flex',
211
214
  alignItems: 'center',
212
215
  justifyContent: 'center',
213
- width: '52px',
214
- height: '52px',
215
- padding: 0,
216
- borderRadius: '9999px',
217
- backgroundColor: isPressed
218
- ? colors.background.light
216
+ width: '72px',
217
+ height: '56px',
218
+ padding: '0 14px',
219
+ borderRadius: '18px',
220
+ background: isPressed
221
+ ? `linear-gradient(180deg, ${colors.background.light}, ${colors.background.medium})`
219
222
  : isHovered
220
- ? colors.background.medium
221
- : colors.background.dark,
222
- border: `2px solid ${isPressed ? stateColor : isHovered ? colors.brand.accent : stateColor + '70'}`,
223
+ ? `linear-gradient(180deg, ${colors.background.medium}, ${colors.background.dark})`
224
+ : `linear-gradient(180deg, ${colors.background.dark}, ${colors.background.medium})`,
225
+ border: `1px solid ${isPressed ? stateColor : isHovered ? colors.brand.accent : `${stateColor}80`}`,
223
226
  cursor: 'pointer',
224
227
  transition: `all 200ms ${animation.easing.spring}`,
225
228
  boxShadow: isPressed
226
- ? `0 2px 8px rgba(8, 6, 18, 0.4), inset 0 2px 4px rgba(0, 0, 0, 0.15)`
229
+ ? '0 8px 18px rgba(8, 6, 18, 0.36), inset 0 2px 4px rgba(0, 0, 0, 0.12)'
227
230
  : isHovered
228
- ? `0 8px 32px rgba(8, 6, 18, 0.45), 0 0 20px ${stateColor}55, 0 0 0 3px ${stateColor}20`
229
- : `0 4px 16px rgba(8, 6, 18, 0.35), 0 0 10px ${stateColor}30`,
231
+ ? `0 18px 36px rgba(8, 6, 18, 0.42), 0 0 0 4px ${stateColor}18`
232
+ : `0 10px 22px rgba(8, 6, 18, 0.3), 0 0 0 1px ${stateColor}14`,
230
233
  userSelect: 'none',
231
234
  transform: isPressed
232
235
  ? 'scale(0.92) translateY(1px)'
233
236
  : isHovered
234
- ? 'scale(1.1) translateY(-2px)'
237
+ ? 'scale(1.04) translateY(-2px)'
235
238
  : 'scale(1) translateY(0px)',
236
239
  outline: 'none',
237
240
  lineHeight: 1,
@@ -247,17 +250,29 @@ export function ManduBadge({
247
250
  };
248
251
 
249
252
  const textStyle: React.CSSProperties = {
253
+ display: 'flex',
254
+ flexDirection: 'column',
255
+ alignItems: 'flex-start',
256
+ gap: '3px',
250
257
  fontFamily: typography.fontFamily.sans,
251
258
  color: colors.brand.accent,
252
- fontSize: '14px',
259
+ fontSize: '13px',
253
260
  fontWeight: typography.fontWeight.bold,
254
261
  lineHeight: 1,
255
- letterSpacing: '0.5px',
262
+ letterSpacing: '0.08em',
256
263
  transition: `transform 200ms ${animation.easing.spring}`,
257
- transform: isHovered ? 'rotate(-8deg)' : 'rotate(0deg)',
264
+ transform: isHovered ? 'translateY(-1px)' : 'translateY(0px)',
258
265
  userSelect: 'none',
259
266
  };
260
267
 
268
+ const subtextStyle: React.CSSProperties = {
269
+ fontSize: '9px',
270
+ fontWeight: typography.fontWeight.medium,
271
+ letterSpacing: '0.16em',
272
+ color: colors.text.secondary,
273
+ textTransform: 'uppercase',
274
+ };
275
+
261
276
  const countBubbleStyle: React.CSSProperties = {
262
277
  position: 'absolute',
263
278
  top: '-4px',
@@ -286,14 +301,23 @@ export function ManduBadge({
286
301
  style={badgeStyle}
287
302
  onClick={onClick}
288
303
  onMouseEnter={() => setIsHovered(true)}
289
- onMouseLeave={() => { setIsHovered(false); setIsPressed(false); }}
304
+ onMouseLeave={() => {
305
+ setIsHovered(false);
306
+ setIsPressed(false);
307
+ }}
290
308
  onMouseDown={() => setIsPressed(true)}
291
309
  onMouseUp={() => setIsPressed(false)}
292
310
  onFocus={() => setIsHovered(true)}
293
- onBlur={() => { setIsHovered(false); setIsPressed(false); }}
311
+ onBlur={() => {
312
+ setIsHovered(false);
313
+ setIsPressed(false);
314
+ }}
294
315
  aria-label={`Mandu Kitchen: ${character.message}${count > 0 ? `, ${count} issues` : ''}`}
295
316
  >
296
- <span aria-hidden="true" style={textStyle}>MK</span>
317
+ <span aria-hidden="true" style={textStyle}>
318
+ <span>MK</span>
319
+ <span style={subtextStyle}>Dev</span>
320
+ </span>
297
321
  {count > 0 && (
298
322
  <span style={countBubbleStyle}>
299
323
  {count > 99 ? '99+' : count}
@@ -176,8 +176,8 @@ export function ErrorsPanel({
176
176
  <div style={styles.emptyState}>
177
177
  <ManduCharacter state="normal" compact />
178
178
  <p style={styles.emptyMessage}>
179
- 에러가 없어요!<br />
180
- 만두가 익고 있어요 🥟
179
+ 활성 오류가 없습니다.<br />
180
+ 이슈가 생기면 여기에 표시됩니다.
181
181
  </p>
182
182
  </div>
183
183
  </div>
@@ -204,12 +204,11 @@ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.Reac
204
204
  if (violations.length === 0) {
205
205
  return (
206
206
  <div style={styles.container}>
207
- <div style={styles.emptyState}>
208
- 🛡️
209
- <p>
210
- 아키텍처 위반이 없어요!<br />
211
- 코드가 규칙을 잘 따르고 있어요.
212
- </p>
207
+ <div style={styles.emptyState}>
208
+ <p>
209
+ 현재 아키텍처 위반이 없습니다.<br />
210
+ 스캔 결과가 생기면 여기에 표시됩니다.
211
+ </p>
213
212
  <button
214
213
  style={{
215
214
  ...styles.clearButton,
@@ -219,16 +218,16 @@ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.Reac
219
218
  }}
220
219
  onClick={handleScan}
221
220
  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>
221
+ >
222
+ {scanning ? '스캔 중...' : '전체 스캔'}
223
+ </button>
224
+ {scanResult && (
225
+ <p style={{ marginTop: spacing.sm, fontSize: typography.fontSize.xs }}>
226
+ 스캔 결과: {scanResult.total === 0
227
+ ? '위반 없음'
228
+ : `${scanResult.total}개 위반 (에러 ${scanResult.errors}, 경고 ${scanResult.warnings})`
229
+ }
230
+ </p>
232
231
  )}
233
232
  </div>
234
233
  </div>
@@ -253,9 +252,9 @@ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.Reac
253
252
  style={{ ...styles.clearButton, opacity: scanning ? 0.5 : 1 }}
254
253
  onClick={handleScan}
255
254
  disabled={scanning}
256
- >
257
- {scanning ? '스캔 중...' : '🔍 스캔'}
258
- </button>
255
+ >
256
+ {scanning ? '스캔 중...' : '스캔'}
257
+ </button>
259
258
  <button style={styles.clearButton} onClick={onClear}>
260
259
  모두 지우기
261
260
  </button>
@@ -323,9 +322,9 @@ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.Reac
323
322
  color: isApproved ? colors.semantic.success : colors.text.secondary,
324
323
  }}
325
324
  onClick={() => handleDecision(violation.ruleId, violation.source.file, 'approve')}
326
- >
327
-
328
- </button>
325
+ >
326
+ 허용
327
+ </button>
329
328
  <button
330
329
  style={{
331
330
  ...styles.clearButton,
@@ -335,9 +334,9 @@ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.Reac
335
334
  color: decision === 'reject' ? colors.semantic.error : colors.text.secondary,
336
335
  }}
337
336
  onClick={() => handleDecision(violation.ruleId, violation.source.file, 'reject')}
338
- >
339
-
340
- </button>
337
+ >
338
+ 차단
339
+ </button>
341
340
  </div>
342
341
  </div>
343
342
 
@@ -359,12 +358,12 @@ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.Reac
359
358
  )}
360
359
  </div>
361
360
 
362
- {violation.suggestion && (
363
- <div style={styles.suggestion}>
364
- <div style={styles.suggestionLabel}>💡 제안</div>
365
- {violation.suggestion}
366
- </div>
367
- )}
361
+ {violation.suggestion && (
362
+ <div style={styles.suggestion}>
363
+ <div style={styles.suggestionLabel}>제안</div>
364
+ {violation.suggestion}
365
+ </div>
366
+ )}
368
367
  </div>
369
368
  );
370
369
  })}
@@ -68,9 +68,18 @@ const styles = {
68
68
  transition: `all ${animation.duration.fast}`,
69
69
  },
70
70
  islandIcon: {
71
- fontSize: typography.fontSize.lg,
72
- width: '28px',
71
+ display: 'inline-flex',
72
+ alignItems: 'center',
73
+ justifyContent: 'center',
74
+ minWidth: '48px',
75
+ height: '28px',
76
+ padding: '0 8px',
77
+ borderRadius: borderRadius.full,
73
78
  textAlign: 'center' as const,
79
+ fontSize: typography.fontSize.xs,
80
+ fontWeight: typography.fontWeight.bold,
81
+ fontFamily: typography.fontFamily.mono,
82
+ letterSpacing: '0.08em',
74
83
  },
75
84
  islandInfo: {
76
85
  flex: 1,
@@ -118,11 +127,11 @@ const styles = {
118
127
  };
119
128
 
120
129
  const statusConfig: Record<string, { icon: string; color: string; bg: string }> = {
121
- ssr: { icon: '📄', color: colors.text.muted, bg: colors.background.light },
122
- pending: { icon: '', color: colors.semantic.warning, bg: `${colors.semantic.warning}20` },
123
- hydrating: { icon: '💧', color: colors.semantic.info, bg: `${colors.semantic.info}20` },
124
- hydrated: { icon: '', color: colors.semantic.success, bg: `${colors.semantic.success}20` },
125
- error: { icon: '', color: colors.semantic.error, bg: `${colors.semantic.error}20` },
130
+ ssr: { icon: 'SSR', color: colors.text.muted, bg: colors.background.light },
131
+ pending: { icon: 'WAIT', color: colors.semantic.warning, bg: `${colors.semantic.warning}20` },
132
+ hydrating: { icon: 'HYDR', color: colors.semantic.info, bg: `${colors.semantic.info}20` },
133
+ hydrated: { icon: 'READY', color: colors.semantic.success, bg: `${colors.semantic.success}20` },
134
+ error: { icon: 'ERR', color: colors.semantic.error, bg: `${colors.semantic.error}20` },
126
135
  };
127
136
 
128
137
  const strategyLabels: Record<string, string> = {
@@ -178,10 +187,9 @@ export function IslandsPanel({ islands }: IslandsPanelProps): React.ReactElement
178
187
  return (
179
188
  <div style={styles.container}>
180
189
  <div style={styles.emptyState}>
181
- 🏝️
182
190
  <p>
183
- 아직 등록된 Island가 없어요.<br />
184
- Island 컴포넌트를 사용하면 여기에 표시됩니다.
191
+ 아직 등록된 Island가 없습니다.<br />
192
+ Island 컴포넌트를 사용하면 여기에 상태가 표시됩니다.
185
193
  </p>
186
194
  </div>
187
195
  </div>
@@ -198,18 +206,18 @@ export function IslandsPanel({ islands }: IslandsPanelProps): React.ReactElement
198
206
  <span style={styles.statValue}>{stats.total}</span>
199
207
  </div>
200
208
  <div style={styles.stat}>
201
- <span>✅</span>
209
+ <span>Ready</span>
202
210
  <span style={styles.statValue}>{stats.hydrated}</span>
203
211
  </div>
204
212
  {stats.pending > 0 && (
205
213
  <div style={styles.stat}>
206
- <span>⏳</span>
214
+ <span>Pending</span>
207
215
  <span style={styles.statValue}>{stats.pending}</span>
208
216
  </div>
209
217
  )}
210
218
  {stats.errors > 0 && (
211
219
  <div style={styles.stat}>
212
- <span>❌</span>
220
+ <span>Error</span>
213
221
  <span style={{ ...styles.statValue, color: colors.semantic.error }}>
214
222
  {stats.errors}
215
223
  </span>
@@ -234,7 +242,15 @@ export function IslandsPanel({ islands }: IslandsPanelProps): React.ReactElement
234
242
 
235
243
  return (
236
244
  <div key={island.id} style={styles.islandItem}>
237
- <span style={styles.islandIcon}>{status.icon}</span>
245
+ <span
246
+ style={{
247
+ ...styles.islandIcon,
248
+ backgroundColor: status.bg,
249
+ color: status.color,
250
+ }}
251
+ >
252
+ {status.icon}
253
+ </span>
238
254
 
239
255
  <div style={styles.islandInfo}>
240
256
  <div style={styles.islandName}>{island.name}</div>
@@ -213,10 +213,9 @@ export function NetworkPanel({ requests }: NetworkPanelProps): React.ReactElemen
213
213
  return (
214
214
  <div style={styles.container}>
215
215
  <div style={styles.emptyState}>
216
- 📡
217
216
  <p>
218
- 아직 네트워크 요청이 없어요.<br />
219
- API 호출이 발생하면 여기에 표시됩니다.
217
+ 아직 네트워크 요청이 없습니다.<br />
218
+ 요청이 발생하면 여기에 기록됩니다.
220
219
  </p>
221
220
  </div>
222
221
  </div>