careermate 0.1.0 → 0.1.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 (134) hide show
  1. package/README.md +22 -17
  2. package/THIRD_PARTY_NOTICES.md +15 -0
  3. package/apps/mcp/src/index.ts +4 -3
  4. package/apps/web/DESIGN_GUIDE.md +2 -2
  5. package/apps/web/demo/demo-shim.js +536 -0
  6. package/apps/web/demo/demo.css +75 -0
  7. package/apps/web/demo/seed.js +269 -0
  8. package/apps/web/public/app.js +34 -2
  9. package/apps/web/public/icon-180.png +0 -0
  10. package/apps/web/public/index.html +14 -3
  11. package/apps/web/public/lib.js +135 -23
  12. package/apps/web/public/pages/applications.js +18 -9
  13. package/apps/web/public/pages/documents.js +66 -50
  14. package/apps/web/public/pages/home.js +12 -11
  15. package/apps/web/public/pages/interview.js +9 -12
  16. package/apps/web/public/pages/jobs.js +39 -36
  17. package/apps/web/public/pages/profile.js +66 -86
  18. package/apps/web/public/pages/settings.js +21 -14
  19. package/apps/web/public/styles.css +105 -38
  20. package/apps/web/src/http.ts +31 -4
  21. package/apps/web/src/info.ts +9 -22
  22. package/apps/web/src/routes.ts +19 -14
  23. package/apps/web/src/server.ts +34 -29
  24. package/apps/web/src/settings.ts +35 -17
  25. package/bin/careermate.mjs +8 -5
  26. package/dist/doctor.mjs +78908 -0
  27. package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
  28. package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
  29. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
  30. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
  31. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
  32. package/dist/install-page/favicon.svg +1 -0
  33. package/dist/install-page/icon-180.png +0 -0
  34. package/dist/install-page/icon-512.png +0 -0
  35. package/dist/install-page/index.html +328 -225
  36. package/dist/install-page/landing.js +54 -0
  37. package/dist/install-page/llms-install.txt +202 -0
  38. package/dist/install-page/og.png +0 -0
  39. package/dist/install-page/shots/applications-dark.png +0 -0
  40. package/dist/install-page/shots/applications-light.png +0 -0
  41. package/dist/install-page/shots/documents-dark.png +0 -0
  42. package/dist/install-page/shots/documents-light.png +0 -0
  43. package/dist/install-page/shots/home-dark.png +0 -0
  44. package/dist/install-page/shots/home-light.png +0 -0
  45. package/dist/install-page/shots/interview-dark.png +0 -0
  46. package/dist/install-page/shots/interview-light.png +0 -0
  47. package/dist/install-page/shots/jobs-dark.png +0 -0
  48. package/dist/install-page/shots/jobs-light.png +0 -0
  49. package/dist/install-page/shots/settings-dark.png +0 -0
  50. package/dist/install-page/shots/settings-light.png +0 -0
  51. package/dist/install-page/start.html +323 -0
  52. package/dist/install-page/style.css +128 -4
  53. package/dist/mcp.mjs +74225 -2007
  54. package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
  55. package/dist/pdf.worker.min.mjs +28 -0
  56. package/dist/public/app.js +34 -2
  57. package/dist/public/icon-180.png +0 -0
  58. package/dist/public/index.html +14 -3
  59. package/dist/public/lib.js +135 -23
  60. package/dist/public/pages/applications.js +18 -9
  61. package/dist/public/pages/documents.js +66 -50
  62. package/dist/public/pages/home.js +12 -11
  63. package/dist/public/pages/interview.js +9 -12
  64. package/dist/public/pages/jobs.js +39 -36
  65. package/dist/public/pages/profile.js +66 -86
  66. package/dist/public/pages/settings.js +21 -14
  67. package/dist/public/styles.css +105 -38
  68. package/dist/web.mjs +39950 -369
  69. package/docs/ARCHITECTURE.md +5 -5
  70. package/docs/DECISIONS.md +1 -1
  71. package/docs/DEMO.md +6 -6
  72. package/docs/MCP_TOOLS.md +68 -9
  73. package/docs/ROADMAP.md +11 -10
  74. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
  75. package/docs/START_WORKFLOW.md +19 -7
  76. package/docs/SUPPORTED_AI_APPS.md +85 -33
  77. package/docs/TODO.md +1 -1
  78. package/docs/WORKFLOWS.md +8 -6
  79. package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
  80. package/package.json +8 -3
  81. package/packages/core/src/services.ts +42 -1
  82. package/packages/db/src/connection.ts +30 -0
  83. package/packages/db/src/index.ts +1 -0
  84. package/packages/db/src/paths.ts +11 -0
  85. package/packages/db/src/repositories.ts +109 -63
  86. package/packages/db/src/schema.ts +118 -0
  87. package/packages/exporters/src/html.ts +9 -12
  88. package/packages/mcp-tools/src/bridge.ts +55 -32
  89. package/packages/mcp-tools/src/index.ts +9 -0
  90. package/packages/mcp-tools/src/tools.ts +269 -65
  91. package/packages/mcp-tools/src/update.ts +330 -0
  92. package/packages/parsers/src/documents.ts +521 -0
  93. package/packages/parsers/src/index.ts +8 -5
  94. package/packages/prompts/src/onboarding.ts +2 -2
  95. package/packages/shared/src/build-info.ts +22 -0
  96. package/packages/shared/src/enums.ts +39 -0
  97. package/packages/shared/src/index.ts +1 -0
  98. package/packages/shared/src/schemas.ts +24 -3
  99. package/packages/workflows/src/definitions.ts +4 -2
  100. package/scripts/build-demo.mjs +130 -0
  101. package/scripts/build-dist.mjs +46 -8
  102. package/scripts/build-mcpb.mjs +60 -4
  103. package/scripts/doctor.ts +16 -3
  104. package/scripts/gen-og.mjs +86 -0
  105. package/scripts/init.ts +32 -3
  106. package/scripts/seed.ts +5 -5
  107. package/scripts/shots-seed.ts +192 -0
  108. package/scripts/shots.ts +89 -0
  109. package/tsconfig.json +3 -1
  110. package/dist/careermate.mcpb +0 -0
  111. package/dist/mcp-smoke.err +0 -3
  112. package/dist/mcpb-stage/README.md +0 -219
  113. package/dist/mcpb-stage/dist/install-page/index.html +0 -434
  114. package/dist/mcpb-stage/dist/install-page/style.css +0 -407
  115. package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
  116. package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
  117. package/dist/mcpb-stage/dist/public/app.js +0 -118
  118. package/dist/mcpb-stage/dist/public/index.html +0 -41
  119. package/dist/mcpb-stage/dist/public/lib.js +0 -282
  120. package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
  121. package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
  122. package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
  123. package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
  124. package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
  125. package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
  126. package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
  127. package/dist/mcpb-stage/dist/public/styles.css +0 -420
  128. package/dist/mcpb-stage/manifest.json +0 -40
  129. package/docs/INSTALL.md +0 -148
  130. package/docs/INSTALL_AND_USAGE.md +0 -99
  131. package/install-page/index.html +0 -474
  132. package/install-page/style.css +0 -391
  133. package/install-page/vercel.json +0 -20
  134. /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
