careermate 0.0.5 → 0.0.7

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 (125) hide show
  1. package/AGENTS.md +13 -10
  2. package/INSTALL.md +16 -15
  3. package/README.md +24 -24
  4. package/THIRD_PARTY_NOTICES.md +2 -3
  5. package/bin/careermate.mjs +8 -1
  6. package/dist/career-os/README.md +12 -6
  7. package/dist/career-os/eop/career-description.md +139 -0
  8. package/dist/career-os/eop/cover-letter.md +10 -5
  9. package/dist/career-os/eop/fit-analysis.md +16 -6
  10. package/dist/career-os/eop/interview-prep.md +209 -21
  11. package/dist/career-os/eop/job-analysis.md +6 -4
  12. package/dist/career-os/eop/profile-extraction.md +5 -4
  13. package/dist/career-os/knowledge/cover-letter.md +5 -4
  14. package/dist/career-os/knowledge/fit-matching.md +8 -8
  15. package/dist/career-os/knowledge/human-writing.md +2 -0
  16. package/dist/career-os/knowledge/interview-behavioral.md +76 -8
  17. package/dist/career-os/knowledge/interview-technical.md +106 -4
  18. package/dist/career-os/knowledge/linkedin-profile.md +1 -1
  19. package/dist/career-os/knowledge/networking-referrals.md +1 -1
  20. package/dist/career-os/knowledge/offer-evaluation-decision.md +10 -10
  21. package/dist/career-os/knowledge/portfolio.md +3 -3
  22. package/dist/career-os/knowledge/verifiers/ats-compat.md +8 -4
  23. package/dist/career-os/knowledge/verifiers/human-voice.md +4 -2
  24. package/dist/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  25. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  26. package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -1
  27. package/dist/doctor.mjs +692 -71
  28. package/dist/mcp.mjs +710 -71
  29. package/dist/migrate.mjs +52 -1
  30. package/dist/public/app.js +42 -16
  31. package/dist/public/i18n/en/applications.js +23 -0
  32. package/dist/public/i18n/en/common.js +22 -0
  33. package/dist/public/i18n/en/documents.js +133 -0
  34. package/dist/public/i18n/en/home.js +75 -0
  35. package/dist/public/i18n/en/interview.js +69 -0
  36. package/dist/public/i18n/en/jobs.js +152 -0
  37. package/dist/public/i18n/en/nav.js +18 -0
  38. package/dist/public/i18n/en/profile.js +201 -0
  39. package/dist/public/i18n/en/settings.js +127 -0
  40. package/dist/public/i18n/en/status.js +22 -0
  41. package/dist/public/i18n/en.js +17 -0
  42. package/dist/public/i18n/ko/applications.js +23 -0
  43. package/dist/public/i18n/ko/common.js +22 -0
  44. package/dist/public/i18n/ko/documents.js +133 -0
  45. package/dist/public/i18n/ko/home.js +63 -0
  46. package/dist/public/i18n/ko/interview.js +69 -0
  47. package/dist/public/i18n/ko/jobs.js +152 -0
  48. package/dist/public/i18n/ko/nav.js +18 -0
  49. package/dist/public/i18n/ko/profile.js +201 -0
  50. package/dist/public/i18n/ko/settings.js +127 -0
  51. package/dist/public/i18n/ko/status.js +23 -0
  52. package/dist/public/i18n/ko.js +18 -0
  53. package/dist/public/i18n.js +55 -0
  54. package/dist/public/index.html +1 -0
  55. package/dist/public/lib.js +24 -17
  56. package/dist/public/pages/applications.js +17 -21
  57. package/dist/public/pages/documents.js +153 -107
  58. package/dist/public/pages/home.js +88 -53
  59. package/dist/public/pages/interview.js +105 -51
  60. package/dist/public/pages/jobs.js +330 -106
  61. package/dist/public/pages/profile.js +157 -152
  62. package/dist/public/pages/settings.js +154 -102
  63. package/dist/public/styles.css +28 -1
  64. package/dist/web.mjs +495 -75
  65. package/docs/FAQ.md +23 -3
  66. package/docs/MCP_TOOLS.md +22 -13
  67. package/docs/START_WORKFLOW.md +3 -3
  68. package/docs/SUPPORTED_AI_APPS.md +13 -0
  69. package/docs/WORKFLOWS.md +77 -20
  70. package/docs/career-os/README.md +12 -6
  71. package/docs/career-os/eop/career-description.md +139 -0
  72. package/docs/career-os/eop/cover-letter.md +10 -5
  73. package/docs/career-os/eop/fit-analysis.md +16 -6
  74. package/docs/career-os/eop/interview-prep.md +209 -21
  75. package/docs/career-os/eop/job-analysis.md +6 -4
  76. package/docs/career-os/eop/profile-extraction.md +5 -4
  77. package/docs/career-os/knowledge/cover-letter.md +5 -4
  78. package/docs/career-os/knowledge/fit-matching.md +8 -8
  79. package/docs/career-os/knowledge/human-writing.md +2 -0
  80. package/docs/career-os/knowledge/interview-behavioral.md +76 -8
  81. package/docs/career-os/knowledge/interview-technical.md +106 -4
  82. package/docs/career-os/knowledge/linkedin-profile.md +1 -1
  83. package/docs/career-os/knowledge/networking-referrals.md +1 -1
  84. package/docs/career-os/knowledge/offer-evaluation-decision.md +10 -10
  85. package/docs/career-os/knowledge/portfolio.md +3 -3
  86. package/docs/career-os/knowledge/verifiers/ats-compat.md +8 -4
  87. package/docs/career-os/knowledge/verifiers/human-voice.md +4 -2
  88. package/docs/career-os/knowledge/verifiers/recency-staleness.md +1 -1
  89. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +2 -2
  90. package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -1
  91. package/package.json +2 -2
  92. package/packages/core/src/onboarding.ts +2 -1
  93. package/packages/core/src/services.ts +134 -17
  94. package/packages/core/src/summary.ts +59 -1
  95. package/packages/core/src/verify/lint.ts +4 -0
  96. package/packages/db/src/index.ts +1 -0
  97. package/packages/db/src/repositories.ts +0 -0
  98. package/packages/db/src/schema.ts +17 -0
  99. package/packages/exporters/src/index.ts +28 -17
  100. package/packages/knowledge/src/index.ts +55 -9
  101. package/packages/mcp-tools/src/tools.ts +250 -24
  102. package/packages/mcp-tools/src/update.ts +30 -15
  103. package/packages/parsers/src/documents.ts +12 -1
  104. package/packages/prompts/src/humanize.ts +46 -0
  105. package/packages/prompts/src/index.ts +5 -1
  106. package/packages/prompts/src/install.ts +4 -4
  107. package/packages/prompts/src/onboarding.ts +4 -4
  108. package/packages/prompts/src/system.ts +9 -7
  109. package/packages/shared/src/enums.ts +14 -4
  110. package/packages/shared/src/schemas.ts +41 -0
  111. package/packages/workflows/src/definitions.ts +45 -8
  112. package/scripts/init.ts +284 -13
  113. package/dist/site/analytics.js +0 -97
  114. package/dist/site/favicon.svg +0 -1
  115. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  116. package/dist/site/icon-180.png +0 -0
  117. package/dist/site/icon-512.png +0 -0
  118. package/dist/site/index.html +0 -465
  119. package/dist/site/landing.js +0 -163
  120. package/dist/site/llms-install.txt +0 -218
  121. package/dist/site/robots.txt +0 -4
  122. package/dist/site/sitemap.xml +0 -21
  123. package/dist/site/start.html +0 -356
  124. package/dist/site/style.css +0 -665
  125. package/dist/site/vercel.json +0 -20
