careermate 0.0.8 → 0.0.10

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 (68) hide show
  1. package/AGENTS.md +2 -2
  2. package/LICENSE +1 -1
  3. package/README.md +78 -81
  4. package/bin/careermate.mjs +11 -2
  5. package/dist/career-os/eop/cover-letter.md +10 -4
  6. package/dist/career-os/eop/interview-prep.md +1 -0
  7. package/dist/career-os/knowledge/cover-letter.md +4 -0
  8. package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
  9. package/dist/doctor.mjs +18138 -6866
  10. package/dist/mcp.mjs +14820 -4124
  11. package/dist/migrate.mjs +14436 -3712
  12. package/dist/pdf.worker.min.mjs +4 -4
  13. package/dist/public/app.js +10 -19
  14. package/dist/public/i18n/en/applications.js +19 -0
  15. package/dist/public/i18n/en/common.js +6 -0
  16. package/dist/public/i18n/en/documents.js +1 -0
  17. package/dist/public/i18n/en/home.js +8 -1
  18. package/dist/public/i18n/en/jobs.js +19 -0
  19. package/dist/public/i18n/en/nav.js +1 -0
  20. package/dist/public/i18n/en/settings.js +5 -2
  21. package/dist/public/i18n/ko/applications.js +19 -0
  22. package/dist/public/i18n/ko/common.js +5 -0
  23. package/dist/public/i18n/ko/documents.js +1 -0
  24. package/dist/public/i18n/ko/home.js +8 -1
  25. package/dist/public/i18n/ko/jobs.js +19 -0
  26. package/dist/public/i18n/ko/nav.js +1 -0
  27. package/dist/public/i18n/ko/settings.js +5 -2
  28. package/dist/public/lib.js +29 -0
  29. package/dist/public/pages/applications.js +86 -10
  30. package/dist/public/pages/documents.js +25 -12
  31. package/dist/public/pages/home.js +24 -4
  32. package/dist/public/pages/insights.js +156 -0
  33. package/dist/public/pages/interview.js +11 -4
  34. package/dist/public/pages/jobs.js +92 -4
  35. package/dist/public/pages/settings.js +2 -2
  36. package/dist/public/styles.css +16 -0
  37. package/dist/web.mjs +17079 -5651
  38. package/docs/FAQ.md +13 -3
  39. package/docs/LOCAL_FIRST.md +32 -0
  40. package/docs/MCP_TOOLS.md +22 -9
  41. package/docs/REVIEW_IMPLEMENTATION_PLAN.md +22 -0
  42. package/docs/career-os/eop/cover-letter.md +10 -4
  43. package/docs/career-os/eop/interview-prep.md +1 -0
  44. package/docs/career-os/knowledge/cover-letter.md +4 -0
  45. package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
  46. package/package.json +9 -5
  47. package/packages/core/src/data-health.ts +161 -0
  48. package/packages/core/src/fit-explain.ts +63 -0
  49. package/packages/core/src/index.ts +4 -0
  50. package/packages/core/src/jobsearch.ts +99 -54
  51. package/packages/core/src/offers.ts +41 -0
  52. package/packages/core/src/reuse-insights.ts +126 -0
  53. package/packages/core/src/services.ts +5 -1
  54. package/packages/core/src/summary.ts +245 -19
  55. package/packages/db/src/connection.ts +102 -14
  56. package/packages/db/src/index.ts +3 -0
  57. package/packages/db/src/interview-retrospectives.ts +149 -0
  58. package/packages/db/src/provenance.ts +125 -0
  59. package/packages/db/src/repositories.ts +140 -8
  60. package/packages/db/src/schema.ts +140 -8
  61. package/packages/mcp-tools/src/result.ts +9 -0
  62. package/packages/mcp-tools/src/tools.ts +143 -20
  63. package/packages/parsers/src/documents.ts +7 -1
  64. package/packages/prompts/src/humanize.ts +4 -0
  65. package/packages/prompts/src/system.ts +21 -64
  66. package/packages/shared/src/schemas.ts +134 -0
  67. package/scripts/init.ts +2 -0
  68. package/tsconfig.json +9 -10