@@ -1,7 +1,7 @@
1
1
  // Interview — preparation materials for jobs that reached 서류 합격 or beyond.
2
2
  import {
3
- el, get, put, icon, navigate, Card, Badge, Btn, EmptyState, Field, Input,
4
- Textarea, openModal, closeModal, toastOk, toastError, copyText,
3
+ el, get, put, icon, navigate, Card, Badge, Btn, SubmitBtn, EmptyState, Field, Input,
4
+ Textarea, openModal, closeModal, toastOk, copyText,
5
5
  downloadUrl, mount,
6
6
  } from '/lib.js';
7
7
 
@@ -200,7 +200,7 @@ export async function render(ctx) {
200
200
  body: formBody,
201
201
  footer: (close) => [
202
202
  Btn('취소', { onClick: close }),
203
- Btn('면접 준비 저장', { variant: 'primary', onClick: () => save(jobId) }),
203
+ SubmitBtn('면접 준비 저장', () => save(jobId)),
204
204
  ],
205
205
  });
206
206
 
@@ -216,15 +216,12 @@ export async function render(ctx) {
216
216
  self_introduction: introInput.value,
217
217
  notes: notesInput.value,
218
218
  };
219
- try {
220
- await put(`/api/jobs/${id}/interview`, payload);
221
- closeModal();
222
- toastOk('면접 준비를 저장했어요.');
223
- await ctx.refreshNav();
224
- await render(ctx);
225
- } catch (err) {
226
- toastError(err);
227
- }
219
+ // Throws on failure so the SubmitBtn surfaces it inline.
220
+ await put(`/api/jobs/${id}/interview`, payload);
221
+ closeModal();
222
+ toastOk('면접 준비를 저장했어요.');
223
+ await ctx.refreshNav();
224
+ await render(ctx);
228
225
  }
229
226
  }
230
227
  }
@@ -3,10 +3,10 @@
3
3
  // cover letters, interview prep, and related history, all driven by the AI's
4
4
  // saved data with manual fallbacks throughout.