@@ -4,18 +4,11 @@ import {
4
4
  el, get, post, icon, navigate, Card, Btn, IconBtn, Input,
5
5
  openModal, copyText, downloadUrl, fmtRelative, toastOk, toastError, mount,
6
6
  } from '/lib.js';
7
+ import { t, getLang, setLang, onLangChange } from '/i18n.js';
7
8
 
8
- const COUNT_LABELS = {
9
- profile: '프로필', experiences: '경력', projects: '프로젝트', skills: '기술',
10
- documents: '문서', cover_letters: '자기소개서', jobs: '채용공고',
11
- applications: '지원', interview_preps: '면접 준비',
12
- };
13
- const BACKUP_COUNT_LABELS = {
14
- ...COUNT_LABELS,
15
- cover_letter_versions: '자소서 버전',
16
- fit_analyses: '적합도 분석',
17
- activities: '활동',
18
- };
9
+ // Data keys whose human labels come from t('settings.count.<key>').
10
+ const COUNT_KEYS = ['profile', 'experiences', 'projects', 'skills', 'documents', 'cover_letters', 'jobs', 'applications', 'interview_preps'];
11
+ const countLabel = (key) => t('settings.count.' + key);
19
12
 
20
13
  export async function render(ctx) {
21
14
  ctx.setActions([]);
@@ -25,9 +18,11 @@ export async function render(ctx) {
25
18
  DataLocation(info),
26
19
  Connection(info),
27
20
  Theme(),
21
+ LanguageSwitcher(),
28
22
  VerifyMode(info),
29
23
  MyData(info.counts),
30
24
  Backup(backups, async () => { await ctx.refreshNav(); await render(ctx); }),
25
+ Support(),
31
26
  DangerZone(ctx),
32
27
  );
33
28
 
@@ -42,53 +37,57 @@ function kvRow(label, value, copyable) {
42
37
  el('div', { class: 'flex between gap-3' },
43
38
  el('span', { class: 'truncate tnum', style: { fontFamily: 'var(--mono, ui-monospace, monospace)' }, title: value }, value || '—'),
44
39
  copyable && value
45
- ? IconBtn('copy', { title: '복사', onClick: () => copyText(value) })
40
+ ? IconBtn('copy', { title: t('settings.copy'), onClick: () => copyText(value) })
46
41
  : null)),
47
42
  ];
48
43
  }
49
44
 