@@ -1,9 +1,9 @@
1
1
  // Documents — cover letters (with version history), career descriptions, 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, SubmitBtn, EmptyState,
4
+ el, get, post, put, del, Card, Badge, Btn, SubmitBtn, EmptyState,
5
5
  Field, Input, Textarea, Select, openModal, confirmDialog,
6
- toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
6
+ toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu, Menu,
7
7
  } from '/lib.js';
8
8
  import { t } from '/i18n.js';
9
9
 
@@ -21,19 +21,28 @@ export async function render(ctx) {
21
21
  const root = el('div', { class: 'stack-4' });
22
22
 
23
23
  const panel = el('div', {});
24
- const tabCover = el('div', { class: `tab${tab === 'cover' ? ' is-active' : ''}`, onClick: () => selectTab('cover') }, t('documents.tab.cover'));
25
- const tabCareer = el('div', { class: `tab${tab === 'career' ? ' is-active' : ''}`, onClick: () => selectTab('career') }, t('documents.tab.career'));
26
- const tabDocs = el('div', { class: `tab${tab === 'docs' ? ' is-active' : ''}`, onClick: () => selectTab('docs') }, t('documents.tab.docs'));
27
- root.append(el('div', { class: 'tabs' }, tabCover, tabCareer, tabDocs), panel);
24
+ // Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
25
+ // semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
26
+ const makeTab = (key, label) =>
27
+ el('div', {
28
+ class: `tab${tab === key ? ' is-active' : ''}`,
29
+ attrs: { role: 'tab', 'aria-selected': String(tab === key) },
30
+ activate: () => selectTab(key),
31
+ }, label);
32
+ const tabCover = makeTab('cover', t('documents.tab.cover'));
33
+ const tabCareer = makeTab('career', t('documents.tab.career'));
34
+ const tabDocs = makeTab('docs', t('documents.tab.docs'));
35
+ root.append(el('div', { class: 'tabs', attrs: { role: 'tablist' } }, tabCover, tabCareer, tabDocs), panel);
28
36
 
29
37
  let query = '';
30
38
  let searchInput = null;
31
39
  function selectTab(next) {
32
40
  tab = next;
33
41
  query = '';
34
- tabCover.classList.toggle('is-active', tab === 'cover');
35
- tabCareer.classList.toggle('is-active', tab === 'career');
36
- tabDocs.classList.toggle('is-active', tab === 'docs');
42
+ for (const [node, key] of [[tabCover, 'cover'], [tabCareer, 'career'], [tabDocs, 'docs']]) {
43
+ node.classList.toggle('is-active', tab === key);
44
+ node.setAttribute('aria-selected', String(tab === key));
45
+ }
37
46
  setActions();
38
47
  renderPanel();
39
48
  }
@@ -135,12 +144,16 @@ function CoverRow(cl, reload) {
135
144
  cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
136
145
  );
137
146
 
147
+ // Two primary actions stay visible (열기·내보내기); the rest fold into an
148
+ // overflow menu so the row reads calmly instead of a wall of five buttons.
138
149
  const actions = el('div', { class: 'flex gap-2 wrap' },
139
150
  Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
140
- Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
141
151
  ExportMenu(`/api/export/cover-letter/${cl.id}`),
142
- cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
143
- IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
152
+ Menu(t('documents.cover.more'), [
153
+ cl.current_content ? { label: t('documents.cover.copy'), icon: 'copy', onClick: () => copyText(cl.current_content || '') } : null,
154
+ cl.is_primary ? null : { label: t('documents.cover.setPrimary'), icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) },
155
+ { label: t('documents.cover.delete'), icon: 'trash', danger: true, onClick: () => removeCover(cl, reload) },
156
+ ], { sm: true, icon: null }),
144
157
  );
145
158
 
