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
@@ -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
  }
@@ -278,7 +264,7 @@ function editWriting(p, reload) {
278
264
  /* ---------------------------------------------------- 4. 경력 (Experiences) */
279
265
 
280
266
  function ExperiencesCard(items, reload) {
281
- const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editExperience(null, reload) });
267
+ const add = Btn('경력 추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editExperience(null, reload) });
282
268
 
283
269
  if (!items.length) {
284
270
  return Card({
@@ -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
  }
@@ -384,7 +368,7 @@ function editExperience(x, reload) {
384
368
  /* ----------------------------------------------------- 5. 프로젝트 (Projects) */
385
369
 
386
370
  function ProjectsCard(items, reload) {
387
- const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editProject(null, reload) });
371
+ const add = Btn('프로젝트 추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editProject(null, reload) });
388
372
 
389
373
  if (!items.length) {
390
374
  return Card({
@@ -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
- const add = Btn('추가', { icon: 'plus', sm: true, variant: 'ghost', onClick: () => editSkill(null, reload) });
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
  }
@@ -6,7 +6,7 @@ import {
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
  }
@@ -150,7 +153,7 @@ function Backup(backups = [], rerender) {
150
153
  async function createBackup() {
151
154
  try {
152
155
  const res = await post('/api/settings/backup');
153
- toastOk('백업을 생성했습니다.');
156
+ toastOk('백업을 생성했어요.');
154
157
  await rerender();
155
158
  } catch (e) {
156
159
  toastError(e);
@@ -181,10 +184,12 @@ function Backup(backups = [], rerender) {
181
184
  function DangerZone(ctx) {
182
185
  function openResetModal() {
183
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.)
184
189
  const input = Input({
185
- placeholder: 'DELETE',
186
- onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== 'DELETE'; },
187
- 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(); },
188
193
  });
189
194
 
190
195
  openModal({
@@ -197,7 +202,7 @@ function DangerZone(ctx) {
197
202
  el('div', { class: 'callout__body' },
198
203
  '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성됩니다.'))),
199
204
  el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
200
- '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, 'DELETE'), ' 를 입력하세요.'),
205
+ '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '초기화'), ' 를 입력하세요.'),
201
206
  input),
202
207
  footer: (close) => {
203
208
  confirmBtn = Btn('모든 데이터 초기화', {
@@ -208,7 +213,7 @@ function DangerZone(ctx) {
208
213
  try {
209
214
  await post('/api/settings/reset', { confirm: 'DELETE' });
210
215
  close();
211
- toastOk('모든 데이터가 초기화되었습니다.');
216
+ toastOk('모든 데이터를 초기화했어요.');
212
217
  navigate('/');
213
218
  await ctx.refreshNav();
214
219
  } catch (e) {
@@ -155,6 +155,13 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
155
155
  .topbar__title { font-size: 15px; font-weight: 650; }
156
156
  .topbar__crumb { color: var(--text-tertiary); }
157
157
  .topbar__spacer { flex: 1; }
158
+ /* mobile nav: hamburger (hidden ≥900px) + off-canvas drawer scrim */
159
+ .nav-toggle { display: none; width: 34px; height: 34px; flex: none; place-items: center;
160
+ border: 1px solid var(--border); background: var(--surface); border-radius: var(--radius-sm);
161
+ color: var(--text-secondary); cursor: pointer; margin-right: 4px; }
162
+ .nav-toggle:hover { background: var(--surface-hover); color: var(--text); }
163
+ .nav-toggle svg { width: 20px; height: 20px; }
164
+ .nav-scrim { display: none; position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,.4); }
158
165
  .view { padding: 24px; max-width: var(--maxw); width: 100%; margin: 0 auto; }
159
166
  .view--wide { max-width: 1280px; }
160
167
 
@@ -168,7 +175,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
168
175
  .grid--2 { grid-template-columns: repeat(2, 1fr); }
169
176
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
170
177
  .grid--4 { grid-template-columns: repeat(4, 1fr); }
171
- @media (max-width: 900px){ .grid--2,.grid--3,.grid--4{ grid-template-columns: 1fr; } .app{ grid-template-columns: 1fr; } .sidebar{ display:none; } }
178
+ @media (max-width: 900px){
179
+ .grid--2,.grid--3,.grid--4{ grid-template-columns: 1fr; }
180
+ .app{ grid-template-columns: 1fr; }
181
+ /* sidebar slides in as an off-canvas drawer; the topbar hamburger toggles it */
182
+ .sidebar{ position: fixed; top: 0; left: 0; bottom: 0; height: 100%; z-index: 50;
183
+ transform: translateX(-100%); transition: transform .22s ease; box-shadow: var(--shadow-lg); }
184
+ .app.nav-open .sidebar{ transform: none; }
185
+ .app.nav-open .nav-scrim{ display: block; }
186
+ .nav-toggle{ display: inline-grid; }
187
+ }
172
188
 
173
189
  /* ----------------------------------------------------------------- card */
174
190
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
@@ -229,7 +245,14 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
229
245
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
230
246
  padding: 8px 11px; transition: border-color .12s, box-shadow .12s;
231
247
  }
232
- .input:focus, .textarea:focus, .select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
248
+ .input:focus, .textarea:focus, .select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
249
+ /* keyboard focus must be clearly visible (the soft ring alone fails WCAG 1.4.11/2.4.11) */
250
+ .input:focus-visible, .textarea:focus-visible, .select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; box-shadow: none; }
251
+ /* skip link — hidden until focused, then drops into the top-left */
252
+ .skip-link { position: fixed; top: 8px; left: 8px; z-index: 200; transform: translateY(-200%);
253
+ background: var(--accent); color: var(--text-on-accent); padding: 8px 14px; border: 0;
254
+ border-radius: var(--radius-sm); font: inherit; font-weight: 600; cursor: pointer; transition: transform .15s; }
255
+ .skip-link:focus { transform: none; outline: 2px solid var(--text-on-accent); outline-offset: 2px; }
233
256
  .textarea { resize: vertical; min-height: 90px; line-height: 1.6; }
234
257
  .textarea--lg { min-height: 280px; font-size: 15px; }
235
258
  .input::placeholder, .textarea::placeholder { color: var(--text-tertiary); }
@@ -268,6 +291,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
268
291
  .table tbody tr.is-clickable:hover { background: var(--surface-hover); }
269
292
  .table .muted { color: var(--text-tertiary); }
270
293
  .table .strong { font-weight: 600; }
294
+ /* keyboard-reachable lead-cell link that looks identical to plain text */
295
+ .cell-link { color: inherit; text-decoration: none; }
296
+ .cell-link:hover { text-decoration: none; }
271
297
 
272
298
  /* ----------------------------------------------------------------- progress */
273
299
  .progress { height: 7px; background: var(--bg-sunken); border-radius: 999px; overflow: hidden; }
@@ -377,7 +403,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
377
403
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px 26px; max-height: 60vh; overflow-y: auto; }
378
404
  .kv { display: grid; grid-template-columns: 132px 1fr; gap: 8px 16px; }
379
405
  .kv dt { color: var(--text-tertiary); font-size: 13px; }
380
- .kv dd { margin: 0; }
406
+ /* min-width:0 overrides a grid item's default min-width:auto so the 1fr column
407
+ can shrink below its content — otherwise a long path/value overflows the grid. */
408
+ .kv dd { margin: 0; min-width: 0; }
381
409
 
382
410
  /* ----------------------------------------------------------------- modal */
383
411
  .modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
@@ -390,6 +418,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
390
418
  .modal__head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
391
419
  .modal__head h3 { font-size: 15px; }
392
420
  .modal__body { padding: 20px; overflow-y: auto; }
421
+ .modal__error { margin: 0 20px 4px; padding: 10px 12px; border-radius: var(--radius); border: 1px solid var(--red-border);
422
+ background: var(--red-soft); color: var(--red); font-size: 13px; line-height: 1.5; }
423
+ .modal__error[hidden] { display: none; }
393
424
  .modal__foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
394
425
  @keyframes pop { from { transform: translate(-50%,-46%); opacity: 0; } to { transform: translate(-50%,-50%); opacity: 1; } }
395
426
  @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@@ -421,11 +452,15 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
421
452
  .between { justify-content: space-between; } .center { align-items: center; } .wrap { flex-wrap: wrap; }
422
453
  .ml-auto { margin-left: auto; } .mt-2{margin-top:8px;} .mt-3{margin-top:12px;} .mt-4{margin-top:16px;} .mb-2{margin-bottom:8px;} .mb-3{margin-bottom:12px;} .mb-4{margin-bottom:16px;}
423
454
  .tnum { font-variant-numeric: tabular-nums; }
424
- .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
455
+ /* min-width:0 lets a truncating element actually shrink inside a flex row;
456
+ without it a long unbreakable value (e.g. a data-folder path) forces the
457
+ flex item to its content width and overflows the viewport on narrow screens. */
458
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; min-width: 0; }
425
459
  .divider { height: 1px; background: var(--border); margin: 16px 0; }
426
460
  .skeleton { background: linear-gradient(90deg, var(--bg-sunken) 25%, var(--surface-hover) 37%, var(--bg-sunken) 63%); background-size: 400% 100%; animation: shimmer 1.4s infinite; border-radius: 6px; }
427
461
  @keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
428
462
  .hide { display: none !important; }
463
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
429
464
 
430
465
  /* score color helper */
431
466
  .score-strong { color: var(--green); } .score-mid { color: var(--amber); } .score-weak { color: var(--red); }
@@ -437,3 +472,27 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
437
472
  .callout--privacy svg { color: var(--text-tertiary); }
438
473
  .callout__title { font-weight: 600; margin-bottom: 2px; }
439
474
  .callout__body { color: var(--text-secondary); font-size: 13px; }
475
+
476
+ /* ----------------------------------------------------------------- phone layout */
477
+ /* Placed last so these win over the base widths (media queries add no specificity). */
478
+ @media (max-width: 640px){
479
+ .topbar{ padding: 0 12px; gap: 8px; }
480
+ /* title takes the room between hamburger and actions and truncates instead of
481
+ collapsing to vertical text; the flex spacer isn't needed at this width */
482
+ .topbar__title{ flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
483
+ .topbar__spacer{ display: none; }
484
+ .input--inline{ width: 110px; max-width: 32vw; }
485
+ .view{ padding: 16px; }
486
+ /* jobs table: drop the secondary 위치·마감일 column so 회사·직무·적합도·상태 all fit */
487
+ .table--jobs th:nth-child(2), .table--jobs td:nth-child(2){ display: none; }
488
+ /* keep the 적합도 score ("88점") on one line in the narrow column */
489
+ .table--jobs th:nth-child(3), .table--jobs td:nth-child(3){ white-space: nowrap; }
490
+ .table--jobs td:nth-child(3) .tnum{ font-size: 13px; }
491
+ /* any remaining wide table can still scroll instead of crushing columns */
492
+ .table-scroll{ overflow-x: auto; -webkit-overflow-scrolling: touch; }
493
+ /* stack key/value so long emails·paths·URLs aren't clipped by the fixed label column */
494
+ .kv{ grid-template-columns: 1fr; gap: 0; }
495
+ .kv dt{ margin-top: 10px; }
496
+ .kv dd{ margin: 0 0 2px; }
497
+ .page-head{ flex-wrap: wrap; }
498
+ }
@@ -107,8 +107,13 @@ export async function readJsonBody<T>(req: IncomingMessage, schema?: ZodType<T>)
107
107
  return schema.parse(parsed);
108
108
  } catch (e) {
109
109
  if (e instanceof ZodError) {
110
- const msg = e.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ');
111
- throw new HttpError(400, `입력값 검증 실패 — ${msg}`, 'validation');
110
+ // Messages already written as full Korean guidance are shown as-is (the zod
111
+ // path like "links.0.url" is developer jargon). Generic/English messages keep
112
+ // a field hint so the user knows which input to fix.
113
+ const msg = e.issues
114
+ .map((i) => (/[가-힣]/.test(i.message) ? i.message : `${i.path.join('.') || '입력값'}: ${i.message}`))
115
+ .join('\n');
116
+ throw new HttpError(400, msg, 'validation');
112
117
  }
113
118
  throw e;
114
119
  }
@@ -170,11 +175,33 @@ export function serveStatic(
170
175
  const mime = MIME[ext] ?? 'application/octet-stream';
171
176
  let payload: Buffer | string = fs.readFileSync(resolved);
172
177
  if (transform && (ext === '.html')) payload = transform(payload.toString('utf8'));
173
- res.writeHead(200, {
178
+ const headers: Record<string, string> = {
174
179
  'Content-Type': mime,
175
180
  'X-Content-Type-Options': 'nosniff',
176
181
  'Cache-Control': ext === '.html' ? 'no-store' : 'no-cache',
177
- });
182
+ };
183
+ if (ext === '.html') {
184
+ // Content-Security-Policy: with no inline scripts and only same-origin
185
+ // assets, any XSS that slips past escaping can't load external code or
186
+ // exfiltrate (connect-src 'self'). Dashboard pages (served with the token
187
+ // transform) hold the CSRF token and render user-controlled career data, so
188
+ // they get a strict no-inline-script policy. The install page (no transform)
189
+ // is static marketing with inline bootstrap scripts (theme/carousel), so it
190
+ // permits inline script but keeps every other hardening directive.
191
+ const scriptSrc = transform ? "script-src 'self'" : "script-src 'self' 'unsafe-inline'";
192
+ headers['Content-Security-Policy'] = [
193
+ "default-src 'none'",
194
+ scriptSrc,
195
+ "style-src 'self' 'unsafe-inline'", // inline style="" attrs + JS-set element styles
196
+ "img-src 'self' data:", // data: favicon + local screenshots/icons
197
+ "font-src 'self'",
198
+ "connect-src 'self'",
199
+ "base-uri 'none'",
200
+ "form-action 'none'",
201
+ "frame-ancestors 'none'", // clickjacking guard
202
+ ].join('; ');
203
+ }
204
+ res.writeHead(200, headers);
178
205
  res.end(payload);
179
206
  return true;
180
207
  }
@@ -10,11 +10,11 @@ import {
10
10
  getBackupsDir,
11
11
  getEntityCounts,
12
12
  } from '@careermate/db';
13
+ import { APP_VERSION } from '@careermate/shared';
13
14
 
14
15
  export const APP_NAME = 'CareerMate';
15
- // 번들 빌드(esbuild)에서 package.json 버전으로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
16
- declare const __APP_VERSION__: string | undefined;
17
- export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0-dev';
16
+ // 버전은 @careermate/shared 단일 출처에서. (번들에서 package.json 버전으로 치환)
17
+ export { APP_VERSION };
18
18
 
19
19
  let _port = 0;
20
20
  export function setServerPort(port: number): void {
@@ -22,6 +22,7 @@ import {
22
22
  APPLICATION_STATUS_LABELS,
23
23
  DOCUMENT_KIND_LABELS,
24
24
  APPLICATION_BOARD_ORDER,
25
+ INTERVIEW_UNLOCK_STATUSES,
25
26
  type DocumentKind,
26
27
  type JobInput,
27
28
  } from '@careermate/shared';
@@ -263,7 +264,7 @@ export function registerApiRoutes(router: Router): void {
263
264
  const preps = interviewRepo.list().map((p) => ({ ...p, job: jobRepo.get(p.job_id) }));
264
265
  const eligible = applicationRepo
265
266
  .list()
266
- .filter((a) => ['document_passed', 'interview', 'final_passed'].includes(a.status))
267
+ .filter((a) => INTERVIEW_UNLOCK_STATUSES.includes(a.status))
267
268
  .map((a) => ({
268
269
  job: jobRepo.get(a.job_id),
269
270
  status: a.status,
@@ -6,15 +6,12 @@ import http from 'node:http';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { getDb, writeRuntimeInfo, clearRuntimeInfo } from '@careermate/db';
9
+ import { BUNDLED } from '@careermate/shared';
9
10
  import { checkRequest, injectToken } from './security.ts';
10
11
  import { Router, sendJson, sendDownload, serveStatic, HttpError } from './http.ts';
11
12
  import { registerApiRoutes, isDownload } from './routes.ts';
12
13
  import { setServerPort } from './info.ts';
13
14
 
14
- // 번들 빌드(esbuild)에서 true로 치환된다. tsx 개발 실행에서는 정의되지 않아 typeof로 안전 분기.
15
- declare const __BUNDLED__: boolean | undefined;
16
- const BUNDLED = typeof __BUNDLED__ !== 'undefined' && __BUNDLED__;
17
-
18
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
16
  // dev(tsx): apps/web/src 기준 상대경로 · prod(dist/web.mjs): dist 기준.
20
17
  const WEB_ROOT = BUNDLED ? path.resolve(__dirname, 'public') : path.resolve(__dirname, '..', 'public');