50
45
  function DataLocation(info) {
51
46
  const kv = el('dl', { class: 'kv' },
52
- ...kvRow('데이터 폴더', info.data_dir, true),
53
- ...kvRow('데이터베이스', info.db_path, true),
54
- ...kvRow('앱 버전', info.version, false),
55
- ...kvRow('Node 버전', info.node_version, false),
47
+ ...kvRow(t('settings.dataLocation.dataDir'), info.data_dir, true),
48
+ ...kvRow(t('settings.dataLocation.database'), info.db_path, true),
49
+ ...kvRow(t('settings.dataLocation.appVersion'), info.version, false),
50
+ ...kvRow(t('settings.dataLocation.nodeVersion'), info.node_version, false),
56
51
  );
57
52
 
58
53
  const privacy = el('div', { class: 'callout callout--privacy' },
59
54
  icon('lock'),
60
55
  el('div', {},
61
- el('div', { class: 'callout__title' }, '내 컴퓨터에만 저장됩니다'),
56
+ el('div', { class: 'callout__title' }, t('settings.dataLocation.privacyTitle')),
62
57
  el('div', { class: 'callout__body' },
63
- '모든 데이터는 이 컴퓨터에만 저장됩니다. 외부로 전송되지 않습니다. AI에는 사용자가 직접 입력하거나 AI가 MCP로 조회한 정보만 전달됩니다.')));
58
+ t('settings.dataLocation.privacyBody'))));
64
59
 
65
60
  return Card({
66
- title: '데이터 저장 위치',
61
+ title: t('settings.dataLocation.title'),
67
62
  body: el('div', { class: 'stack-3' }, kv, privacy),
68
63
  });
69
64
  }
70
65
 
71
66
  /* ---------------------------------------------------------- 연결 상태 */
72
67
  function Connection(info) {
68
+ const isDemo = !!info.demo;
69
+
73
70
  async function createShortcut() {
74
71
  try {
75
72
  const { shortcut } = await post('/api/settings/dashboard-shortcut', { open: true });
76
- toastOk('대시보드 바로가기를 만들었어요.');
73
+ toastOk(isDemo ? t('settings.connection.shortcutDemoToast') : t('settings.connection.shortcutToast'));
77
74
  openModal({
78
- title: '바로가기 생성',
75
+ title: isDemo ? t('settings.connection.shortcutDemoTitle') : t('settings.connection.shortcutTitle'),
79
76
  body: el('div', { class: 'stack-3' },
80
77
  el('div', { class: 'callout' },
81
78
  icon('info'),
82
79
  el('div', {},
83
- el('div', { class: 'callout__title' }, '폴더를 만들었습니다'),
80
+ el('div', { class: 'callout__title' }, isDemo ? t('settings.connection.shortcutDemoCalloutTitle') : t('settings.connection.shortcutCalloutTitle')),
84
81
  el('div', { class: 'callout__body' },
85
- '대시보드가 실행 중이면 브라우저만 열고, 꺼져 있으면 서버를 시작한 뒤 브라우저를 엽니다.'))),
82
+ isDemo
83
+ ? t('settings.connection.shortcutDemoCalloutBody')
84
+ : t('settings.connection.shortcutCalloutBody')))),
86
85
  el('dl', { class: 'kv' },
87
- ...kvRow('폴더', shortcut.shortcut_dir, true),
88
- ...kvRow('바로가기', shortcut.launcher_path, true))),
86
+ ...kvRow(t('settings.connection.shortcutFolder'), shortcut.shortcut_dir, true),
87
+ ...kvRow(t('settings.connection.shortcutLauncher'), shortcut.launcher_path, true))),
89
88
  footer: (close) => [
90
- Btn('경로 복사', { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
91
- Btn('확인', { variant: 'primary', onClick: close }),
89
+ Btn(t('settings.copyPath'), { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
90
+ Btn(t('settings.confirm'), { variant: 'primary', onClick: close }),
92
91
  ],
93
92
  });
94
93
  } catch (e) {
@@ -98,23 +97,23 @@ function Connection(info) {
98
97
 
99
98
  const dashRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0' } },
100
99
  el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'center' } },
101
- el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), '실행 중'),
100
+ el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), t('settings.connection.running')),
102
101
  el('div', {},
103
- el('div', { class: 'strong' }, '대시보드'),
102
+ el('div', { class: 'strong' }, t('settings.connection.dashboard')),
104
103
  el('div', { class: 'muted text-sm tnum' }, info.url || '—'))),
105
- Btn('바로가기 만들기', { icon: 'copy', sm: true, variant: 'ghost', onClick: createShortcut }));
104
+ Btn(t('settings.connection.createShortcut'), { icon: 'copy', sm: true, variant: 'ghost', onClick: createShortcut }));
106
105
 
107
106
  const mcpRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
108
107
  el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'flex-start' } },
109
108
  el('div', { class: 'empty__icon', style: { width: '30px', height: '30px', margin: 0, borderRadius: '8px' } }, icon('link')),