146
159
  return Card({
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
8
8
  Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
9
+ scheduleDdayBadge,
9
10
  } from '/lib.js';
10
11
  import { t } from '/i18n.js';
11
12
 
@@ -141,6 +142,14 @@ function Pipeline(breakdown) {
141
142
  if (total === 0) {
142
143
  body = el('p', { class: 'muted', style: { margin: 0 } },
143
144
  t('home.pipeline.empty'));
145
+ } else if (total < 3) {
146
+ // Too few applications for a stacked bar to read as anything but noise —
147
+ // show the current stage(s) as one calm line until the pipeline fills in.
148
+ const detail = PIPE_ORDER.filter((st) => c(st) > 0)
149
+ .map((st) => t('home.pipeline.small.item', { status: t('status.' + st), count: c(st) }))
150
+ .join(' · ');
151
+ body = el('p', { class: 'muted', style: { margin: 0 } },
152
+ t('home.pipeline.small', { detail }));
144
153
  } else {
145
154
  const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
146
155
  el('div', {
@@ -210,16 +219,27 @@ function ActionLane(s) {
210
219
  push(it.job.id, ListRow({
211
220
  leading: statusDot('document_passed'),
212
221
  title: it.job.company,
213
- sub: it.job.position,
222
+ sub: t('home.actionLane.reason.interview'),
214
223
  trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), t('home.actionLane.interviewPrep')),
215
224
  onClick: () => navigate(`/jobs/${it.job.id}`),
216
225
  }));
217
226
  }
227
+ // Hiring-schedule stages (면접·인적성·발표) sit right after interview prep and before
228
+ // deadlines: they're the most time-bound "be there on this date" items.
229
+ for (const it of s.upcoming_schedule || []) {
230
+ push(it.job.id, ListRow({
231
+ leading: statusDot('interview'),
232
+ title: it.job.company,
233
+ sub: it.time ? `${it.label} · ${it.time}` : it.label,
234
+ trailing: scheduleDdayBadge(it.d_day),
235
+ onClick: () => navigate(`/jobs/${it.job.id}`),
236
+ }));
237
+ }
218
238
  for (const it of s.deadlines || []) {
219
239
  push(it.job.id, ListRow({
220
240
  leading: statusDot(it.status),
221
241
  title: it.job.company,
222
- sub: it.job.position,
242
+ sub: t('home.actionLane.reason.deadline'),
223
243
  trailing: ddayBadge(it.days_left),
224
244
  onClick: () => navigate(`/jobs/${it.job.id}`),
225
245
  }));
@@ -228,8 +248,8 @@ function ActionLane(s) {
228
248
  push(it.job.id, ListRow({
229
249
  leading: statusDot('applied'),
230
250
  title: it.job.company,
231
- sub: it.job.position,
232
- trailing: Badge('accent', t('home.actionLane.followup', { n: it.days_since })),
251
+ sub: t('home.actionLane.reason.followup', { n: it.days_since }),
252
+ trailing: Badge('accent', t('home.actionLane.followupBadge')),
233
253
  onClick: () => navigate(`/jobs/${it.job.id}`),
234
254
  }));
235
255
  }
@@ -0,0 +1,156 @@
1
+ import { el, get, post, Card, Btn, icon, mount, toastOk, toastError } from '/lib.js';
2
+ import { getLang } from '/i18n.js';
3
+
4
+ const copy = {
5
+ ko: {
6
+ title: '진단·데이터 건강도',
7
+ subtitle: '로컬 데이터 상태, 모순, 출처 확인 대상과 경험 재사용 현황을 한곳에서 점검합니다.',
8
+ runtime: '실행 환경',
9
+ health: '데이터 건강도',
10
+ provenance: '사람 확인이 필요한 정보',
11
+ reuse: '경험·프로젝트 재사용 지도',
12
+ healthy: '정상',
13
+ failed: '확인 필요',
14
+ verify: '사람이 확인함',
15
+ empty: '확인할 문제가 없습니다.',
16
+ score: '건강도 점수',
17
+ times: '회 사용',
18
+ local: 'DB와 커리어 데이터는 이 컴퓨터에 저장됩니다.',
19
+ network: '업데이트 확인·설치 문서·채용 검색은 선택적으로 인터넷을 사용할 수 있습니다.',
20
+ },
21
+ en: {
22
+ title: 'Diagnostics & data health',
23
+ subtitle: 'Review local storage, contradictions, provenance, and experience reuse.',
24
+ runtime: 'Runtime checks',
25
+ health: 'Data health',
26
+ provenance: 'Needs human verification',
27
+ reuse: 'Experience & project reuse map',
28
+ healthy: 'Healthy',
29
+ failed: 'Needs attention',
30
+ verify: 'Mark human verified',
31
+ empty: 'No issues found.',
32
+ score: 'Health score',
33
+ times: 'uses',
34
+ local: 'Your database and career records are stored on this computer.',
35
+ network: 'Update checks, install docs, and job search may optionally use the internet.',
36
+ },
37
+ };
38
+
39
+ function text() {
40
+ return copy[getLang()] || copy.ko;
41
+ }
42
+
43
+ function severityBadge(severity) {
44
+ const label = severity === 'error' ? 'ERROR' : severity === 'warning' ? 'WARN' : 'INFO';
45
+ return el('span', { class: `badge badge--${severity === 'error' ? 'rejected' : severity === 'warning' ? 'planned' : 'draft'}` }, label);
46
+ }
47
+
48
+ export async function render(ctx) {
49
+ const c = text();
50
+ ctx.setTitle(c.title);
51
+ ctx.setActions([Btn(c.title, { icon: 'check', sm: true, variant: 'ghost', onClick: () => render(ctx) })]);
52
+
53
+ try {
54
+ const diagnostics = await get('/api/diagnostics');
55
+ const checks = diagnostics.checks || [];
56
+ const report = diagnostics.data_health || { score: 0, issues: [], counts: {} };
57
+ const unverified = diagnostics.unverified_entities || [];
58
+ const reuseMap = diagnostics.reuse_map || [];
59
+
60
+ const localFirst = Card({
61
+ title: 'Local-first',
62
+ body: el('div', { class: 'stack-2' },
63
+ el('div', { class: 'callout callout--privacy' }, icon('lock'), el('div', {},
64
+ el('div', { class: 'callout__title' }, c.local),
65
+ el('div', { class: 'callout__body' }, c.network))),
66
+ ),
67
+ });
68
+
69
+ const runtime = Card({
70
+ title: c.runtime,
71
+ body: el('div', { class: 'stack-2' }, ...checks.map((check) =>
72
+ el('div', { class: 'flex between gap-3' },
73
+ el('div', {},
74
+ el('div', { class: 'strong' }, check.label),
75
+ el('div', { class: 'muted text-sm tnum' }, check.detail || '—')),
76
+ el('span', { class: `badge badge--${check.ok ? 'final_passed' : 'rejected'}` }, check.ok ? c.healthy : c.failed),
77
+ ))),
78
+ });
79
+
80
+ const issueList = report.issues?.length
81
+ ? el('div', { class: 'stack-2' }, ...report.issues.map((issue) =>
82
+ el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
83
+ severityBadge(issue.severity),
84
+ el('div', {},
85
+ el('div', { class: 'strong' }, issue.message),
86
+ el('div', { class: 'muted text-sm' }, `${issue.entity_type}${issue.entity_id ? ` · ${issue.entity_id}` : ''}`)),
87
+ )))
88
+ : el('p', { class: 'muted' }, c.empty);
89
+
90
+ const health = Card({
91
+ title: c.health,
92
+ actions: [el('span', { class: 'badge badge--final_passed tnum' }, `${c.score} ${report.score}`)],
93
+ body: issueList,
94
+ });
95
+
96
+ const reuse = Card({
97
+ title: c.reuse,
98
+ body: reuseMap.length
99
+ ? el('div', { class: 'stack-2' }, ...reuseMap.map((item) =>
100
+ el('div', { class: 'flex between gap-3', style: { alignItems: 'flex-start' } },
101
+ el('div', {},
102
+ el('div', { class: 'strong' }, item.label),
103
+ el('div', { class: 'muted text-sm' },
104
+ item.cover_letters?.length
105
+ ? item.cover_letters.map((letter) => [letter.company, letter.position, letter.title].filter(Boolean).join(' · ')).join(' / ')
106
+ : c.empty)),
107
+ el('span', { class: `badge badge--${item.reuse_count ? 'final_passed' : 'draft'} tnum` }, `${item.reuse_count} ${c.times}`),
108
+ )))
109
+ : el('p', { class: 'muted' }, c.empty),
110
+ });
111
+
112
+ async function verify(item, button) {
113
+ try {
114
+ button.disabled = true;
115
+ await post('/api/provenance/verify', {
116
+ entity_type: item.entity_type,
117
+ entity_id: item.entity_id,
118
+ verified: true,
119
+ });
120
+ toastOk(c.verify);
121
+ await render(ctx);
122
+ } catch (error) {
123
+ button.disabled = false;
124
+ toastError(error);
125
+ }
126
+ }
127
+
128
+ const provenance = Card({
129
+ title: c.provenance,
130
+ body: unverified.length
131
+ ? el('div', { class: 'stack-2' }, ...unverified.map((item) => {
132
+ const button = Btn(c.verify, { icon: 'check', sm: true, variant: 'ghost' });
133
+ button.addEventListener('click', () => verify(item, button));
134
+ return el('div', { class: 'flex between gap-3' },
135
+ el('div', {},
136
+ el('div', { class: 'strong' }, `${item.entity_type} · ${item.entity_id}`),
137
+ el('div', { class: 'muted text-sm' }, item.source_excerpt || item.source)),
138
+ button,
139
+ );
140
+ }))
141
+ : el('p', { class: 'muted' }, c.empty),
142
+ });
143
+
144
+ mount(ctx.view, el('div', { class: 'stack-4' },
145
+ el('p', { class: 'muted', style: { marginTop: 0 } }, c.subtitle),
146
+ localFirst,
147
+ runtime,
148
+ health,
149
+ reuse,
150
+ provenance,
151
+ ));
152
+ } catch (error) {
153
+ toastError(error);
154
+ mount(ctx.view, el('div', { class: 'card' }, el('div', { class: 'card__body' }, String(error))));
155
+ }
156
+ }
@@ -146,12 +146,19 @@ export async function render(ctx) {
146
146
  mount(panel, list.length
147
147
  ? el('div', { class: 'stack-3' }, ...list.map(QuestionBlock))
148
148
  : el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.questionsEmptyTab')));
149
- for (const c of CATEGORIES) tabEls[c].classList.toggle('is-active', c === active);
149
+ for (const c of CATEGORIES) {
150
+ tabEls[c].classList.toggle('is-active', c === active);
151
+ tabEls[c].setAttribute('aria-selected', String(c === active));
152
+ }
150
153
  }
151
- const tabs = el('div', { class: 'tabs' }, ...CATEGORIES.map((c) => {
154
+ // 키보드 조작 가능 탭(activate = role/tabindex/Enter·Space) + 탭 시맨틱.
155
+ const tabs = el('div', { class: 'tabs', attrs: { role: 'tablist' } }, ...CATEGORIES.map((c) => {
152
156
  const n = groups[c].length;
153
- const tabEl = el('div', { class: 'tab', onClick: () => { active = c; draw(); } },
154
- n ? `${t('interview.tab.' + c)} (${n})` : t('interview.tab.' + c));
157
+ const tabEl = el('div', {
158
+ class: 'tab',
159
+ attrs: { role: 'tab', 'aria-selected': 'false' },
160
+ activate: () => { active = c; draw(); },
161
+ }, n ? `${t('interview.tab.' + c)} (${n})` : t('interview.tab.' + c));
155
162
  tabEls[c] = tabEl;
156
163
  return tabEl;
157
164
  }));
@@ -6,7 +6,7 @@ import {
6
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, linesToArray, csvToArray,
9
+ fmtDate, scoreClass, mount, meta, linesToArray, csvToArray, daysUntil, scheduleDdayBadge,
10
10
  } from '/lib.js';
11
11
  import { t } from '/i18n.js';
12
12
 
@@ -314,11 +314,11 @@ async function renderDetail(ctx, jobId) {
314
314
  ]);
315
315
 
316
316
  // --- Page head ------------------------------------------------------------
317
- // 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태 변경·원문·삭제만.
317
+ // 수정은 상단바(ctx.setActions)에만 둔다 — 여기엔 상태 변경·원문만.
318
+ // 삭제는 위험 액션이라 페이지 최하단 '위험 구역'으로 강등한다(아래 DangerZone).
318
319
  const headActions = el('div', { class: 'flex gap-2 center wrap' },
319
320
  statusSelect,
320
321
  job.url ? el('a', { class: 'btn btn--ghost btn--sm', href: job.url, attrs: { target: '_blank', rel: 'noopener noreferrer' } }, icon('external'), el('span', {}, t('jobs.detail.openPosting'))) : null,
321
- delBtn,
322
322
  );
323
323
 
324
324
  const head = el('div', { class: 'page-head' },
@@ -340,13 +340,27 @@ async function renderDetail(ctx, jobId) {
340
340
  CoverLettersCard(job.cover_letters),
341
341
  InterviewCard(job)),
342
342
  PostingCard(job),
343
+ CompanyResearchCard(job),
343
344
  FitCard(job.fit),
344
345
  TimelineCard(job.timeline),
345
346
  RelatedCard(job.related),
347
+ DangerZone(delBtn),
346
348
  );
347
349
  mount(ctx.view, wrap);
348
350
  }
349
351
 
352
+ // Delete is destructive, so it sits at the very bottom in a quiet 위험 구역 row
353
+ // (not a card, per the design guide) instead of competing in the top-right header.
354
+ function DangerZone(delBtn) {
355
+ return el('div', { class: 'stack-2', style: { marginTop: '8px' } },
356
+ el('div', { class: 'divider' }),
357
+ el('div', { class: 'flex between center wrap gap-3' },
358
+ el('div', { style: { maxWidth: '440px' } },
359
+ el('div', { class: 'strong text-sm' }, t('jobs.detail.dangerZone')),
360
+ el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } }, t('jobs.detail.dangerZoneHint'))),
361
+ delBtn));
362
+ }
363
+
350
364
  /* ----------------------------------------------------------- detail cards */
351
365
 
352
366
  function PostingCard(job) {
@@ -372,6 +386,41 @@ function PostingCard(job) {
372
386
  ...job.requirements.map((r) => el('li', {}, r)))));
373
387
  }
