careermate 0.1.1 → 0.1.3

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -10
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/apps/mcp/src/index.ts +4 -5
  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/index.html +10 -2
  10. package/apps/web/public/lib.js +134 -22
  11. package/apps/web/public/pages/applications.js +2 -9
  12. package/apps/web/public/pages/documents.js +73 -52
  13. package/apps/web/public/pages/home.js +16 -18
  14. package/apps/web/public/pages/interview.js +9 -12
  15. package/apps/web/public/pages/jobs.js +40 -41
  16. package/apps/web/public/pages/profile.js +69 -89
  17. package/apps/web/public/pages/settings.js +15 -10
  18. package/apps/web/public/styles.css +63 -4
  19. package/apps/web/src/http.ts +31 -4
  20. package/apps/web/src/info.ts +3 -3
  21. package/apps/web/src/routes.ts +2 -1
  22. package/apps/web/src/server.ts +1 -4
  23. package/dist/doctor.mjs +72715 -697
  24. package/dist/install-page/analytics.js +97 -0
  25. package/dist/install-page/index.html +94 -112
  26. package/dist/install-page/landing.js +54 -0
  27. package/dist/install-page/og.png +0 -0
  28. package/dist/install-page/robots.txt +4 -0
  29. package/dist/install-page/sitemap.xml +21 -0
  30. package/dist/install-page/start.html +26 -46
  31. package/dist/install-page/style.css +105 -37
  32. package/dist/mcp.mjs +74044 -2062
  33. package/dist/migrate.mjs +110 -90
  34. package/dist/pdf.worker.min.mjs +28 -0
  35. package/dist/public/app.js +34 -2
  36. package/dist/public/index.html +10 -2
  37. package/dist/public/lib.js +134 -22
  38. package/dist/public/pages/applications.js +2 -9
  39. package/dist/public/pages/documents.js +73 -52
  40. package/dist/public/pages/home.js +16 -18
  41. package/dist/public/pages/interview.js +9 -12
  42. package/dist/public/pages/jobs.js +40 -41
  43. package/dist/public/pages/profile.js +69 -89
  44. package/dist/public/pages/settings.js +15 -10
  45. package/dist/public/styles.css +63 -4
  46. package/dist/web.mjs +39740 -326
  47. package/package.json +6 -3
  48. package/packages/db/src/paths.ts +11 -0
  49. package/packages/db/src/repositories.ts +18 -1
  50. package/packages/mcp-tools/src/bridge.ts +55 -32
  51. package/packages/mcp-tools/src/index.ts +9 -0
  52. package/packages/mcp-tools/src/tools.ts +198 -75
  53. package/packages/mcp-tools/src/update.ts +330 -0
  54. package/packages/parsers/src/documents.ts +533 -0
  55. package/packages/parsers/src/index.ts +8 -5
  56. package/packages/prompts/src/onboarding.ts +2 -2
  57. package/packages/shared/src/build-info.ts +22 -0
  58. package/packages/shared/src/index.ts +1 -0
  59. package/packages/shared/src/schemas.ts +138 -83
  60. package/packages/workflows/src/definitions.ts +4 -2
  61. package/scripts/build-demo.mjs +130 -0
  62. package/scripts/build-dist.mjs +26 -1
  63. package/scripts/doctor.ts +16 -3
  64. package/scripts/gen-og.mjs +1 -1
  65. package/scripts/test.ts +25 -0
  66. package/scripts/ui-smoke.ts +32 -4
  67. package/docs/ARCHITECTURE.md +0 -208
  68. package/docs/CHANGES_V1.md +0 -103
  69. package/docs/DATA_MODEL.md +0 -460
  70. package/docs/DECISIONS.md +0 -277
  71. package/docs/DEMO.md +0 -242
  72. package/docs/MCP_TOOLS.md +0 -252
  73. package/docs/ROADMAP.md +0 -134
  74. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +0 -135
  75. package/docs/START_WORKFLOW.md +0 -131
  76. package/docs/SUPPORTED_AI_APPS.md +0 -112
  77. package/docs/TODO.md +0 -57
  78. package/docs/UX_NOTES.md +0 -247
  79. package/docs/WORKFLOWS.md +0 -200