5
5
  import {
6
- el, get, post, put, del, icon, navigate, Card, Badge, Btn,
6
+ el, get, post, put, del, icon, navigate, Card, Badge, Btn, SubmitBtn,
7
7
  EmptyState, Chips, Field, Input, Textarea, Select,
8
8
  openModal, closeModal, confirmDialog, toastOk, toastError,
9
- fmtDate, scoreClass, mount, meta,
9
+ fmtDate, scoreClass, mount, meta, linesToArray, csvToArray,
10
10
  } from '/lib.js';
11
11
 
12
12
  export async function render(ctx) {
@@ -40,7 +40,9 @@ async function renderList(ctx) {
40
40
  const rowData = jobs.map((j) => ({
41
41
  tr: el('tr', { class: 'is-clickable', onClick: () => navigate(`/jobs/${j.id}`) },
42
42
  el('td', {},
43
- el('div', { class: 'strong' }, j.company),
43
+ // Real link in the lead cell so keyboard/AT users can reach the row (a
44
+ // role=button on <tr> would be invalid ARIA); the whole row stays mouse-clickable.
45
+ el('a', { class: 'cell-link strong', href: `#/jobs/${j.id}` }, j.company),
44
46
  el('div', { class: 'muted text-sm' }, j.position)),
45
47
  el('td', {},
46
48
  el('div', {}, j.location || el('span', { class: 'muted' }, '위치 미정')),
@@ -61,13 +63,13 @@ async function renderList(ctx) {
61
63
  wrap.append(Card({
62
64
  title: '저장된 공고',
63
65
  sub: `${jobs.length}개`,
64
- body: el('table', { class: 'table' },
66
+ body: el('div', { class: 'table-scroll' }, el('table', { class: 'table table--jobs' },
65
67
  el('thead', {}, el('tr', {},
66
68
  el('th', {}, '회사 · 직무'),
67
69
  el('th', {}, '위치 · 마감일'),
68
70
  el('th', { style: { textAlign: 'right' } }, '적합도'),
69
71
  el('th', {}, '상태'))),
70
- tbody),
72
+ tbody)),
71
73
  }));
72
74
 
73
75
  mount(ctx.view, wrap);
@@ -82,13 +84,6 @@ async function renderList(ctx) {
82
84
 
83
85
  /* ---------------------------------------------------------- add/edit modal */
84
86
 
85
- function linesToArray(str) {
86
- return (str || '').split('\n').map((s) => s.trim()).filter(Boolean);
87
- }
88
- function csvToArray(str) {
89
- return (str || '').split(',').map((s) => s.trim()).filter(Boolean);
90
- }
91
-
92
87
  /**
93
88
  * Build a job form (used for both create and edit). `job` is the existing
94
89
  * record when editing, or null when creating. `onSaved(result)` receives the
@@ -114,12 +109,17 @@ function jobForm(ctx, { job, onSaved }) {
114
109
  });
115
110
 
116
111
  // "붙여넣은 텍스트 정리" — server-side clean + best-effort field extraction.
112
+ // Deterministic text parsing — NOT AI (CareerMate has no LLM). Use a neutral icon and
113
+ // show "정리 중…" so a slow parse of a long posting doesn't look frozen.
117
114
  const tidyBtn = Btn('붙여넣은 텍스트 정리', {
118
- icon: 'sparkle', sm: true, variant: 'ghost',
115
+ icon: 'edit', sm: true, variant: 'ghost',
119
116
  onClick: async () => {
120
117
  const raw = description.value.trim();
121
118
  if (!raw) { toastError('먼저 공고 원문을 붙여넣어 주세요.'); return; }
119
+ const labelEl = tidyBtn.querySelector('span');
120
+ const origLabel = labelEl ? labelEl.textContent : '';
122
121
  tidyBtn.disabled = true;
122
+ if (labelEl) labelEl.textContent = '정리 중…';
123
123
  try {
124
124
  const r = await post('/api/parse/job', { raw });
125
125
  if (r.text) description.value = r.text;
@@ -133,11 +133,11 @@ function jobForm(ctx, { job, onSaved }) {
133
133
  }
134
134
  toastOk('붙여넣은 텍스트를 정리하고 일부 항목을 채웠어요.');
135
135
  } catch (e) { toastError(e); }
136
- finally { tidyBtn.disabled = false; }
136
+ finally { tidyBtn.disabled = false; if (labelEl) labelEl.textContent = origLabel; }
137
137
  },
138
138
  });
139
139
 
140
- const form = el('form', { class: 'stack-3', onSubmit: submit },
140
+ const form = el('form', { class: 'stack-3' },
141
141
  el('div', { class: 'grid grid--2' },
142
142
  Field('회사 *', company),
143
143
  Field('직무 *', position)),
@@ -155,8 +155,8 @@ function jobForm(ctx, { job, onSaved }) {
155
155
  Field('자격요건 · 우대사항', requirements, '한 줄에 하나씩'),
156
156
  );
157
157
 
158
- async function submit(e) {
159
- if (e) e.preventDefault();
158
+ // Throws on validation/API failure so the SubmitBtn shows it inline.
159
+ async function submit() {
160
160
  const body = {
161
161
  company: company.value.trim(),
162
162
  position: position.value.trim(),
@@ -169,21 +169,20 @@ function jobForm(ctx, { job, onSaved }) {
169
169
  keywords: csvToArray(keywords.value),
170
170
  requirements: linesToArray(requirements.value),
171
171
  };
172
- if (!body.company || !body.position) {
173
- toastError('회사와 직무는 필수입니다.');
174
- return;
175
- }
176
- try {
177
- const res = job
178
- ? await put(`/api/jobs/${job.id}`, body)
179
- : await post('/api/jobs', body);
180
- toastOk(job ? '공고를 수정했습니다.' : '공고를 저장했습니다.');
181
- closeModal();
182
- await onSaved(res);
183
- } catch (err) { toastError(err); }
172
+ if (!body.company || !body.position) throw new Error('회사와 직무는 필수입니다.');
173
+ const res = job
174
+ ? await put(`/api/jobs/${job.id}`, body)
175
+ : await post('/api/jobs', body);
176
+ toastOk(job ? '공고를 수정했어요.' : '공고를 저장했어요.');
177
+ closeModal();
178
+ await onSaved(res);
184
179
  }
185
180
 
186
- return { form, submit };
181
+ // Route the form's Enter-to-submit through the footer SubmitBtn so it shares the
182
+ // same in-flight disable + inline error handling.
183
+ const bindSubmit = (btn) => form.addEventListener('submit', (e) => { e.preventDefault(); btn.click(); });
184
+
185
+ return { form, submit, bindSubmit };
187
186
  }
188
187
 
189
188
  function openJobModal(ctx) {
@@ -194,13 +193,15 @@ function openJobModal(ctx) {
194
193
  navigate(`/jobs/${res.job.id}`);
195
194
  },
196
195
  });
196
+ const saveBtn = SubmitBtn('공고 저장', built.submit);
197
+ built.bindSubmit(saveBtn);
197
198
  openModal({
198
199
  title: '공고 추가',
199
200
  size: 'lg',
200
201
  body: built.form,
201
202
  footer: (close) => [
202
203
  Btn('취소', { onClick: close }),
203
- Btn('공고 저장', { variant: 'primary', onClick: built.submit }),
204
+ saveBtn,
204
205
  ],
205
206
  });
206
207
  }
@@ -210,13 +211,15 @@ function openEditModal(ctx, job, remount) {
210
211
  job,
211
212
  onSaved: async () => { await ctx.refreshNav(); await remount(); },
212
213
  });
214
+ const saveBtn = SubmitBtn('공고 저장', built.submit);
215
+ built.bindSubmit(saveBtn);
213
216
  openModal({
214
217
  title: '공고 수정',
215
218
  size: 'lg',
216
219
  body: built.form,
217
220
  footer: (close) => [
218
221
  Btn('취소', { onClick: close }),
219
- Btn('공고 저장', { variant: 'primary', onClick: built.submit }),
222
+ saveBtn,
220
223
  ],
221
224
  });
222
225
  }
@@ -255,7 +258,7 @@ async function renderDetail(ctx, jobId) {
255
258
  if (status === job.status) return;
256
259
  try {
257
260
  const res = await put(`/api/applications/${jobId}/status`, { status });
258
- toastOk('상태를 변경했습니다.');
261
+ toastOk('상태를 변경했어요.');
259
262
  if (res && res.hint) toastOk(res.hint);
260
263
  await ctx.refreshNav();
261
264
  await remount();
@@ -277,7 +280,7 @@ async function renderDetail(ctx, jobId) {
277
280
  if (!ok) return;
278
281
  try {
279
282
  await del(`/api/jobs/${jobId}`);
280
- toastOk('공고를 삭제했습니다.');
283
+ toastOk('공고를 삭제했어요.');
281
284
  await ctx.refreshNav();
282
285
  navigate('/jobs');
283
286
  } catch (err) { toastError(err); }
@@ -436,7 +439,7 @@ function CoverLettersCard(coverLetters) {
436
439
  ...list.map((cl) => el('div', {
437
440
  class: 'flex between center is-clickable',
438
441
  style: { padding: '8px 0', cursor: 'pointer' },
439
- onClick: () => navigate('/documents'),
442
+ activate: () => navigate('/documents'),
440
443
  },
441
444
  el('div', { class: 'flex gap-2 center' },
442
445
  icon('file', 'muted'),
@@ -484,7 +487,7 @@ function RelatedCard(related) {
484
487
  ...list.map((r) => el('div', {
485
488
  class: 'flex between center is-clickable',
486
489
  style: { padding: '8px 0', cursor: 'pointer' },
487
- onClick: () => navigate(`/jobs/${r.id}`),
490
+ activate: () => navigate(`/jobs/${r.id}`),
488
491
  },
489
492
  el('div', {},
490
493
  el('div', { class: 'strong' }, r.position),
@@ -1,20 +1,12 @@
1
1
  // Profile — the user's career identity, fully editable. Feeds AI writing/analysis.
2
2
  import {
3
- el, get, post, put, del, icon, Card, Badge, Btn, IconBtn, EmptyState, Chips,
3
+ el, get, post, put, del, icon, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState, Chips,
4
4
  Field, Input, Textarea, openModal, closeModal, confirmDialog,
5
- toastOk, toastError, fmtDate, mount,
5
+ toastOk, toastError, fmtDate, mount, linesToArray, csvToArray,
6
6
  } from '/lib.js';
7
7
 
8
8
  /* ----------------------------------------------------------- small helpers */
9
9
 
10
- // textarea (newline-separated) → trimmed array, empties dropped.
11
- function lines(str) {
12
- return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean);
13
- }
14
- // "a, b, c" → trimmed array, empties dropped.
15
- function commas(str) {
16
- return String(str || '').split(',').map((s) => s.trim()).filter(Boolean);
17
- }
18
10
  function val(node) { return node ? node.value : ''; }
19
11
 
20
12
  // Render a "YYYY-MM ~ YYYY-MM / 재직중" period label from a record.
@@ -146,24 +138,22 @@ function editBasic(p, reload) {
146
138
  ),
147
139
  footer: (close) => [
148
140
  Btn('취소', { onClick: close }),
149
- Btn('기본 정보 저장', { variant: 'primary', onClick: async () => {
150
- const links = lines(linksTa.value).map((line) => {
141
+ SubmitBtn('기본 정보 저장', async () => {
142
+ const links = linesToArray(linksTa.value).map((line) => {
151
143
  const i = line.indexOf('|');
152
144
  const label = (i >= 0 ? line.slice(0, i) : line).trim();
153
145
  const url = (i >= 0 ? line.slice(i + 1) : line).trim();
154
146
  return { label: label || url, url };
155
147
  }).filter((l) => l.url);
156
- try {
157
- await put('/api/profile', {
158
- name: val(name).trim(), email: val(email).trim(), phone: val(phone).trim(),
159
- location: val(location).trim(), headline: val(headline).trim(),
160
- summary: val(summary).trim(), links,
161
- });
162
- toastOk('기본 정보를 저장했어요.');
163
- close();
164
- await reload();
165
- } catch (e) { toastError(e); }
166
- } }),
148
+ await put('/api/profile', {
149
+ name: val(name).trim(), email: val(email).trim(), phone: val(phone).trim(),
150
+ location: val(location).trim(), headline: val(headline).trim(),
151
+ summary: val(summary).trim(), links,
152
+ });
153
+ toastOk('기본 정보를 저장했어요.');
154
+ close();
155
+ await reload();
156
+ }),
167
157
  ],
168
158
  });
169
159
  }
@@ -206,17 +196,15 @@ function editDesired(p, reload) {
206
196
  ),
207
197
  footer: (close) => [
208
198
  Btn('취소', { onClick: close }),
209
- Btn('희망 조건 저장', { variant: 'primary', onClick: async () => {
210
- try {
211
- await put('/api/profile', {
212
- desired_roles: commas(roles.value),
213
- desired_conditions: val(conditions).trim(),
214
- });
215
- toastOk('희망 조건을 저장했어요.');
216
- close();
217
- await reload();
218
- } catch (e) { toastError(e); }
219
- } }),
199
+ SubmitBtn('희망 조건 저장', async () => {
200
+ await put('/api/profile', {
201
+ desired_roles: csvToArray(roles.value),
202
+ desired_conditions: val(conditions).trim(),
203
+ });
204
+ toastOk('희망 조건을 저장했어요.');
205
+ close();
206
+ await reload();
207
+ }),
220
208
  ],
221
209
  });
222
210
  }
@@ -260,17 +248,15 @@ function editWriting(p, reload) {
260
248
  ),
261
249
  footer: (close) => [
262
250
  Btn('취소', { onClick: close }),
263
- Btn('설정 저장', { variant: 'primary', onClick: async () => {
264
- try {
265
- await put('/api/profile', {
266
- preferred_tone: val(tone).trim(),
267
- emphasis_points: lines(emphasis.value),
268
- });
269
- toastOk('자기소개서 설정을 저장했어요.');
270
- close();
271
- await reload();
272
- } catch (e) { toastError(e); }
273
- } }),
251
+ SubmitBtn('설정 저장', async () => {
252
+ await put('/api/profile', {
253
+ preferred_tone: val(tone).trim(),
254
+ emphasis_points: linesToArray(emphasis.value),
255
+ });
256
+ toastOk('자기소개서 설정을 저장했어요.');
257
+ close();
258
+ await reload();
259
+ }),
274
260
  ],
275
261
  });
276
262
  }
@@ -323,7 +309,7 @@ function ExperiencesCard(items, reload) {
323
309
  async function removeExperience(x, reload) {
324
310
  const ok = await confirmDialog({ title: '경력 삭제', message: `"${x.company}" 경력을 삭제할까요?`, confirmLabel: '삭제', danger: true });
325
311
  if (!ok) return;
326
- try { await del(`/api/experiences/${x.id}`); toastOk('삭제했어요.'); await reload(); }
312
+ try { await del(`/api/experiences/${x.id}`); toastOk('경력을 삭제했어요.'); await reload(); }
327
313
  catch (e) { toastError(e); }
328
314
  }
329
315
 
@@ -356,8 +342,8 @@ function editExperience(x, reload) {
356
342
  ),
357
343
  footer: (close) => [
358
344
  Btn('취소', { onClick: close }),
359
- Btn('경력 저장', { variant: 'primary', onClick: async () => {
360
- if (!val(company).trim()) { toastError(new Error('회사명을 입력해 주세요.')); return; }
345
+ SubmitBtn(x ? '경력 저장' : '경력 추가', async () => {
346
+ if (!val(company).trim()) throw new Error('회사명을 입력해 주세요.');
361
347
  const payload = {
362
348
  company: val(company).trim(),
363
349
  role: val(role).trim(),
@@ -366,17 +352,15 @@ function editExperience(x, reload) {
366
352
  end_date: isCurrent.checked ? '' : val(end).trim(),
367
353
  is_current: isCurrent.checked,
368
354
  description: val(desc).trim(),
369
- achievements: lines(achievements.value),
370
- tech: commas(tech.value),
355
+ achievements: linesToArray(achievements.value),
356
+ tech: csvToArray(tech.value),
371
357
  };
372
- try {
373
- if (x) await put(`/api/experiences/${x.id}`, payload);
374
- else await post('/api/experiences', payload);
375
- toastOk(x ? '경력을 수정했어요.' : '경력을 추가했어요.');
376
- close();
377
- await reload();
378
- } catch (e) { toastError(e); }
379
- } }),
358
+ if (x) await put(`/api/experiences/${x.id}`, payload);
359
+ else await post('/api/experiences', payload);
360
+ toastOk(x ? '경력을 수정했어요.' : '경력을 추가했어요.');
361
+ close();
362
+ await reload();
363
+ }),
380
364
  ],
381
365
  });
382
366
  }
