careermate 0.0.9 → 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.
@@ -6,7 +6,6 @@
6
6
  import { el, icon, get, navigate, toastError, Spinner, clear } from '/lib.js';
7
7
  import { t, onLangChange } from '/i18n.js';
8
8
 
9
- // 라벨은 t('nav.<id>')로 해석 — 로케일 전환 시 재렌더로 갱신된다.
10
9
  const ROUTES = [
11
10
  { id: 'home', icon: 'home', module: '/pages/home.js' },
12
11
  { id: 'profile', icon: 'user', module: '/pages/profile.js' },
@@ -14,6 +13,7 @@ const ROUTES = [
14
13
  { id: 'applications', icon: 'layers', module: '/pages/applications.js', countKey: 'active_applications' },
15
14
  { id: 'documents', icon: 'file', module: '/pages/documents.js', countKey: 'cover_letters' },
16
15
  { id: 'interview', icon: 'mic', module: '/pages/interview.js', countKey: 'interview_pending' },
16
+ { id: 'insights', icon: 'target', module: '/pages/insights.js' },
17
17
  { id: 'settings', icon: 'settings', module: '/pages/settings.js' },
18
18
  ];
19
19
 
@@ -24,6 +24,10 @@ const actionsEl = document.getElementById('topbar-actions');
24
24
  const routeStatusEl = document.getElementById('route-status');
25
25
  const moduleCache = new Map();
26
26
 
27
+ function removePrivateRepositoryLinks(root = document) {
28
+ root.querySelectorAll?.('a[href*="github.com/osntak/careermate"]').forEach((link) => link.remove());
29
+ }
30
+
27
31
  function parseHash() {
28
32
  const raw = location.hash.replace(/^#\/?/, '');
29
33
  const segments = raw.split('/').filter(Boolean);
@@ -35,7 +39,7 @@ function renderNav(counts = {}) {
35
39
  navEl.append(el('div', { class: 'nav__label' }, t('nav.section.workspace')));
36
40
  const { page } = parseHash();
37
41
  for (const r of ROUTES) {
38
- if (r.id === 'settings') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
42
+ if (r.id === 'insights') navEl.append(el('div', { class: 'nav__label' }, t('nav.section.system')));
39
43
  const count = r.countKey ? counts[r.countKey] : null;
40
44
  navEl.append(el('a', {
41
45
  class: `nav__item${page === r.id ? ' is-active' : ''}`,
@@ -67,7 +71,6 @@ async function route() {
67
71
  const { page, params } = parseHash();
68
72
  const def = ROUTES.find((r) => r.id === page) || ROUTES[0];
69
73
 
70
- // active nav state
71
74
  navEl.querySelectorAll('.nav__item').forEach((n) => n.classList.toggle('is-active', n.getAttribute('href') === `#/${def.id}`));
72
75
  const pageTitle = t('nav.' + def.id);
73
76
  titleEl.textContent = pageTitle;
@@ -89,11 +92,10 @@ async function route() {
89
92
  view.append(Spinner());
90
93
  try {
91
94
  const mod = await loadModule(def.module);
92
- if (token !== renderToken) return; // a newer navigation superseded this one
95
+ if (token !== renderToken) return;
93
96
  clear(view);
94
97
  await mod.render(ctx);
95
- // Announce just the new page name to screen readers (titleEl reflects any
96
- // detail-page ctx.setTitle override).
98
+ removePrivateRepositoryLinks(view);
97
99
  if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || t('nav.' + def.id);
98
100
  } catch (err) {
99
101
  if (token !== renderToken) return;
@@ -106,9 +108,6 @@ async function route() {
106
108
  }
107
109
  }
108
110
 
109
- // Mobile/narrow-window nav: the sidebar is the only navigation, so below 900px it
110
- // becomes an off-canvas drawer toggled from the topbar. Without this a phone/narrow
111
- // window is stranded on whatever page loaded first.
112
111
  function setupMobileNav() {
113
112
  const toggle = document.getElementById('nav-toggle');
114
113
  const scrim = document.getElementById('nav-scrim');
@@ -126,14 +125,10 @@ function setupMobileNav() {
126
125
  }
127
126
 
128
127
  function initTheme() {
129
- // Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
130
128
  const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
131
129
  if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
132
130
  }
133
131
 
134
- // Static shell strings live in index.html (not page-rendered), so set them from the
135
- // catalog at boot + on locale change. foot-status belongs to the demo shim when the
136
- // demo is running, so skip it there.
137
132
  function localizeShell() {
138
133
  const sub = document.querySelector('.sidebar__subtitle');
139
134
  if (sub) sub.textContent = t('nav.subtitle');
@@ -141,36 +136,32 @@ function localizeShell() {
141
136
  if (skip) skip.textContent = t('nav.skip');
142
137
  const foot = document.getElementById('foot-status');
143
138
  if (foot && !window.__CAREERMATE_DEMO__) foot.textContent = t('nav.footStatus');
144
- // FAQ help link points at the marketing-site FAQ in the active locale.
145
139
  const faq = document.getElementById('foot-faq');
146
140
  if (faq) {
147
141
  const en = document.documentElement.lang === 'en';
148
142
  faq.href = en ? 'https://careermate.life/en/faq' : 'https://careermate.life/faq';
149
143
  faq.title = en ? 'FAQ' : '자주 묻는 질문';
150
144
  }
145
+ removePrivateRepositoryLinks();
151
146
  }
152
147
 
153
148
  async function boot() {
154
149
  initTheme();
155
150
  setupMobileNav();
156
151
  localizeShell();
157
- // Skip link: jump keyboard focus past the sidebar to the page content. A hash
158
- // anchor would collide with the router, so move focus directly instead.
159
152
  document.getElementById('skip-link')?.addEventListener('click', () => {
160
153
  view.setAttribute('tabindex', '-1');
161
154
  view.focus();
162
155
  });
163
- // Show data location in the footer (privacy: user can always see where data lives).
164
156
  try {
165
157
  const h = await get('/api/health');
166
158
  document.getElementById('foot-path').textContent = h.data_dir;
167
159
  document.getElementById('foot-path').title = h.data_dir;
168
160
  } catch { /* ignore */ }
169
161
  await refreshNav();
170
- // 로케일 전환 시 내비게이션과 현재 라우트를 다시 그린다(전체 리로드 없이).
171
162
  onLangChange(() => { localizeShell(); refreshNav(); route(); });
172
163
  window.addEventListener('hashchange', route);
173
164
  await route();
174
165
  }
175
166
 
176
- boot();
167
+ boot();
@@ -28,6 +28,7 @@ export default {
28
28
  'documents.cover.primary': 'Primary',
29
29
  'documents.cover.open': 'Open',
30
30
  'documents.cover.copy': 'Copy',
31
+ 'documents.cover.more': 'More',
31
32
  'documents.cover.setPrimary': 'Set as primary',
32
33
  'documents.cover.delete': 'Delete',
33
34
  'documents.cover.noContent': 'No content yet.',
@@ -47,6 +47,9 @@ export default {
47
47
  'home.pipeline.title': 'Application pipeline',
48
48
  'home.pipeline.openBoard': 'Open board',
49
49
  'home.pipeline.empty': 'No application stages yet. Set an application status on a posting and your pipeline fills in here.',
50
+ // small-sample fallback (< 3 applications): show the current stage as one line
51
+ 'home.pipeline.small.item': '{{status}} · {{count}}',
52
+ 'home.pipeline.small': '{{detail}} — your stage flow shows here as applications add up',
50
53
  'home.pipeline.stage.applied': 'Applied',
51
54
  'home.pipeline.stage.documentPassed': 'Screening passed',
52
55
  'home.pipeline.stage.interview': 'Interview',
@@ -62,7 +65,11 @@ export default {
62
65
  'home.actionLane.title': 'What to do next',
63
66
  'home.actionLane.viewAll': 'View all',
64
67
  'home.actionLane.interviewPrep': 'Interview prep',
65
- 'home.actionLane.followup': 'Follow up · {{n}}d',
68
+ 'home.actionLane.followupBadge': 'Follow up',
69
+ // per-type reason / next-action line
70
+ 'home.actionLane.reason.interview': 'Time to start interview prep',
71
+ 'home.actionLane.reason.deadline': 'Application deadline',
72
+ 'home.actionLane.reason.followup': 'Applied {{n}}d ago · time to follow up',
66
73
  'home.actionLane.empty': 'Nothing to handle right now. Try analyzing a new posting.',
67
74
  'home.placeholder.dash': '—',
68
75
 
@@ -68,6 +68,8 @@ export default {
68
68
  'jobs.detail.deleteMessage': "Delete the '{{company}} · {{position}}' posting? Its linked application records will be removed too.",
69
69
  'jobs.detail.deleteConfirm': 'Delete',
70
70
  'jobs.detail.deleted': 'Posting deleted.',
71
+ 'jobs.detail.dangerZone': 'Danger zone',
72
+ 'jobs.detail.dangerZoneHint': 'Deletes this posting along with its fit analysis, cover letters, interview prep, and application records. This cannot be undone.',
71
73
  'jobs.detail.list': 'Postings',
72
74
  'jobs.detail.statusLabel': 'Status',
73
75
  'jobs.detail.openPosting': 'Original posting',
@@ -6,6 +6,7 @@ export default {
6
6
  'nav.applications': 'Applications',
7
7
  'nav.documents': 'Documents',
8
8
  'nav.interview': 'Interview prep',
9
+ 'nav.insights': 'Diagnostics & insights',
9
10
  'nav.settings': 'Settings',
10
11
  'nav.section.workspace': 'Workspace',
11
12
  'nav.section.system': 'System',
@@ -28,6 +28,7 @@ export default {
28
28
  'documents.cover.primary': '대표',
29
29
  'documents.cover.open': '열기',
30
30
  'documents.cover.copy': '복사',
31
+ 'documents.cover.more': '더보기',
31
32
  'documents.cover.setPrimary': '대표 지정',
32
33
  'documents.cover.delete': '삭제',
33
34
  'documents.cover.noContent': '아직 내용이 없습니다.',
@@ -38,6 +38,9 @@ export default {
38
38
  'home.pipeline.title': '지원 파이프라인',
39
39
  'home.pipeline.openBoard': '보드 열기',
40
40
  'home.pipeline.empty': '아직 지원 단계가 없어요. 공고에 지원 상태를 표시하면 여기에 파이프라인이 채워져요.',
41
+ // small-sample fallback (< 3 applications): show the current stage as one line
42
+ 'home.pipeline.small.item': '{{status}} {{count}}건',
43
+ 'home.pipeline.small': '{{detail}} — 지원이 쌓이면 단계별 흐름이 여기 보여요',
41
44
  'home.pipeline.stage.applied': '지원',
42
45
  'home.pipeline.stage.documentPassed': '서류 합격',
43
46
  'home.pipeline.stage.interview': '면접',
@@ -50,7 +53,11 @@ export default {
50
53
  'home.actionLane.title': '이번에 할 일',
51
54
  'home.actionLane.viewAll': '전체 보기',
52
55
  'home.actionLane.interviewPrep': '면접 준비',
53
- 'home.actionLane.followup': '후속 필요 · {{n}}일',
56
+ 'home.actionLane.followupBadge': '팔로업',
57
+ // per-type reason / next-action line
58
+ 'home.actionLane.reason.interview': '면접 준비를 시작할 차례',
59
+ 'home.actionLane.reason.deadline': '지원 마감',
60
+ 'home.actionLane.reason.followup': '지원 {{n}}일 — 팔로업 적기',
54
61
  'home.actionLane.empty': '지금 처리할 일이 없어요. 새 공고를 분석해 보세요.',
55
62
  'home.placeholder.dash': '—',
56
63
 
@@ -68,6 +68,8 @@ export default {
68
68
  'jobs.detail.deleteMessage': "'{{company}} · {{position}}' 공고를 삭제할까요? 연결된 지원 기록도 함께 사라집니다.",
69
69
  'jobs.detail.deleteConfirm': '삭제',
70
70
  'jobs.detail.deleted': '공고를 삭제했어요.',
71
+ 'jobs.detail.dangerZone': '위험 구역',
72
+ 'jobs.detail.dangerZoneHint': '이 공고와 연결된 적합도 분석·자기소개서·면접 자료·지원 기록이 함께 삭제됩니다. 되돌릴 수 없어요.',
71
73
  'jobs.detail.list': '목록',
72
74
  'jobs.detail.statusLabel': '상태',
73
75
  'jobs.detail.openPosting': '공고 원문',
@@ -6,6 +6,7 @@ export default {
6
6
  'nav.applications': '지원 현황',
7
7
  'nav.documents': '문서',
8
8
  'nav.interview': '면접 준비',
9
+ 'nav.insights': '진단·인사이트',
9
10
  'nav.settings': '설정',
10
11
  'nav.section.workspace': '워크스페이스',
11
12
  'nav.section.system': '시스템',
@@ -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
 
@@ -144,12 +144,16 @@ function CoverRow(cl, reload) {
144
144
  cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
145
145
  );
146
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.
147
149
  const actions = el('div', { class: 'flex gap-2 wrap' },
148
150
  Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
149
- Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
150
151
  ExportMenu(`/api/export/cover-letter/${cl.id}`),
151
- cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
152
- 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 }),
153
157
  );
154
158
 
155
159
  return Card({
@@ -142,6 +142,14 @@ function Pipeline(breakdown) {
142
142
  if (total === 0) {
143
143
  body = el('p', { class: 'muted', style: { margin: 0 } },
144
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 }));
145
153
  } else {
146
154
  const segs = PIPE_ORDER.filter((st) => c(st) > 0).map((st) =>
147
155
  el('div', {
@@ -211,7 +219,7 @@ function ActionLane(s) {
211
219
  push(it.job.id, ListRow({
212
220
  leading: statusDot('document_passed'),
213
221
  title: it.job.company,
214
- sub: it.job.position,
222
+ sub: t('home.actionLane.reason.interview'),
215
223
  trailing: el('span', { class: 'badge badge--accent' }, el('span', { class: 'dot' }), t('home.actionLane.interviewPrep')),
216
224
  onClick: () => navigate(`/jobs/${it.job.id}`),
217
225
  }));
@@ -231,7 +239,7 @@ function ActionLane(s) {
231
239
  push(it.job.id, ListRow({
232
240
  leading: statusDot(it.status),
233
241
  title: it.job.company,
234
- sub: it.job.position,
242
+ sub: t('home.actionLane.reason.deadline'),
235
243
  trailing: ddayBadge(it.days_left),
236
244
  onClick: () => navigate(`/jobs/${it.job.id}`),
237
245
  }));
@@ -240,8 +248,8 @@ function ActionLane(s) {
240
248
  push(it.job.id, ListRow({
241
249
  leading: statusDot('applied'),
242
250
  title: it.job.company,
243
- sub: it.job.position,
244
- 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')),
245
253
  onClick: () => navigate(`/jobs/${it.job.id}`),
246
254
  }));
247
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
+ }
@@ -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' },
@@ -344,10 +344,23 @@ async function renderDetail(ctx, jobId) {
344
344
  FitCard(job.fit),
345
345
  TimelineCard(job.timeline),
346
346
  RelatedCard(job.related),
347
+ DangerZone(delBtn),
347
348
  );
348
349
  mount(ctx.view, wrap);
349
350
  }
350
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
+
351
364
  /* ----------------------------------------------------------- detail cards */
352
365
 
353
366
  function PostingCard(job) {
@@ -534,7 +547,12 @@ function CoverLettersCard(coverLetters) {
534
547
  icon('file', 'muted'),
535
548
  el('div', {},
536
549
  el('div', { class: 'strong' }, cl.title),
537
- 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)}` : ''))),
538
556
  icon('chevronRight', 'muted')))),
539
557
  });
540
558
  }
@@ -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. */