@@ -2,16 +2,9 @@
2
2
  // (no horizontal scroll). Change a status inline to move an item between groups.
3
3
  import {
4
4
  el, get, put, navigate, EmptyState, Btn, ListRow,
5
- Select, scoreClass, mount, toast, toastOk, toastError, meta,
5
+ Select, scoreClass, statusColor, mount, toast, toastOk, toastError, meta,
6
6
  } from '/lib.js';
7
7
 
8
- // status code → theme colour (matches the badge--{status} hues)
9
- const STATUS_COLOR = {
10
- draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
11
- document_passed: 'var(--teal)', interview: 'var(--amber)',
12
- final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
13
- };
14
-
15
8
  // deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
16
9
  function ddayLabel(deadline) {
17
10
  if (!deadline) return null;
@@ -63,7 +56,7 @@ export async function render(ctx) {
63
56
  function StatusGroup(code, apps) {
64
57
  return el('div', {},
65
58
  el('div', { class: 'app-group__head' },
66
- el('span', { class: 'app-group__dot', style: { background: STATUS_COLOR[code] || 'var(--text-tertiary)' } }),
59
+ el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
67
60
  el('span', { class: 'app-group__title' }, labelByCode[code] || code),
68
61
  el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
69
62
  el('div', {}, ...apps.map(AppRow)));
@@ -1,7 +1,7 @@
1
1
  // Documents — cover letters (with version history) and résumés/other documents.
2
2
  // XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
3
3
  import {
4
- el, get, post, put, del, Card, Badge, Btn, IconBtn, EmptyState,
4
+ el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
5
5
  Field, Input, Textarea, Select, openModal, confirmDialog,
6
6
  toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
7
7
  } from '/lib.js';
@@ -9,7 +9,10 @@ import {
9
9
  const SOURCE_LABELS = { manual: '직접 입력', upload: '파일 업로드', ai: 'AI 생성', edit: '직접 수정' };
10
10
  const sourceLabel = (s) => SOURCE_LABELS[s] || s || '';
11
11
 
12
- let tab = 'cover'; // 'cover' | 'docs'
12
+ // Module-scoped on purpose: remember the last-viewed tab ('cover' | 'docs') across
13
+ // navigations so returning to Documents reopens where the user left off. The search
14
+ // query is intentionally NOT persisted (it resets per visit).
15
+ let tab = 'cover';
13
16
 
14
17
  export async function render(ctx) {
15
18
  const root = el('div', { class: 'stack-4' });
@@ -20,6 +23,7 @@ export async function render(ctx) {
20
23
  root.append(el('div', { class: 'tabs' }, tabCover, tabDocs), panel);
21
24
 
22
25
  let query = '';
26
+ let searchInput = null;
23
27
  function selectTab(next) {
24
28
  tab = next;
25
29
  query = '';
@@ -40,9 +44,12 @@ export async function render(ctx) {
40
44
  if (!hide) shown += 1;
41
45
  }
42
46
  const existing = panel.querySelector('.search-empty');
47
+ if (existing) existing.remove();
43
48
  if (q && cards.length && shown === 0) {
44
- if (!existing) panel.append(el('p', { class: 'search-empty muted', style: { textAlign: 'center', margin: '8px 0' } }, '검색 결과가 없어요.'));
45
- } else if (existing) existing.remove();
49
+ panel.append(el('div', { class: 'search-empty empty' },
50
+ el('p', { class: 'muted', style: { margin: '0 0 12px' } }, `‘${query.trim()}’에 맞는 항목이 없어요.`),
51
+ Btn('필터 초기화', { sm: true, variant: 'ghost', onClick: () => { query = ''; if (searchInput) searchInput.value = ''; applyFilter(); } })));
52
+ }
46
53
  }
47
54
 
48
55
  async function reload() {
@@ -67,6 +74,7 @@ export async function render(ctx) {
67
74
  function setActions() {
68
75
  const search = Input({ type: 'search', placeholder: tab === 'cover' ? '자기소개서 검색' : '문서 검색', value: query, attrs: { 'aria-label': '검색' } });
69
76
  search.classList.add('input--inline');
77
+ searchInput = search;
70
78
  search.addEventListener('input', () => { query = search.value; applyFilter(); });
71
79
  const createBtn = tab === 'cover'
72
80
  ? Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) })
@@ -101,7 +109,7 @@ async function CoverLettersTab(reload) {
101
109
  function CoverRow(cl, reload) {
102
110
  const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
103
111
  el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
104
- cl.is_primary ? Badge('final_passed', '대표') : null,
112
+ cl.is_primary ? Badge('accent', '대표') : null,
105
113
  );
106
114
 
107
115
  const actions = el('div', { class: 'flex gap-2 wrap' },
@@ -129,7 +137,7 @@ function CoverRow(cl, reload) {
129
137
  async function setPrimaryCover(id, reload) {
130
138
  try {
131
139
  await put(`/api/cover-letters/${id}/primary`, {});
132
- toastOk('대표 자기소개서로 지정했습니다.');
140
+ toastOk('대표 자기소개서로 지정했어요.');
133
141
  await reload();
134
142
  } catch (err) { toastError(err); }
135
143
  }
@@ -143,45 +151,56 @@ async function removeCover(cl, reload) {
143
151
  if (!ok) return;
144
152
  try {
145
153
  await del(`/api/cover-letters/${cl.id}`);
146
- toastOk('자기소개서를 삭제했습니다.');
154
+ toastOk('자기소개서를 삭제했어요.');
147
155
  await reload();
148
156
  } catch (err) { toastError(err); }
149
157
  }
150
158
 
151
- function openCoverCreate(reload) {
159
+ async function openCoverCreate(reload) {
160
+ // Let the user PICK the job to link instead of typing an internal ID they've never seen.
161
+ let jobs = [];
162
+ try { ({ jobs } = await get('/api/jobs')); } catch { /* dropdown falls back to "연결 안 함" */ }
152
163
  const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
153
164
  const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
154
- const jobId = Input({ placeholder: '연결할 공고 ID (선택)' });
165
+ const jobPick = Select([
166
+ { value: '', label: '연결 안 함', selected: true },
167
+ ...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
168
+ ]);
155
169
 
156
170
  openModal({
157
171
  title: '새 자기소개서',
158
172
  size: 'lg',
159
173
  body: el('div', { class: 'stack-3' },
160
174
  Field('제목 (필수)', title),
161
- Field('내용 (선택)', content, '내용을 입력하면 v1 버전으로 저장됩니다.'),
162
- Field('공고 ID (선택)', jobId, '특정 공고에 연결하려면 입력하세요.'),
175
+ Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
176
+ Field('연결할 공고 (선택)', jobPick,
177
+ jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
163
178
  ),
164
179
  footer: (close) => [
165
180
  Btn('취소', { onClick: close }),
166
- Btn('자기소개서 저장', { variant: 'primary', onClick: async () => {
181
+ SubmitBtn('자기소개서 저장', async () => {
167
182
  const t = title.value.trim();
168
- if (!t) { toastError(new Error('제목을 입력해 주세요.')); title.focus(); return; }
169
- try {
170
- const body = { title: t, source: 'manual' };
171
- if (content.value.trim()) body.content = content.value;
172
- if (jobId.value.trim()) body.job_id = jobId.value.trim();
173
- await post('/api/cover-letters', body);
174
- toastOk('자기소개서를 추가했습니다.');
175
- close();
176
- await reload();
177
- } catch (err) { toastError(err); }
178
- } }),
183
+ if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
184
+ const body = { title: t, source: 'manual' };
185
+ if (content.value.trim()) body.content = content.value;
186
+ if (jobPick.value) body.job_id = jobPick.value;
187
+ await post('/api/cover-letters', body);
188
+ toastOk('자기소개서를 추가했어요.');
189
+ close();
190
+ await reload();
191
+ }),
179
192
  ],
180
193
  });
181
194
  }
182
195
 
183
196
  async function openCoverDetail(id, reload) {
184
- let data = (await get(`/api/cover-letters/${id}`)).cover_letter;
197
+ let data;
198
+ try {
199
+ data = (await get(`/api/cover-letters/${id}`)).cover_letter;
200
+ } catch (err) {
201
+ toastError(err);
202
+ return;
203
+ }
185
204
 
186
205
  openModal({
187
206
  title: data.title,
@@ -210,7 +229,7 @@ function CoverDetailBody(cl, { refresh }) {
210
229
  wrap.append(el('div', { class: 'flex between wrap gap-2' },
211
230
  el('div', { class: 'flex gap-2 wrap' },
212
231
  el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
213
- cl.is_primary ? Badge('final_passed', '대표') : null,
232
+ cl.is_primary ? Badge('accent', '대표') : null,
214
233
  ),
215
234
  el('div', { class: 'flex gap-2 wrap' },
216
235
  Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
@@ -277,7 +296,7 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
277
296
  async function makeCurrent(id, versionId, refresh) {
278
297
  try {
279
298
  await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
280
- toastOk('현재 버전을 변경했습니다.');
299
+ toastOk('현재 버전을 변경했어요.');
281
300
  await refresh();
282
301
  } catch (err) { toastError(err); }
283
302
  }
@@ -292,16 +311,14 @@ function EditNewVersion(cl, refresh) {
292
311
  Field('내용', content),
293
312
  Field('변경 메모 (선택)', note),
294
313
  el('div', { class: 'flex gap-2' },
295
- Btn('새 버전으로 저장', { variant: 'primary', icon: 'check', onClick: async () => {
296
- if (!content.value.trim()) { toastError(new Error('내용을 입력해 주세요.')); content.focus(); return; }
297
- try {
298
- await post(`/api/cover-letters/${cl.id}/versions`, {
299
- content: content.value, note: note.value.trim() || undefined, source: 'edit',
300
- });
301
- toastOk('새 버전을 저장했습니다.');
302
- await refresh();
303
- } catch (err) { toastError(err); }
304
- } }),
314
+ SubmitBtn('새 버전으로 저장', async () => {
315
+ if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
316
+ await post(`/api/cover-letters/${cl.id}/versions`, {
317
+ content: content.value, note: note.value.trim() || undefined, source: 'edit',
318
+ });
319
+ toastOk('새 버전을 저장했어요.');
320
+ await refresh();
321
+ }, { icon: 'check' }),
305
322
  Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
306
323
  ),
307
324
  );
@@ -344,7 +361,7 @@ function DocRow(doc, kindLabels, m, reload) {
344
361
  el('div', { class: 'flex gap-2 wrap center' },
345
362
  el('span', { class: 'strong' }, doc.title),
346
363
  el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
347
- doc.is_primary ? Badge('final_passed', '대표') : null,
364
+ doc.is_primary ? Badge('accent', '대표') : null,
348
365
  ),
349
366
  el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
350
367
  );
@@ -363,7 +380,13 @@ function DocRow(doc, kindLabels, m, reload) {
363
380
  }
364
381
 
365
382
  async function openDocDetail(id, kindLabels, m, reload) {
366
- const doc = (await get(`/api/documents/${id}`)).document;
383
+ let doc;
384
+ try {
385
+ doc = (await get(`/api/documents/${id}`)).document;
386
+ } catch (err) {
387
+ toastError(err);
388
+ return;
389
+ }
367
390
 
368
391
  openModal({
369
392
  title: doc.title,
@@ -371,7 +394,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
371
394
  body: el('div', { class: 'stack-3' },
372
395
  el('div', { class: 'flex gap-2 wrap center' },
373
396
  el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
374
- doc.is_primary ? Badge('final_passed', '대표') : null,
397
+ doc.is_primary ? Badge('accent', '대표') : null,
375
398
  el('span', { class: 'chip' }, sourceLabel(doc.source)),
376
399
  el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
377
400
  ),
@@ -383,7 +406,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
383
406
  Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
384
407
  const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
385
408
  if (!ok) return;
386
- try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했습니다.'); close(); await reload(); }
409
+ try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
387
410
  catch (err) { toastError(err); }
388
411
  } }),
389
412
  Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
@@ -392,7 +415,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
392
415
  }
393
416
 
394
417
  function openDocCreate(reload) {
395
- meta().then((m) => docForm(null, m, reload));
418
+ meta().then((m) => docForm(null, m, reload)).catch(toastError);
396
419
  }
397
420
 
398
421
  function openDocEdit(doc, m, reload) {
@@ -418,19 +441,17 @@ function docForm(doc, m, reload) {
418
441
  ),
419
442
  footer: (close) => [
420
443
  Btn('취소', { onClick: close }),
421
- Btn('문서 저장', { variant: 'primary', onClick: async () => {
444
+ SubmitBtn('문서 저장', async () => {
422
445
  const t = title.value.trim();
423
- if (!t) { toastError(new Error('제목을 입력해 주세요.')); title.focus(); return; }
424
- if (!content.value.trim()) { toastError(new Error('내용을 입력해 주세요.')); content.focus(); return; }
446
+ if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
447
+ if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
425
448
  const body = { kind: kind.value, title: t, content: content.value, is_primary: primary.checked };
426
- try {
427
- if (doc) await put(`/api/documents/${doc.id}`, body);
428
- else await post('/api/documents', { ...body, source: 'manual' });
429
- toastOk(doc ? '문서를 수정했습니다.' : '문서를 추가했습니다.');
430
- close();
431
- await reload();
432
- } catch (err) { toastError(err); }
433
- } }),
449
+ if (doc) await put(`/api/documents/${doc.id}`, body);
450
+ else await post('/api/documents', { ...body, source: 'manual' });
451
+ toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
452
+ close();
453
+ await reload();
454
+ }),
434
455
  ],
435
456
  });
436
457
  }
@@ -4,17 +4,10 @@
4
4
  // the 시작하기 card. Every empty state is ONE screen-specific sentence; never repeat
5
5
  // the same "AI에게 보내면 저장됩니다" instruction across surfaces.
6
6
  import {
7
- el, get, icon, navigate, Card, Badge, Btn, EmptyState, ListRow, CheckRow,
8
- Stat, PageHead, fmtDate, fmtRelative, scoreClass, mount,
7
+ el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
8
+ Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
9
9
  } from '/lib.js';
10
10
 
11
- // status code → saturated theme colour. MUST track the badge--{status} hues so a
12
- // colour means the same stage in the pipeline bar, the badges, and the board.
13
- const STATUS_COLOR = {
14
- draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
15
- document_passed: 'var(--teal)', interview: 'var(--amber)',
16
- final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
17
- };
18
11
  const PIPE_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
19
12
 
20
13
  // activity type → icon + theme hue (soft tinted chip + coloured stroke)
@@ -66,7 +59,7 @@ function Greeting(name, s, firstRun) {
66
59
  let focus;
67
60
  if (firstRun) focus = '시작해 볼까요 — 먼저 기본 정보를 채워 주세요.';
68
61
  else if (s.interview_todo.length) focus = `면접 준비가 필요한 지원 ${s.interview_todo.length}건이 있어요.`;
69
- else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건을 확인하세요.`;
62
+ else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건이 있어요.`;
70
63
  else if (s.counts.jobs) focus = `저장된 공고 ${s.counts.jobs}건 · 새 적합도 분석을 받아보세요.`;
71
64
  else focus = '오늘도 한 걸음 나아가 볼까요.';
72
65
  return PageHead({ title: name ? `${name}님, 안녕하세요` : '환영합니다', desc: focus });
@@ -84,8 +77,17 @@ function GettingStarted(o) {
84
77
  title: '시작하기',
85
78
  sub: `프로필 완성도 ${o.profile_completeness}%`,
86
79
  body: [
80
+ // The one place (per DESIGN_GUIDE rule 4) that teaches the core mental model:
81
+ // CareerMate stores; your own AI does the thinking. Without this, first-run users
82
+ // read the empty dashboard as a manual CRUD form and never find the real workflow.
83
+ el('div', { class: 'callout mb-3' },
84
+ icon('info'),
85
+ el('div', {},
86
+ el('div', { class: 'callout__title' }, '두뇌는 당신의 AI, 보관은 CareerMate'),
87
+ el('div', { class: 'callout__body' },
88
+ '공고 분석·자기소개서·면접 준비는 평소 쓰던 AI에게 말로 시키면, 결과가 이 대시보드에 쌓여요.'))),
87
89
  el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
88
- el('p', { class: 'text-secondary text-sm', style: { margin: '12px 0 8px' } },
90
+ el('p', { class: 'text-secondary text-sm mt-3 mb-2' },
89
91
  '완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
90
92
  el('div', {}, ...steps.map((it) => CheckRow({ done: it.done, label: it.label, onClick: () => navigate(it.to) }))),
91
93
  ],
@@ -94,9 +96,8 @@ function GettingStarted(o) {
94
96
 
95
97
  /* ----------------------------------------------- KPI summary tiles (at-a-glance) */
96
98
  function StatTile(label, value, iconName, to) {
97
- const tile = Stat({ label, value, iconName });
99
+ const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
98
100
  tile.classList.add('is-clickable');
99
- tile.addEventListener('click', () => navigate(to));
100
101
  return tile;
101
102
  }
102
103
  function StatRow(counts) {
@@ -162,7 +163,7 @@ function Pipeline(breakdown) {
162
163
 
163
164
  /* ----------------------------------------------- action lane (what to do next) */
164
165
  function statusDot(status) {
165
- return el('span', { class: 'list-row__dot', style: { background: STATUS_COLOR[status] || 'var(--text-tertiary)' } });
166
+ return el('span', { class: 'list-row__dot', style: { background: statusColor(status) } });
166
167
  }
167
168
 
168
169
  function ActionLane(interviewTodo, inProgress) {
@@ -200,10 +201,7 @@ function RecentJobs(jobs) {
200
201
  if (!jobs.length) {
201
202
  return Card({
202
203
  title: '최근 공고',
203
- body: EmptyState({
204
- iconName: 'briefcase', title: '저장된 공고가 아직 없어요',
205
- action: Btn('공고 추가', { icon: 'plus', variant: 'ghost', onClick: () => navigate('/jobs') }),
206
- }),
204
+ body: el('p', { class: 'muted', style: { margin: 0 } }, '저장된 공고가 아직 없어요. 새 공고를 추가해 보세요.'),
207
205
  });
208
206
  }
209
207
  const rows = jobs.map((j) => ListRow({
@@ -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); }
@@ -362,11 +365,7 @@ function FitCard(fit) {
362
365
  if (!fit) {
363
366
  return Card({
364
367
  title: '적합도 분석',
365
- body: EmptyState({
366
- iconName: 'target',
367
- title: '적합도 분석 전이에요',
368
- body: '이 공고와 내 프로필을 비교한 분석 결과가 여기에 표시됩니다.',
369
- }),
368
+ body: el('p', { class: 'muted', style: { margin: 0 } }, '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.'),
370
369
  });
371
370
  }
372
371
 
@@ -436,7 +435,7 @@ function CoverLettersCard(coverLetters) {
436
435
  ...list.map((cl) => el('div', {
437
436
  class: 'flex between center is-clickable',
438
437
  style: { padding: '8px 0', cursor: 'pointer' },
439
- onClick: () => navigate('/documents'),
438
+ activate: () => navigate('/documents'),
440
439
  },
441
440
  el('div', { class: 'flex gap-2 center' },
442
441
  icon('file', 'muted'),
@@ -484,7 +483,7 @@ function RelatedCard(related) {
484
483
  ...list.map((r) => el('div', {
485
484
  class: 'flex between center is-clickable',
486
485
  style: { padding: '8px 0', cursor: 'pointer' },
487
- onClick: () => navigate(`/jobs/${r.id}`),
486
+ activate: () => navigate(`/jobs/${r.id}`),
488
487
  },
489
488
  el('div', {},
490
489
  el('div', { class: 'strong' }, r.position),