110
109
  el('div', {},
111
- el('div', { class: 'strong' }, 'MCP 서버'),
110
+ el('div', { class: 'strong' }, t('settings.connection.mcpServer')),
112
111
  el('div', { class: 'muted text-sm', style: { maxWidth: '440px', lineHeight: '1.6' } },
113
- 'MCP 서버는 사용하시는 AI 클라이언트(Claude·ChatGPT·Cursor)가 직접 실행하며, 이 대시보드와 같은 데이터베이스를 공유합니다.'))),
114
- Btn('설치·연결 안내 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open('/install', '_blank') }));
112
+ t('settings.connection.mcpBody')))),
113
+ Btn(t('settings.connection.openInstall'), { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open(isDemo ? '/start.html' : '/install', '_blank') }));
115
114
 
116
115
  return Card({
117
- title: '연결 상태',
116
+ title: t('settings.connection.title'),
118
117
  body: el('div', { class: 'stack-2' }, dashRow, el('div', { class: 'divider' }), mcpRow),
119
118
  });
120
119
  }
@@ -128,9 +127,9 @@ function currentTheme() {
128
127
 
129
128
  function Theme() {
130
129
  const options = [
131
- { value: 'light', label: '라이트' },
132
- { value: 'dark', label: '다크' },
133
- { value: 'system', label: '시스템' },
130
+ { value: 'light', key: 'settings.theme.light' },
131
+ { value: 'dark', key: 'settings.theme.dark' },
132
+ { value: 'system', key: 'settings.theme.system' },
134
133
  ];
135
134
 
136
135
  const row = el('div', { class: 'flex gap-2 wrap' });
@@ -151,7 +150,7 @@ function Theme() {
151
150
  function paint() {
152
151
  const active = currentTheme();
153
152
  mount(row, ...options.map((o) => {
154
- const b = Btn(o.label, { variant: 'ghost', onClick: () => applyTheme(o.value) });
153
+ const b = Btn(t(o.key), { variant: 'ghost', onClick: () => applyTheme(o.value) });
155
154
  b.setAttribute('aria-pressed', String(o.value === active));
156
155
  if (o.value === active) b.classList.add('is-selected');
157
156
  return b;
@@ -160,8 +159,37 @@ function Theme() {
160
159
  paint();
161
160
 
162
161
  return Card({
163
- title: '테마',
164
- sub: '시스템 설정을 따르거나 직접 고를 수 있어요',
162
+ title: t('settings.theme.title'),
163
+ sub: t('settings.theme.sub'),
164
+ body: row,
165
+ });
166
+ }
167
+
168
+ /* ------------------------------------------------------------- 언어 / Language */
169
+ function LanguageSwitcher() {
170
+ // 테마 컴포넌트와 동일 패턴: localStorage 영속 + paint() + 변경 시 재페인트.
171
+ // 카탈로그가 채워지는 Phase 2부터 페이지 텍스트가 실제로 전환된다.
172
+ const options = [
173
+ { value: 'ko', label: '한국어' },
174
+ { value: 'en', label: 'English' },
175
+ ];
176
+ const row = el('div', { class: 'flex gap-2 wrap' });
177
+
178
+ function paint() {
179
+ const active = getLang();
180
+ mount(row, ...options.map((o) => {
181
+ const b = Btn(o.label, { variant: 'ghost', onClick: () => setLang(o.value) });
182
+ b.setAttribute('aria-pressed', String(o.value === active));
183
+ if (o.value === active) b.classList.add('is-selected');
184
+ return b;
185
+ }));
186
+ }
187
+ paint();
188
+ onLangChange(paint);
189
+
190
+ return Card({
191
+ title: '언어 / Language',
192
+ sub: '대시보드 표시 언어',
165
193
  body: row,
166
194
  });
167
195
  }
@@ -169,8 +197,8 @@ function Theme() {
169
197
  /* ------------------------------------------------- 자소서 수치 점검 모드 */
170
198
  function VerifyMode(info) {
171
199
  const options = [
172
- { value: false, label: '기본', desc: '저장된 경력·이력서·프로젝트에 아예 근거가 없는 수치만 차단' },
173
- { value: true, label: '엄격', desc: '이력서 본문에 없고 구조화 경력/프로젝트 항목에만 있는 수치까지 차단' },
200
+ { value: false, labelKey: 'settings.verify.basic', descKey: 'settings.verify.basicDesc' },
201
+ { value: true, labelKey: 'settings.verify.strict', descKey: 'settings.verify.strictDesc' },
174
202
  ];
175
203
  let current = !!info.verify_strict;
176
204
  const row = el('div', { class: 'flex gap-2 wrap' });
@@ -180,7 +208,7 @@ function VerifyMode(info) {
180
208
  try {
181
209
  const res = await post('/api/settings/verify-mode', { strict: value });
182
210
  current = !!res.verify_strict;
183
- toastOk(current ? '엄격 모드를 켰어요.' : '엄격 모드를 껐어요.');
211
+ toastOk(current ? t('settings.verify.strictOnToast') : t('settings.verify.strictOffToast'));
184
212
  paint();
185
213
  } catch (e) {
186
214
  toastError(e);
@@ -189,7 +217,7 @@ function VerifyMode(info) {
189
217
 
190
218
  function paint() {
191
219
  mount(row, ...options.map((o) => {
192
- const b = Btn(o.label, { variant: 'ghost', onClick: () => set(o.value) });
220
+ const b = Btn(t(o.labelKey), { variant: 'ghost', onClick: () => set(o.value) });
193
221
  b.setAttribute('aria-pressed', String(o.value === current));
194
222
  if (o.value === current) b.classList.add('is-selected');
195
223
  return b;
@@ -198,27 +226,27 @@ function VerifyMode(info) {
198
226
  paint();
199
227
 
200
228
  const note = el('div', { class: 'muted text-sm', style: { lineHeight: '1.7', marginTop: '10px' } },
201
- el('div', {}, el('span', { class: 'strong' }, '기본'), ' — ', options[0].desc),
202
- el('div', {}, el('span', { class: 'strong' }, '엄격'), ' — ', options[1].desc),
229
+ el('div', {}, el('span', { class: 'strong' }, t('settings.verify.basic')), ' — ', t('settings.verify.basicDesc')),
230
+ el('div', {}, el('span', { class: 'strong' }, t('settings.verify.strict')), ' — ', t('settings.verify.strictDesc')),
203
231
  el('div', { style: { marginTop: '6px' } },
204
- '엄격 모드에선 올린 이력서에 없는 수치가 막힐 수 있어요. 막히면 이력서를 올리거나 모드를 끄세요. AI에게 "엄격하게 봐줘"라고 해도 켤 수 있습니다.'));
232
+ t('settings.verify.note')));
205
233
 
206
234
  return Card({
207
- title: '자소서 수치 점검',
208
- sub: 'AI가 쓴 수치가 내 실제 데이터에 근거가 있는지 저장 전에 자동으로 확인합니다',
235
+ title: t('settings.verify.title'),
236
+ sub: t('settings.verify.sub'),
209
237
  body: el('div', { class: 'stack-2' }, row, note),
210
238
  });
211
239
  }
212
240
 
213
241
  /* ---------------------------------------------------------- 내 데이터 */
214
242
  function MyData(counts = {}) {
215
- const items = Object.entries(COUNT_LABELS).map(([key, label]) =>
243
+ const items = COUNT_KEYS.map((key) =>
216
244
  el('div', { class: 'countstrip__item' },
217
245
  el('div', { class: 'countstrip__num tnum' }, String(counts[key] ?? 0)),
218
- el('div', { class: 'countstrip__label' }, label)));
246
+ el('div', { class: 'countstrip__label' }, countLabel(key))));
219
247
  return Card({
220
- title: '내 데이터',
221
- sub: '이 컴퓨터에 저장된 항목 수',
248
+ title: t('settings.myData.title'),
249
+ sub: t('settings.myData.sub'),
222
250
  body: el('div', { class: 'countstrip' }, ...items),
223
251
  });
224
252
  }
@@ -246,7 +274,7 @@ function Backup(backups = [], rerender) {
246
274
  async function createBackup() {
247
275
  try {
248
276
  const res = await post('/api/settings/backup');
249
- toastOk('백업을 생성했어요.');
277
+ toastOk(t('settings.backup.createToast'));
250
278
  await rerender();
251
279
  } catch (e) {
252
280
  toastError(e);
@@ -256,19 +284,19 @@ function Backup(backups = [], rerender) {
256
284
  const list = backups.length
257
285
  ? el('table', { class: 'table' },
258
286
  el('thead', {}, el('tr', {},
259
- el('th', {}, '파일'), el('th', {}, '생성'), el('th', { style: { textAlign: 'right' } }, '크기'))),
287
+ el('th', {}, t('settings.backup.colFile')), el('th', {}, t('settings.backup.colCreated')), el('th', { style: { textAlign: 'right' } }, t('settings.backup.colSize')))),
260
288
  el('tbody', {}, ...backups.map((b) => el('tr', {},
261
289
  el('td', {}, el('span', { class: 'truncate', title: b.path || b.filename }, b.filename)),
262
290
  el('td', { class: 'muted text-sm' }, fmtRelative(b.created_at)),
263
- el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } }, `${Math.round((b.size || 0) / 1024)} KB`)))))
264
- : el('p', { class: 'muted', style: { margin: 0 } }, '아직 로컬 백업이 없습니다. "현재 상태 백업"으로 지금 데이터를 보관하세요.');
291
+ el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } }, t('settings.backup.size', { kb: Math.round((b.size || 0) / 1024) }))))))
292
+ : el('p', { class: 'muted', style: { margin: 0 } }, t('settings.backup.empty'));
265
293
 
266
294
  return Card({
267
- title: '내보내기 / 가져오기',
295
+ title: t('settings.backup.title'),
268
296
  actions: [
269
- Btn('내보내기(JSON)', { icon: 'download', sm: true, variant: 'primary', onClick: () => downloadUrl('/api/settings/export-all') }),
270
- Btn('가져오기(JSON)', { icon: 'upload', sm: true, variant: 'ghost', onClick: () => fileInput.click() }),
271
- Btn('현재 상태 백업', { icon: 'copy', sm: true, variant: 'ghost', onClick: createBackup }),
297
+ Btn(t('settings.backup.export'), { icon: 'download', sm: true, variant: 'primary', onClick: () => downloadUrl('/api/settings/export-all') }),
298
+ Btn(t('settings.backup.import'), { icon: 'upload', sm: true, variant: 'ghost', onClick: () => fileInput.click() }),
299
+ Btn(t('settings.backup.create'), { icon: 'copy', sm: true, variant: 'ghost', onClick: createBackup }),
272
300
  ],
273
301
  body: el('div', { class: 'stack-3' }, fileInput, list),
274
302
  });
@@ -277,12 +305,12 @@ function Backup(backups = [], rerender) {
277
305
  function readBackupFile(file) {
278
306
  return new Promise((resolve, reject) => {
279
307
  const reader = new FileReader();
280
- reader.onerror = () => reject(new Error('백업 파일을 읽지 못했어요.'));
308
+ reader.onerror = () => reject(new Error(t('settings.backup.readError')));
281
309
  reader.onload = () => {
282
310
  try {
283
311
  resolve(JSON.parse(String(reader.result || '')));
284
312
  } catch {
285
- reject(new Error('JSON 백업 파일만 복원할 수 있어요.'));
313
+ reject(new Error(t('settings.backup.parseError')));
286
314
  }
287
315
  };
288
316
  reader.readAsText(file);
@@ -290,66 +318,73 @@ function readBackupFile(file) {
290
318
  }
291
319
 
292
320
  function fmtBackupDate(iso) {
293
- if (!iso) return '알 수 없음';
321
+ if (!iso) return t('settings.backup.unknownDate');
294
322
  const d = new Date(iso);
295
323
  if (Number.isNaN(d.getTime())) return iso;
296
- return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
324
+ return new Intl.DateTimeFormat(getLang(), { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(d);
325
+ }
326
+
327
+ // Resolve a backup-count data key to its human label (falls back to the raw key).
328
+ function backupCountLabel(key) {
329
+ const label = t('settings.count.' + key);
330
+ return label === 'settings.count.' + key ? key : label;
297
331
  }
298
332
 
299
333
  function previewRows(counts = {}) {
300
334
  const entries = Object.entries(counts)
301
335
  .filter(([, count]) => Number(count) > 0)
302
- .sort(([a], [b]) => (BACKUP_COUNT_LABELS[a] || a).localeCompare(BACKUP_COUNT_LABELS[b] || b, 'ko'));
336
+ .sort(([a], [b]) => backupCountLabel(a).localeCompare(backupCountLabel(b), getLang()));
303
337
 
304
338
  if (!entries.length) {
305
- return el('p', { class: 'muted', style: { margin: 0 } }, '복원할 항목이 없습니다.');
339
+ return el('p', { class: 'muted', style: { margin: 0 } }, t('settings.preview.empty'));
306
340
  }
307
341
 
308
342
  return el('table', { class: 'table' },
309
- el('thead', {}, el('tr', {}, el('th', {}, '데이터'), el('th', { style: { textAlign: 'right' } }, '항목'))),
343
+ el('thead', {}, el('tr', {}, el('th', {}, t('settings.preview.colData')), el('th', { style: { textAlign: 'right' } }, t('settings.preview.colCount')))),
310
344
  el('tbody', {}, ...entries.map(([key, count]) => el('tr', {},
311
- el('td', {}, BACKUP_COUNT_LABELS[key] || key),
345
+ el('td', {}, backupCountLabel(key)),
312
346
  el('td', { class: 'tnum', style: { textAlign: 'right' } }, String(count))))));
313
347
  }
314
348
 
315
349
  function openImportModal({ fileName, backup, preview, rerender }) {
316
350
  let confirmBtn;
351
+ const gateWord = t('settings.import.gateWord');
317
352
  const input = Input({
318
- placeholder: '가져오기',
319
- onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== '가져오기'; },
353
+ placeholder: gateWord,
354
+ onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== gateWord; },
320
355
  onKeydown: (e) => {
321
- if (e.key === 'Enter' && e.target.value.trim() === '가져오기' && confirmBtn && !confirmBtn.disabled) confirmBtn.click();
356
+ if (e.key === 'Enter' && e.target.value.trim() === gateWord && confirmBtn && !confirmBtn.disabled) confirmBtn.click();
322
357
  },
323
358
  });
324
359
 
325
360
  openModal({
326
- title: '데이터 가져오기',
361
+ title: t('settings.import.title'),
327
362
  size: 'lg',
328
363
  body: el('div', { class: 'stack-3' },
329
364
  el('div', { class: 'callout' },
330
365
  icon('info'),
331
366
  el('div', {},
332
- el('div', { class: 'callout__title' }, '현재 데이터는 먼저 백업됩니다'),
367
+ el('div', { class: 'callout__title' }, t('settings.import.calloutTitle')),
333
368
  el('div', { class: 'callout__body' },
334
- '가져오면 이 컴퓨터의 CareerMate 데이터가 선택한 JSON 내용으로 전체 교체됩니다.'))),
369
+ t('settings.import.calloutBody')))),
335
370
  el('dl', { class: 'kv' },
336
- ...kvRow('파일', fileName, false),
337
- ...kvRow('백업 시각', fmtBackupDate(preview.exported_at), false),
338
- ...kvRow('스키마', `${preview.version} / 현재 ${preview.current_version}`, false),
339
- ...kvRow('전체 항목', String(preview.total_rows || 0), false)),
371
+ ...kvRow(t('settings.import.file'), fileName, false),
372
+ ...kvRow(t('settings.import.exportedAt'), fmtBackupDate(preview.exported_at), false),
373
+ ...kvRow(t('settings.import.schema'), t('settings.import.schemaValue', { version: preview.version, current: preview.current_version }), false),
374
+ ...kvRow(t('settings.import.totalRows'), String(preview.total_rows || 0), false)),
340
375
  previewRows(preview.counts),
341
376
  preview.warnings?.length
342
377
  ? el('div', { class: 'callout' },
343
378
  icon('info'),
344
379
  el('div', {},
345
- el('div', { class: 'callout__title' }, '확인 필요'),
380
+ el('div', { class: 'callout__title' }, t('settings.import.warningsTitle')),
346
381
  el('div', { class: 'callout__body' }, preview.warnings.join(' '))))
347
382
  : null,
348
383
  el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
349
- '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '가져오기'), ' 를 입력하세요.'),
384
+ t('settings.import.instruction', { word: gateWord })),
350
385
  input),
351
386
  footer: (close) => {
352
- confirmBtn = Btn('가져오기', {
387
+ confirmBtn = Btn(t('settings.import.confirm'), {
353
388
  icon: 'upload',
354
389
  variant: 'danger',
355
390
  disabled: true,
@@ -358,45 +393,62 @@ function openImportModal({ fileName, backup, preview, rerender }) {
358
393
  try {
359
394
  await post('/api/settings/restore', { backup, confirm: 'RESTORE' });
360
395
  close();
361
- toastOk('데이터를 가져왔어요.');
396
+ toastOk(t('settings.import.doneToast'));
362
397
  await rerender();
363
398
  } catch (e) {
364
399
  toastError(e);
365
- confirmBtn.disabled = input.value.trim() !== '가져오기';
400
+ confirmBtn.disabled = input.value.trim() !== gateWord;
366
401
  }
367
402
  },
368
403
  });
369
- return [Btn('취소', { onClick: close }), confirmBtn];
404
+ return [Btn(t('settings.cancel'), { onClick: close }), confirmBtn];
370
405
  },
371
406
  });
372
407
  }
373
408
 
374
409
  /* -------------------------------------------------- 위험 구역 (Danger zone) */
410
+ /* ----------------------------------------------------- 도움말 · 문제 신고 */
411
+ function Support() {
412
+ const ext = (href, iconName, label) =>
413
+ el('a', { class: 'btn btn--ghost btn--sm', href, attrs: { target: '_blank', rel: 'noopener noreferrer' } },
414
+ icon(iconName), el('span', {}, label));
415
+ return Card({
416
+ title: t('settings.support.title'),
417
+ sub: t('settings.support.sub'),
418
+ body: el('div', { class: 'flex gap-2 wrap' },
419
+ ext('https://github.com/osntak/careermate/issues/new?template=bug_report.yml', 'external', t('settings.support.report')),
420
+ ext('https://github.com/osntak/careermate/discussions', 'external', t('settings.support.ask')),
421
+ el('a', { class: 'btn btn--ghost btn--sm', href: 'mailto:osntak@gmail.com' }, icon('mail'), el('span', {}, t('settings.support.email'))),
422
+ ),
423
+ });
424
+ }
425
+
375
426
  function DangerZone(ctx) {
376
427
  function openResetModal() {
377
428
  let confirmBtn;
378
- // Gate on the Korean word so there's no IME switch and it matches the '초기화' button.
429
+ // Gate on the locale-resolved word so there's no IME switch and it matches the button.
379
430
  // (The API still expects the fixed 'DELETE' token; we send that on the wire below.)
431
+ const gateWord = t('settings.reset.gateWord');
380
432
  const input = Input({
381
- placeholder: '초기화',
382
- onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== '초기화'; },
383
- onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() === '초기화' && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
433
+ placeholder: gateWord,
434
+ onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== gateWord; },
435
+ onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() === gateWord && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
384
436
  });
385
437
 
386
438
  openModal({
387
- title: '모든 데이터 초기화',
439
+ title: t('settings.reset.title'),
388
440
  body: el('div', { class: 'stack-3' },
389
441
  el('div', { class: 'callout' },
390
442
  icon('info'),
391
443
  el('div', {},
392
- el('div', { class: 'callout__title' }, '되돌릴 수 없습니다'),
444
+ el('div', { class: 'callout__title' }, t('settings.reset.calloutTitle')),
393
445
  el('div', { class: 'callout__body' },
394
- '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성됩니다.'))),
446
+ t('settings.reset.calloutBody')))),
395
447
  el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
396
- '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '초기화'), ' 를 입력하세요.'),
448
+ t('settings.reset.instruction', { word: gateWord })),
397
449
  input),
398
450
  footer: (close) => {
399
- confirmBtn = Btn('모든 데이터 초기화', {
451
+ confirmBtn = Btn(t('settings.reset.confirm'), {
400
452
  variant: 'danger',
401
453
  disabled: true,
402
454
  onClick: async () => {
@@ -404,28 +456,28 @@ function DangerZone(ctx) {
404
456
  try {
405
457
  await post('/api/settings/reset', { confirm: 'DELETE' });
406
458
  close();
407
- toastOk('모든 데이터를 초기화했어요.');
459
+ toastOk(t('settings.reset.doneToast'));
408
460
  navigate('/');
409
461
  await ctx.refreshNav();
410
462
  } catch (e) {
411
463
  toastError(e);
412
- confirmBtn.disabled = input.value.trim() !== 'DELETE';
464
+ confirmBtn.disabled = input.value.trim() !== gateWord;
413
465
  }
414
466
  },
415
467
  });
416
- return [Btn('취소', { onClick: close }), confirmBtn];
468
+ return [Btn(t('settings.cancel'), { onClick: close }), confirmBtn];
417
469
  },
418
470
  });
419
471
  }
420
472
 
421
473
  return Card({
422
- title: '위험 구역',
423
- sub: '이 작업은 되돌릴 수 없습니다',
474
+ title: t('settings.danger.title'),
475
+ sub: t('settings.danger.sub'),
424
476
  body: el('div', { class: 'flex between gap-3 wrap' },
425
477
  el('div', { style: { maxWidth: '440px' } },
426
- el('div', { class: 'strong' }, '모든 데이터 초기화'),
478
+ el('div', { class: 'strong' }, t('settings.danger.resetTitle')),
427
479
  el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } },
428
- '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동으로 백업이 생성됩니다.')),
429
- Btn('모든 데이터 초기화', { icon: 'trash', variant: 'danger', onClick: openResetModal })),
480
+ t('settings.danger.resetBody'))),
481
+ Btn(t('settings.danger.resetTitle'), { icon: 'trash', variant: 'danger', onClick: openResetModal })),
430
482
  });