@@ -432,7 +416,7 @@ function periodProject(x) {
432
416
  async function removeProject(x, reload) {
433
417
  const ok = await confirmDialog({ title: '프로젝트 삭제', message: `"${x.name}" 프로젝트를 삭제할까요?`, confirmLabel: '삭제', danger: true });
434
418
  if (!ok) return;
435
- try { await del(`/api/projects/${x.id}`); toastOk('삭제했어요.'); await reload(); }
419
+ try { await del(`/api/projects/${x.id}`); toastOk('프로젝트를 삭제했어요.'); await reload(); }
436
420
  catch (e) { toastError(e); }
437
421
  }
438
422
 
@@ -460,43 +444,41 @@ function editProject(x, reload) {
460
444
  ),
461
445
  footer: (close) => [
462
446
  Btn('취소', { onClick: close }),
463
- Btn('프로젝트 저장', { variant: 'primary', onClick: async () => {
464
- if (!val(name).trim()) { toastError(new Error('프로젝트명을 입력해 주세요.')); return; }
447
+ SubmitBtn(x ? '프로젝트 저장' : '프로젝트 추가', async () => {
448
+ if (!val(name).trim()) throw new Error('프로젝트명을 입력해 주세요.');
465
449
  const payload = {
466
450
  name: val(name).trim(),
467
451
  role: val(role).trim(),
468
452
  description: val(desc).trim(),
469
- highlights: lines(highlights.value),
470
- tech: commas(tech.value),
453
+ highlights: linesToArray(highlights.value),
454
+ tech: csvToArray(tech.value),
471
455
  url: val(url).trim(),
472
456
  start_date: val(start).trim(),
473
457
  end_date: val(end).trim(),
474
458
  };
475
- try {
476
- if (x) await put(`/api/projects/${x.id}`, payload);
477
- else await post('/api/projects', payload);
478
- toastOk(x ? '프로젝트를 수정했어요.' : '프로젝트를 추가했어요.');
479
- close();
480
- await reload();
481
- } catch (e) { toastError(e); }
482
- } }),
459
+ if (x) await put(`/api/projects/${x.id}`, payload);
460
+ else await post('/api/projects', payload);
461
+ toastOk(x ? '프로젝트를 수정했어요.' : '프로젝트를 추가했어요.');
462
+ close();
463
+ await reload();
464
+ }),
483
465
  ],
484
466
  });