374
388
 
389
+ if (job.questions && job.questions.length) {
390
+ // 한국 문항형 자소서: 회사 지정 문항·글자수 한도. AI가 문항별 답변을 쓰고 한도를
391
+ // 점검할 원본이자, 사용자가 대시보드에서 요구 문항을 그대로 볼 수 있게 한다.
392
+ body.push(el('div', { class: 'mt-3' },
393
+ el('div', { class: 'muted text-sm mb-2' }, t('jobs.questions.title')),
394
+ el('ol', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
395
+ ...job.questions.map((q) => {
396
+ const label = questionLimitLabel(q);
397
+ return el('li', {},
398
+ el('span', {}, q.question),
399
+ label ? el('span', { class: 'muted text-sm', style: { marginLeft: '8px' } }, label) : null);
400
+ }))));
401
+ }
402
+
403
+ if (job.schedule && job.schedule.length) {
404
+ // 전형 일정: 면접·인적성·발표 등 단계별 날짜. 날짜순으로 D-day 라벨과 함께 보여주고,
405
+ // 이미 지난 일정은 흐리게 처리한다(v9 문항 블록과 같은 위치·스타일 패턴).
406
+ const sorted = job.schedule.slice().sort((a, b) => {
407
+ if (a.date !== b.date) return a.date < b.date ? -1 : 1;
408
+ return (a.time || '99:99').localeCompare(b.time || '99:99');
409
+ });
410
+ body.push(el('div', { class: 'mt-3' },
411
+ el('div', { class: 'muted text-sm mb-2' }, t('jobs.schedule.title')),
412
+ el('ul', { class: 'stack-2', style: { margin: 0, padding: 0, listStyle: 'none' } },
413
+ ...sorted.map((s) => {
414
+ const dDay = daysUntil(s.date);
415
+ const when = s.time ? `${fmtDate(s.date)} ${s.time}` : fmtDate(s.date);
416
+ return el('li', { class: `flex gap-2 center${dDay != null && dDay < 0 ? ' muted' : ''}` },
417
+ scheduleDdayBadge(dDay),
418
+ el('span', { class: 'strong' }, s.label),
419
+ el('span', { class: 'text-sm' }, when),
420
+ s.note ? el('span', { class: 'muted text-sm' }, s.note) : null);
421
+ }))));
422
+ }
423
+
375
424
  if (job.description) {
376
425
  // 원문은 가장 길어 칼럼을 늘리는 주범 — 기본 접힘(아코디언)으로 둔다.
377
426
  body.push(el('details', { class: 'disclosure mt-3' },
@@ -384,6 +433,40 @@ function PostingCard(job) {
384
433
  return Card({ title: t('jobs.posting.title'), body });
385
434
  }
386
435
 
436
+ // 문항 글자수 한도 라벨(예: "최대 700자(공백포함)"). 한도가 없으면 null.
437
+ // count_mode: with_space=공백포함 · no_space=공백제외 · byte=UTF-8 바이트(공백 구분 없음).
438
+ function questionLimitLabel(q) {
439
+ const hasMin = typeof q.min_chars === 'number';
440
+ const hasMax = typeof q.max_chars === 'number';
441
+ if (!hasMin && !hasMax) return null;
442
+ const unit = q.count_mode === 'byte' ? t('jobs.questions.unitByte') : t('jobs.questions.unitChar');
443
+ const suffix = q.count_mode === 'no_space' ? t('jobs.questions.modeNoSpace')
444
+ : q.count_mode === 'with_space' ? t('jobs.questions.modeWithSpace')
445
+ : '';
446
+ const parts = [];
447
+ if (hasMin) parts.push(t('jobs.questions.min', { n: q.min_chars, unit }));
448
+ if (hasMax) parts.push(t('jobs.questions.max', { n: q.max_chars, unit }));
449
+ return `${parts.join(' · ')}${suffix}`;
450
+ }
451
+
452
+ // Company research the AI saved (overview·인재상·핵심가치·평판) — rendered so the
453
+ // user can actually SEE it in the dashboard, not just have the AI use it.
454
+ function CompanyResearchCard(job) {
455
+ const hasValues = job.core_values && job.core_values.length;
456
+ if (!job.company_overview && !job.talent_profile && !hasValues && !job.reputation) return null;
457
+ const field = (labelKey, value) => el('div', { class: 'mt-3' },
458
+ el('div', { class: 'muted text-sm mb-2' }, t(labelKey)),
459
+ el('p', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, value));
460
+ const body = [];
461
+ if (job.company_overview) body.push(field('jobs.company.overview', job.company_overview));
462
+ if (job.talent_profile) body.push(field('jobs.company.talent', job.talent_profile));
463
+ if (hasValues) body.push(el('div', { class: 'mt-3' },
464
+ el('div', { class: 'muted text-sm mb-2' }, t('jobs.company.values')),
465
+ Chips(job.core_values, { accent: true })));
466
+ if (job.reputation) body.push(field('jobs.company.reputation', job.reputation));
467
+ return Card({ title: t('jobs.company.title'), body });
468
+ }
469
+
387
470
  function FitCard(fit) {
388
471
  if (!fit) {
389
472
  return Card({
@@ -464,7 +547,12 @@ function CoverLettersCard(coverLetters) {
464
547
  icon('file', 'muted'),
465
548
  el('div', {},
466
549
  el('div', { class: 'strong' }, cl.title),
467
- el('div', { class: 'muted text-sm' }, t('jobs.cover.versions', { count: cl.version_count ?? 1 })))),
550
+ // Preview line: version count + last-updated date so the row isn't a bare
551
+ // title. (The latest version's note isn't in this response — listByJob maps
552
+ // without versions — so we fall back to the date per the design.)
553
+ el('div', { class: 'muted text-sm' },
554
+ t('jobs.cover.versions', { count: cl.version_count ?? 1 }),
555
+ cl.updated_at ? ` · ${fmtDate(cl.updated_at)}` : ''))),
468
556
  icon('chevronRight', 'muted')))),
469
557
  });