431
483
  }
@@ -61,7 +61,7 @@
61
61
  --maxw: 1080px;
62
62
 
63
63
  --font: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
64
- 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
64
+ 'Segoe UI', system-ui, 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
65
65
  --mono: 'SFMono-Regular', 'JetBrains Mono', Consolas, 'D2Coding', monospace;
66
66
  }
67
67
 
@@ -317,6 +317,33 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
317
317
  .tl-item__line { width: 2px; flex: 1; background: var(--border); margin: 4px 0; }
318
318
  .tl-item:last-child .tl-item__line { display: none; }
319
319
  .tl-item__body { padding-bottom: 16px; }
320
+ .timeline--job .tl-item__dot { margin-top: 7px; }
321
+ .timeline-event__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; }
322
+ .timeline-event__title { font-weight: 650; font-size: 14px; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
323
+ .timeline-event__summary { margin-top: 3px; color: var(--text-secondary); font-size: 13.5px; line-height: 1.5; }
324
+ .timeline-event__extra { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
325
+ .timeline-event__meta { color: var(--text-tertiary); font-size: 13px; }
326
+ .timeline-docs { display: flex; flex-direction: column; gap: 4px; }
327
+ .timeline-ref { display: flex; align-items: center; gap: 7px; min-width: 0; font-size: 13.5px; }
328
+ .timeline-ref__label { flex: none; color: var(--text-tertiary); }
329
+ .timeline-ref__link {
330
+ appearance: none; border: 0; background: transparent; color: var(--text); padding: 1px 4px;
331
+ border-radius: 4px; font: inherit; font-weight: 600; cursor: pointer; min-width: 0;
332
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
333
+ }
334
+ .timeline-ref__link:hover { background: var(--surface-hover); text-decoration: underline; }
335
+ .timeline-ref__missing { color: var(--text-tertiary); font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
336
+ .timeline-ref__deleted { flex: none; padding: 1px 6px; border-radius: 999px; background: var(--bg-sunken); color: var(--text-tertiary); font-size: 11.5px; font-weight: 650; }
337
+ .pick-list { display: flex; flex-direction: column; gap: 6px; }
338
+ .pick-row {
339
+ display: flex; align-items: flex-start; gap: 10px; padding: 9px 10px; border: 1px solid var(--border);
340
+ border-radius: var(--radius-sm); cursor: pointer; background: var(--surface);
341
+ }
342
+ .pick-row:hover { background: var(--surface-hover); border-color: var(--border-strong); }
343
+ .pick-row input { margin-top: 3px; flex: none; }
344
+ .pick-row__main { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
345
+ .pick-row__title { font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
346
+ .pick-row__sub { color: var(--text-tertiary); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
320
347
 
321
348
  /* ----------------------------------------------------------------- kanban */
322
349
  .board { display: grid; grid-auto-flow: column; grid-auto-columns: 272px; gap: 14px; overflow-x: auto; padding-bottom: 8px; }