485
467
  }
486
468
 
487
- /* -------------------------------------------------------- 6. 기술스택 (Skills) */
469
+ /* -------------------------------------------------------- 6. 기술 (Skills) */
488
470
 
489
471
  function SkillsCard(items, reload) {
490
472
  const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editSkill(null, reload) });
491
473
 
492
474
  if (!items.length) {
493
475
  return Card({
494
- title: '기술스택',
476
+ title: '기술',
495
477
  actions: add,
496
478
  body: EmptyState({
497
479
  iconName: 'sparkle',
498
480
  title: '등록된 기술이 없어요',
499
- body: '보유 기술스택을 정리해 두면 공고 키워드 매칭이 정확해집니다.',
481
+ body: '보유 기술을 정리해 두면 공고 키워드 매칭이 정확해집니다.',
500
482
  action: Btn('기술 추가', { icon: 'plus', variant: 'primary', onClick: () => editSkill(null, reload) }),
501
483
  }),
502
484
  });
@@ -524,7 +506,7 @@ function SkillsCard(items, reload) {
524
506
  IconBtn('trash', { title: '삭제', variant: 'danger', onClick: () => removeSkill(s, reload) })))))));
525
507
 
526
508
  return Card({
527
- title: '기술스택',
509
+ title: '기술',
528
510
  sub: `${items.length}개`,
529
511
  actions: add,
530
512
  body: el('div', { class: 'stack-3' }, ...blocks),
@@ -534,7 +516,7 @@ function SkillsCard(items, reload) {
534
516
  async function removeSkill(s, reload) {
535
517
  const ok = await confirmDialog({ title: '기술 삭제', message: `"${s.name}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
536
518
  if (!ok) return;
537
- try { await del(`/api/skills/${s.id}`); toastOk('삭제했어요.'); await reload(); }
519
+ try { await del(`/api/skills/${s.id}`); toastOk('기술을 삭제했어요.'); await reload(); }
538
520
  catch (e) { toastError(e); }
539
521
  }
540
522
 
@@ -554,8 +536,8 @@ function editSkill(s, reload) {
554
536
  ),
555
537
  footer: (close) => [
556
538
  Btn('취소', { onClick: close }),
557
- Btn('기술 저장', { variant: 'primary', onClick: async () => {
558
- if (!val(name).trim()) { toastError(new Error('기술명을 입력해 주세요.')); return; }
539
+ SubmitBtn(s ? '기술 저장' : '기술 추가', async () => {
540
+ if (!val(name).trim()) throw new Error('기술명을 입력해 주세요.');
559
541
  const yrs = val(years).trim();
560
542
  const payload = {
561
543
  name: val(name).trim(),
@@ -563,14 +545,12 @@ function editSkill(s, reload) {
563
545
  level: val(level).trim(),
564
546
  };
565
547
  if (yrs !== '' && !Number.isNaN(Number(yrs))) payload.years = Number(yrs);
566
- try {
567
- if (s) await put(`/api/skills/${s.id}`, payload);
568
- else await post('/api/skills', payload);
569
- toastOk(s ? '기술을 수정했어요.' : '기술을 추가했어요.');
570
- close();
571
- await reload();
572
- } catch (e) { toastError(e); }
573
- } }),
548
+ if (s) await put(`/api/skills/${s.id}`, payload);
549
+ else await post('/api/skills', payload);
550
+ toastOk(s ? '기술을 수정했어요.' : '기술을 추가했어요.');
551
+ close();
552
+ await reload();
553
+ }),
574
554
  ],
575
555
  });
576
556
  }
@@ -1,12 +1,12 @@
1
1
  // Settings — data location, connection, theme, backup/export, and danger zone.
2
2
  // Everything stays local; this page surfaces where data lives and how to manage it.
3
3
  import {
4
- el, get, post, icon, navigate, Card, Stat, Btn, IconBtn, Input,
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
7
 
8
8
  const COUNT_LABELS = {
9
- profile: '프로필', experiences: '경력', projects: '프로젝트', skills: '스킬',
9
+ profile: '프로필', experiences: '경력', projects: '프로젝트', skills: '기술',
10
10
  documents: '문서', cover_letters: '자기소개서', jobs: '채용공고',
11
11
  applications: '지원', interview_preps: '면접 준비',
12
12
  };
@@ -90,7 +90,8 @@ function Connection(info) {
90
90
 
91
91
  /* ------------------------------------------------------------- 테마 */
92
92
  function currentTheme() {
93
- const saved = localStorage.getItem('cf-theme');
93
+ // New key with legacy 'cf-theme' fallback so a saved theme survives the rename.
94
+ const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
94
95
  return saved === 'light' || saved === 'dark' ? saved : 'system';
95
96
  }
96
97
 
@@ -106,10 +107,12 @@ function Theme() {
106
107
  function applyTheme(value) {
107
108
  if (value === 'system') {
108
109
  delete document.documentElement.dataset.theme;
109
- localStorage.removeItem('cf-theme');
110
+ localStorage.removeItem('careermate-theme');
111
+ localStorage.removeItem('cf-theme'); // also clear legacy key
110
112
  } else {
111
113
  document.documentElement.dataset.theme = value;
112
- localStorage.setItem('cf-theme', value);
114
+ localStorage.setItem('careermate-theme', value);
115
+ localStorage.removeItem('cf-theme'); // migrate off legacy key
113
116
  }
114
117
  paint();
115
118
  }
@@ -134,12 +137,14 @@ function Theme() {
134
137
 
135
138
  /* ---------------------------------------------------------- 내 데이터 */
136
139
  function MyData(counts = {}) {
137
- const entries = Object.entries(COUNT_LABELS)
138
- .map(([key, label]) => Stat({ label, value: counts[key] ?? 0 }));
140
+ const items = Object.entries(COUNT_LABELS).map(([key, label]) =>
141
+ el('div', { class: 'countstrip__item' },
142
+ el('div', { class: 'countstrip__num tnum' }, String(counts[key] ?? 0)),
143
+ el('div', { class: 'countstrip__label' }, label)));
139
144
  return Card({
140
145
  title: '내 데이터',
141
146
  sub: '이 컴퓨터에 저장된 항목 수',
142
- body: el('div', { class: 'grid grid--3' }, ...entries),
147
+ body: el('div', { class: 'countstrip' }, ...items),
143
148
  });
144
149
  }
145
150
 
@@ -148,7 +153,7 @@ function Backup(backups = [], rerender) {
148
153
  async function createBackup() {
149
154
  try {
150
155
  const res = await post('/api/settings/backup');
151
- toastOk('백업을 생성했습니다.');
156
+ toastOk('백업을 생성했어요.');
152
157
  await rerender();
153
158
  } catch (e) {
154
159
  toastError(e);
@@ -179,10 +184,12 @@ function Backup(backups = [], rerender) {
179
184
  function DangerZone(ctx) {
180
185
  function openResetModal() {
181
186
  let confirmBtn;
187
+ // Gate on the Korean word so there's no IME switch and it matches the '초기화' button.
188
+ // (The API still expects the fixed 'DELETE' token; we send that on the wire below.)
182
189
  const input = Input({
183
- placeholder: 'DELETE',
184
- onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== 'DELETE'; },
185
- onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() === 'DELETE' && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
190
+ placeholder: '초기화',
191
+ onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== '초기화'; },
192
+ onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() === '초기화' && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
186
193
  });
187
194
 
188
195
  openModal({
@@ -195,7 +202,7 @@ function DangerZone(ctx) {
195
202
  el('div', { class: 'callout__body' },
196
203
  '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성됩니다.'))),
197
204
  el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
198
- '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, 'DELETE'), ' 를 입력하세요.'),
205
+ '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '초기화'), ' 를 입력하세요.'),
199
206
  input),
200
207
  footer: (close) => {
201
208
  confirmBtn = Btn('모든 데이터 초기화', {
@@ -206,7 +213,7 @@ function DangerZone(ctx) {
206
213
  try {
207
214
  await post('/api/settings/reset', { confirm: 'DELETE' });
208
215
  close();
209
- toastOk('모든 데이터가 초기화되었습니다.');
216
+ toastOk('모든 데이터를 초기화했어요.');
210
217
  navigate('/');
211
218
  await ctx.refreshNav();
212
219
  } catch (e) {