470
558
  }
@@ -168,7 +168,6 @@ function Theme() {
168
168
  /* ------------------------------------------------------------- 언어 / Language */
169
169
  function LanguageSwitcher() {
170
170
  // 테마 컴포넌트와 동일 패턴: localStorage 영속 + paint() + 변경 시 재페인트.
171
- // 카탈로그가 채워지는 Phase 2부터 페이지 텍스트가 실제로 전환된다.
172
171
  const options = [
173
172
  { value: 'ko', label: '한국어' },
174
173
  { value: 'en', label: 'English' },
@@ -188,8 +187,9 @@ function LanguageSwitcher() {
188
187
  onLangChange(paint);
189
188
 
190
189
  return Card({
190
+ // 제목은 의도적으로 양언어 병기 — 현재 언어를 못 읽는 사용자도 이 카드를 찾아야 한다.
191
191
  title: '언어 / Language',
192
- sub: '대시보드 표시 언어',
192
+ sub: t('settings.lang.sub'),
193
193
  body: row,
194
194
  });
195
195
  }
@@ -419,6 +419,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
419
419
  .pipefunnel__value { font-size: 24px; font-weight: 700; letter-spacing: -.02em; margin-top: 5px; line-height: 1.1; }
420
420
  .pipefunnel__conv { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
421
421
  @media (max-width: 560px){ .pipefunnel { grid-template-columns: repeat(2, 1fr); } }
422
+ /* Home KPI tiles (the only .grid--4) read better as a 2×2 on phones than a single
423
+ full-width column; overrides the ≤900px collapse for this narrow range. */
424
+ @media (max-width: 560px){ .grid--4 { grid-template-columns: repeat(2, 1fr); } }
422
425
 
423
426
  /* ----------------------------------------------------------------- check row (onboarding)
424
427
  The ONE place setup guidance lives. Outcome rows auto-detect completion. */
@@ -577,3 +580,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
577
580
  .card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
578
581
  .page-head{ flex-wrap: wrap; }
579
582
  }
583
+
584
+ /* ---------- reduced motion ----------
585
+ 전정기관 민감 사용자를 위해 OS 설정을 존중한다: 모달·토스트·오프캔버스의
586
+ 등장 애니메이션과 hover 트랜지션을 사실상 즉시(0.01ms)로 줄인다.
587
+ (스켈레톤 shimmer 포함 — 반복 애니메이션이 가장 부담이 크다.) */
588
+ @media (prefers-reduced-motion: reduce) {
589
+ *, *::before, *::after {
590
+ animation-duration: 0.01ms !important;
591
+ animation-iteration-count: 1 !important;
592
+ transition-duration: 0.01ms !important;
593
+ scroll-behavior: auto !important;
594
+ }
595
+ }