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
@@ -0,0 +1,269 @@
1
+ // =============================================================================
2
+ // Demo seed data — realistic sample career workspace for careermate.life/demo.
3
+ // Loaded fresh into the in-memory demo DB on every page load (and on reset).
4
+ // Shapes mirror @careermate/shared *Record types (see packages/shared/src/schemas.ts).
5
+ // All timestamps are fixed strings so the demo is deterministic (no Date.now()).
6
+ // =============================================================================
7
+
8
+ // Anchor "now" for relative-time rendering. Recent activity is dated near this.
9
+ const T = (iso) => iso; // readability helper
10
+
11
+ export function buildSeed() {
12
+ return {
13
+ profile: {
14
+ id: 'p1',
15
+ created_at: '2026-05-02T09:00:00.000Z',
16
+ updated_at: '2026-06-14T11:20:00.000Z',
17
+ name: '김하늘',
18
+ email: 'haneul.kim@example.com',
19
+ phone: '010-1234-5678',
20
+ location: '서울 성동구',
21
+ headline: '5년차 백엔드 엔지니어 · 결제/정산 도메인',
22
+ summary:
23
+ '대규모 트래픽의 결제·정산 시스템을 설계·운영해 온 백엔드 엔지니어입니다. ' +
24
+ '장애 대응과 점진적 마이그레이션 경험이 많고, 팀의 배포 파이프라인을 정비해 ' +
25
+ '배포 빈도를 주 1회에서 일 5회로 끌어올렸습니다.',
26
+ desired_roles: ['백엔드 엔지니어', '플랫폼 엔지니어'],
27
+ desired_conditions: '연봉 7,000만원+ · 서울/판교 · 주 2회 이상 재택',
28
+ preferred_tone: '담백하고 구체적인 · 성과는 숫자로',
29
+ emphasis_points: ['결제/정산 도메인 전문성', '대용량 트래픽 안정화', '팀 생산성 개선'],
30
+ links: [
31
+ { label: 'GitHub', url: 'https://github.com/example' },
32
+ { label: '기술 블로그', url: 'https://blog.example.com' },
33
+ ],
34
+ },
35
+
36
+ experiences: [
37
+ {
38
+ id: 'e1', created_at: '2026-05-02T09:05:00.000Z', updated_at: '2026-05-02T09:05:00.000Z',
39
+ company: '페이코어', role: '백엔드 엔지니어 (시니어)', employment_type: '정규직',
40
+ start_date: '2022-03', end_date: null, is_current: true,
41
+ description: '결제 게이트웨이와 정산 시스템 백엔드 개발·운영.',
42
+ achievements: [
43
+ '정산 배치 처리 시간을 4시간 → 38분으로 단축 (쿼리·인덱스·병렬화)',
44
+ '결제 승인 API p99 지연 320ms → 110ms 개선',
45
+ '무중단 DB 마이그레이션으로 월 1회 정기 점검 다운타임 제거',
46
+ ],
47
+ tech: ['Java', 'Spring', 'Kafka', 'MySQL', 'Redis', 'Kubernetes'],
48
+ order_index: 0,
49
+ },
50
+ {
51
+ id: 'e2', created_at: '2026-05-02T09:06:00.000Z', updated_at: '2026-05-02T09:06:00.000Z',
52
+ company: '커머스랩', role: '백엔드 엔지니어', employment_type: '정규직',
53
+ start_date: '2019-01', end_date: '2022-02', is_current: false,
54
+ description: '이커머스 주문/재고 도메인 API 개발.',
55
+ achievements: [
56
+ '주문 폭주 시 재고 동시성 버그 해결 (분산 락 도입, 오버셀 0건)',
57
+ '신규 정기배송 기능 백엔드 단독 설계·출시',
58
+ ],
59
+ tech: ['Node.js', 'TypeScript', 'PostgreSQL', 'AWS'],
60
+ order_index: 1,
61
+ },
62
+ ],
63
+
64
+ projects: [
65
+ {
66
+ id: 'pr1', created_at: '2026-05-02T09:10:00.000Z', updated_at: '2026-05-02T09:10:00.000Z',
67
+ name: '정산 시스템 차세대 리아키텍처', role: '리드',
68
+ description: '레거시 배치 정산을 이벤트 기반 스트리밍 정산으로 전환.',
69
+ highlights: ['Kafka 기반 이벤트 소싱 도입', '정산 정확도 검증 파이프라인 자동화'],
70
+ tech: ['Kafka', 'Java', 'Spring Batch'],
71
+ url: 'https://github.com/example/settlement', start_date: '2023-06', end_date: '2024-01',
72
+ order_index: 0,
73
+ },
74
+ {
75
+ id: 'pr2', created_at: '2026-05-02T09:11:00.000Z', updated_at: '2026-05-02T09:11:00.000Z',
76
+ name: '사내 배포 파이프라인 정비', role: '개인 주도',
77
+ description: 'GitHub Actions + ArgoCD로 카나리 배포 자동화.',
78
+ highlights: ['배포 빈도 주1회 → 일5회', '롤백 평균 12분 → 90초'],
79
+ tech: ['GitHub Actions', 'ArgoCD', 'Kubernetes'],
80
+ url: null, start_date: '2024-02', end_date: '2024-05',
81
+ order_index: 1,
82
+ },
83
+ ],
84
+
85
+ skills: [
86
+ { id: 's1', created_at: '2026-05-02T09:12:00.000Z', updated_at: '2026-05-02T09:12:00.000Z', name: 'Java / Spring', category: '언어/프레임워크', level: '상', years: 5, order_index: 0 },
87
+ { id: 's2', created_at: '2026-05-02T09:12:10.000Z', updated_at: '2026-05-02T09:12:10.000Z', name: 'Kafka', category: '인프라', level: '상', years: 3, order_index: 1 },
88
+ { id: 's3', created_at: '2026-05-02T09:12:20.000Z', updated_at: '2026-05-02T09:12:20.000Z', name: 'MySQL / PostgreSQL', category: '데이터베이스', level: '상', years: 5, order_index: 2 },
89
+ { id: 's4', created_at: '2026-05-02T09:12:30.000Z', updated_at: '2026-05-02T09:12:30.000Z', name: 'Kubernetes', category: '인프라', level: '중', years: 3, order_index: 3 },
90
+ { id: 's5', created_at: '2026-05-02T09:12:40.000Z', updated_at: '2026-05-02T09:12:40.000Z', name: 'TypeScript / Node.js', category: '언어/프레임워크', level: '중', years: 3, order_index: 4 },
91
+ ],
92
+
93
+ documents: [
94
+ {
95
+ id: 'd1', created_at: '2026-05-03T10:00:00.000Z', updated_at: '2026-06-10T08:30:00.000Z',
96
+ kind: 'resume', title: '김하늘 이력서 (2026)', source: 'upload', is_primary: true,
97
+ tags: ['백엔드', '결제'],
98
+ content:
99
+ '# 김하늘 — 백엔드 엔지니어\n\n## 경력 요약\n결제·정산 도메인 5년. 대용량 트래픽 안정화와 팀 생산성 개선에 강점.\n\n' +
100
+ '## 핵심 성과\n- 정산 배치 4h → 38m\n- 결제 API p99 320ms → 110ms\n- 무중단 마이그레이션으로 정기 다운타임 제거\n',
101
+ },
102
+ {
103
+ id: 'd2', created_at: '2026-05-20T13:00:00.000Z', updated_at: '2026-05-20T13:00:00.000Z',
104
+ kind: 'career_description', title: '경력기술서 — 페이코어', source: 'manual', is_primary: false,
105
+ tags: ['결제'],
106
+ content: '## 페이코어 (2022.03~)\n결제 게이트웨이/정산 백엔드. 상세 프로젝트와 성과는 본문 참조.\n',
107
+ },
108
+ ],
109
+
110
+ jobs: [
111
+ {
112
+ id: 'j1', created_at: '2026-06-01T09:00:00.000Z', updated_at: '2026-06-12T09:00:00.000Z',
113
+ company: '토스페이먼츠', position: '백엔드 엔지니어 (결제)', url: 'https://example.com/jobs/j1',
114
+ location: '서울 강남구', employment_type: '정규직',
115
+ description: '결제 코어 시스템을 함께 만들 백엔드 엔지니어를 찾습니다. 대용량 트랜잭션 처리 경험 우대.',
116
+ requirements: ['JVM 기반 백엔드 5년+', '대용량 트래픽 경험', '결제/금융 도메인 우대'],
117
+ keywords: ['Java', 'Spring', 'Kafka', 'MSA', '결제'],
118
+ deadline: '2026-06-30', source: '원티드',
119
+ },
120
+ {
121
+ id: 'j2', created_at: '2026-06-03T09:00:00.000Z', updated_at: '2026-06-11T09:00:00.000Z',
122
+ company: '쿠팡', position: 'Software Engineer, Backend', url: 'https://example.com/jobs/j2',
123
+ location: '서울 송파구', employment_type: '정규직',
124
+ description: '커머스 주문/정산 플랫폼 백엔드. 글로벌 스케일의 분산 시스템.',
125
+ requirements: ['분산 시스템 설계 경험', 'Java 또는 Kotlin', '영어 협업 가능'],
126
+ keywords: ['Java', 'Kotlin', 'AWS', '분산시스템'],
127
+ deadline: '2026-07-15', source: '사람인',
128
+ },
129
+ {
130
+ id: 'j3', created_at: '2026-06-05T09:00:00.000Z', updated_at: '2026-06-05T09:00:00.000Z',
131
+ company: '당근', position: '플랫폼 엔지니어', url: 'https://example.com/jobs/j3',
132
+ location: '서울 서초구', employment_type: '정규직',
133
+ description: '사내 개발 생산성을 끌어올릴 플랫폼 엔지니어.',
134
+ requirements: ['Kubernetes 운영 경험', 'CI/CD 파이프라인 설계', 'Go 또는 Java'],
135
+ keywords: ['Kubernetes', 'CI/CD', 'Platform', 'Go'],
136
+ deadline: null, source: '직접 입력',
137
+ },
138
+ {
139
+ id: 'j4', created_at: '2026-05-25T09:00:00.000Z', updated_at: '2026-06-08T09:00:00.000Z',
140
+ company: '네이버페이', position: '백엔드 개발자 (정산)', url: 'https://example.com/jobs/j4',
141
+ location: '경기 성남시', employment_type: '정규직',
142
+ description: '정산 시스템 백엔드 개발. 배치/스트리밍 정산 경험 우대.',
143
+ requirements: ['Spring Batch 경험', '정산 도메인 이해', 'SQL 최적화'],
144
+ keywords: ['Java', 'Spring Batch', '정산', 'MySQL'],
145
+ deadline: '2026-06-22', source: '원티드',
146
+ },
147
+ {
148
+ id: 'j5', created_at: '2026-05-15T09:00:00.000Z', updated_at: '2026-06-02T09:00:00.000Z',
149
+ company: '배달의민족', position: '서버 개발자', url: 'https://example.com/jobs/j5',
150
+ location: '서울 송파구', employment_type: '정규직',
151
+ description: '주문 중계 플랫폼 서버 개발.',
152
+ requirements: ['Java/Kotlin', '대용량 트래픽', 'MSA 경험'],
153
+ keywords: ['Kotlin', 'Spring', 'MSA'],
154
+ deadline: '2026-06-18', source: '사람인',
155
+ },
156
+ {
157
+ id: 'j6', created_at: '2026-06-10T09:00:00.000Z', updated_at: '2026-06-10T09:00:00.000Z',
158
+ company: '카카오뱅크', position: '백엔드 엔지니어', url: 'https://example.com/jobs/j6',
159
+ location: '경기 성남시', employment_type: '정규직',
160
+ description: '뱅킹 코어 백엔드. 안정성과 정합성이 최우선.',
161
+ requirements: ['금융 도메인 경험', 'Java', '높은 테스트 커버리지 문화'],
162
+ keywords: ['Java', '금융', 'Spring'],
163
+ deadline: '2026-07-05', source: '직접 입력',
164
+ },
165
+ ],
166
+
167
+ // job_id → application. status drives the pipeline + board.
168
+ applications: [
169
+ { id: 'a1', created_at: '2026-06-01T10:00:00.000Z', updated_at: '2026-06-13T10:00:00.000Z', job_id: 'j1', status: 'interview', resume_id: 'd1', cover_letter_id: 'c1', applied_at: '2026-06-06', notes: '1차 기술면접 통과, 2차 임원면접 예정' },
170
+ { id: 'a2', created_at: '2026-06-03T10:00:00.000Z', updated_at: '2026-06-09T10:00:00.000Z', job_id: 'j2', status: 'applied', resume_id: 'd1', cover_letter_id: null, applied_at: '2026-06-09', notes: null },
171
+ { id: 'a3', created_at: '2026-06-05T10:00:00.000Z', updated_at: '2026-06-05T10:00:00.000Z', job_id: 'j3', status: 'planned', resume_id: null, cover_letter_id: null, applied_at: null, notes: '지원서 작성 중' },
172
+ { id: 'a4', created_at: '2026-05-25T10:00:00.000Z', updated_at: '2026-06-12T10:00:00.000Z', job_id: 'j4', status: 'document_passed', resume_id: 'd1', cover_letter_id: 'c2', applied_at: '2026-05-28', notes: '서류 합격 — 면접 일정 조율 중' },
173
+ { id: 'a5', created_at: '2026-05-15T10:00:00.000Z', updated_at: '2026-06-02T10:00:00.000Z', job_id: 'j5', status: 'rejected', resume_id: 'd1', cover_letter_id: null, applied_at: '2026-05-18', notes: '서류 탈락' },
174
+ { id: 'a6', created_at: '2026-06-10T10:00:00.000Z', updated_at: '2026-06-10T10:00:00.000Z', job_id: 'j6', status: 'draft', resume_id: null, cover_letter_id: null, applied_at: null, notes: null },
175
+ ],
176
+
177
+ fits: [
178
+ {
179
+ id: 'f1', created_at: '2026-06-02T09:30:00.000Z', updated_at: '2026-06-02T09:30:00.000Z', job_id: 'j1',
180
+ score: 88, summary: '결제 도메인 전문성과 대용량 트래픽 경험이 공고 요구와 강하게 맞습니다.',
181
+ strengths: ['결제/정산 5년 실무', 'Kafka·Spring 숙련', 'p99 지연 개선 정량 성과'],
182
+ gaps: ['MSA 전환 리딩 경험은 보강하면 좋음'],
183
+ matched_keywords: ['Java', 'Spring', 'Kafka', '결제'], missing_keywords: ['MSA'],
184
+ recommendations: ['지원동기에 결제 코어 안정화 경험을 앞세우기', 'MSA 관련 학습/사이드 언급'],
185
+ },
186
+ {
187
+ id: 'f2', created_at: '2026-05-26T09:30:00.000Z', updated_at: '2026-05-26T09:30:00.000Z', job_id: 'j4',
188
+ score: 91, summary: '정산 배치 최적화 경험이 공고 핵심 요구와 거의 일치합니다.',
189
+ strengths: ['Spring Batch 정산 실무', '배치 4h→38m 성과', 'SQL 최적화'],
190
+ gaps: ['스트리밍 정산은 프로젝트 경험 1건'],
191
+ matched_keywords: ['Java', 'Spring Batch', '정산', 'MySQL'], missing_keywords: [],
192
+ recommendations: ['정산 정확도 검증 자동화 사례를 구체 수치로 제시'],
193
+ },
194
+ {
195
+ id: 'f3', created_at: '2026-06-04T09:30:00.000Z', updated_at: '2026-06-04T09:30:00.000Z', job_id: 'j2',
196
+ score: 72, summary: '백엔드 역량은 충분하나 글로벌 스케일 분산 시스템 경험은 부분적입니다.',
197
+ strengths: ['대용량 트래픽 안정화', '동시성 문제 해결'],
198
+ gaps: ['글로벌 멀티리전 운영 경험', '영어 협업 빈도'],
199
+ matched_keywords: ['Java', 'AWS'], missing_keywords: ['Kotlin', '분산시스템'],
200
+ recommendations: ['분산 트랜잭션 사례 강조', 'Kotlin 학습 계획 언급'],
201
+ },
202
+ ],
203
+
204
+ coverLetters: [
205
+ {
206
+ id: 'c1', created_at: '2026-06-04T11:00:00.000Z', updated_at: '2026-06-08T11:00:00.000Z',
207
+ title: '토스페이먼츠 백엔드 지원동기', job_id: 'j1', is_primary: true,
208
+ current_version_id: 'c1v2', version_count: 2,
209
+ current_content:
210
+ '결제는 한 번의 오류가 곧 신뢰의 손실인 도메인입니다. 페이코어에서 결제 승인 API의 p99 지연을 ' +
211
+ '320ms에서 110ms로 줄이며, 빠르면서도 틀리지 않는 시스템이 무엇인지 체득했습니다. ' +
212
+ '토스페이먼츠의 결제 코어에서 이 경험을 이어가고 싶습니다.',
213
+ versions: [
214
+ { id: 'c1v1', cover_letter_id: 'c1', version_no: 1, content: '(초안) 결제 도메인 경험 중심 지원동기 초안.', note: '초안', source: 'ai', created_at: '2026-06-04T11:00:00.000Z' },
215
+ { id: 'c1v2', cover_letter_id: 'c1', version_no: 2, content: '결제는 한 번의 오류가 곧 신뢰의 손실인 도메인입니다. 페이코어에서 결제 승인 API의 p99 지연을 320ms에서 110ms로 줄이며, 빠르면서도 틀리지 않는 시스템이 무엇인지 체득했습니다. 토스페이먼츠의 결제 코어에서 이 경험을 이어가고 싶습니다.', note: '수치 보강 + 마무리 다듬기', source: 'edit', created_at: '2026-06-08T11:00:00.000Z' },
216
+ ],
217
+ },
218
+ {
219
+ id: 'c2', created_at: '2026-05-27T11:00:00.000Z', updated_at: '2026-05-27T11:00:00.000Z',
220
+ title: '네이버페이 정산 지원동기', job_id: 'j4', is_primary: false,
221
+ current_version_id: 'c2v1', version_count: 1,
222
+ current_content:
223
+ '정산은 정확함이 전부입니다. Spring Batch 기반 정산 배치를 4시간에서 38분으로 줄이면서도 ' +
224
+ '검증 파이프라인을 자동화해 정합성을 지켰습니다. 네이버페이 정산 시스템에 기여하고 싶습니다.',
225
+ versions: [
226
+ { id: 'c2v1', cover_letter_id: 'c2', version_no: 1, content: '정산은 정확함이 전부입니다. Spring Batch 기반 정산 배치를 4시간에서 38분으로 줄이면서도 검증 파이프라인을 자동화해 정합성을 지켰습니다. 네이버페이 정산 시스템에 기여하고 싶습니다.', note: 'AI 생성', source: 'ai', created_at: '2026-05-27T11:00:00.000Z' },
227
+ ],
228
+ },
229
+ ],
230
+
231
+ interviews: [
232
+ {
233
+ id: 'iv1', created_at: '2026-06-12T14:00:00.000Z', updated_at: '2026-06-13T09:30:00.000Z', job_id: 'j1',
234
+ self_introduction:
235
+ '안녕하세요, 결제·정산 도메인 5년차 백엔드 엔지니어 김하늘입니다. ' +
236
+ '페이코어에서 하루 수백만 건의 결제 트랜잭션을 다루며, "빠르면서도 절대 틀리지 않는" 시스템을 ' +
237
+ '만드는 데 집중해 왔습니다. 결제 승인 API의 p99 지연을 320ms에서 110ms로 줄였고, ' +
238
+ '정산 배치를 4시간에서 38분으로 단축하면서도 검증 자동화로 정합성을 지켰습니다. ' +
239
+ '토스페이먼츠의 결제 코어에서 이 경험을 한 단계 더 키우고 싶습니다.',
240
+ notes:
241
+ '2차 임원면접 — 컬처핏 + 시스템 설계 위주 예상.\n' +
242
+ '· 결제 도메인에서의 "정확성 vs 속도" 트레이드오프 관점 강조하기\n' +
243
+ '· 페이코어 → 토스페이먼츠 지원 동기(규모/임팩트) 솔직하게\n' +
244
+ '· 역질문: 결제 코어 팀의 온콜/장애 대응 문화, 차세대 정산 로드맵',
245
+ questions: [
246
+ { question: '결제 승인 API의 p99 지연을 어떻게 320ms에서 110ms로 줄였나요?', intent: '성능 최적화의 사고 과정과 측정 기반 접근 확인', followups: ['병목을 어떻게 측정했나요?', '개선의 트레이드오프는 무엇이었나요?', '캐시 정합성은 어떻게 보장했나요?'], answer_outline: 'APM으로 구간별 측정 → 가설(N+1 쿼리·직렬화·커넥션 대기) → 인덱스 재설계·커넥션풀 튜닝·읽기 캐시 순차 적용 → 각 단계 A/B로 효과 검증. 캐시는 TTL+이벤트 무효화로 정합성 유지.' },
247
+ { question: '무중단으로 결제 DB를 마이그레이션한 경험을 설명해 주세요.', intent: '운영 안정성과 단계적 전환 설계 역량', followups: ['롤백 전략은?', '데이터 정합성 검증은?'], answer_outline: '듀얼 라이트 → 과거 데이터 백필 → 신구 비교 검증 → 점진 트래픽 컷오버. 각 단계가 가역적이라 언제든 롤백 가능하게 설계.' },
248
+ { question: '대용량 트래픽에서 동시성 문제를 해결한 사례가 있나요?', intent: '분산 환경 동시성 이해도', followups: ['분산 락의 한계는?', '멱등성은 어떻게 보장했나요?'], answer_outline: '주문 폭주 시 재고 오버셀 → 분산 락(Redis) + 멱등 키로 중복 처리 차단. 락 경합이 큰 구간은 큐 기반 직렬화로 전환해 오버셀 0건 달성.' },
249
+ { question: '결제처럼 정확성이 중요한 도메인에서 장애가 났을 때 어떻게 대응하나요?', intent: '장애 대응 원칙과 우선순위 판단', followups: ['사후 분석은 어떻게 하나요?'], answer_outline: '1) 영향 범위 격리·차단(서킷브레이커) 2) 정합성 우선 — 의심 거래는 보류 처리 3) 복구 후 정산 재대사 4) 블레임리스 포스트모템으로 재발 방지 액션화.' },
250
+ { question: '함께 일하기 좋은 동료/팀 문화는 무엇이라고 생각하나요?', intent: '컬처핏', followups: ['코드 리뷰 문화에 대한 생각은?'], answer_outline: '문제를 숨기지 않고 일찍 공유하는 문화, 그리고 결정의 "왜"를 문서로 남기는 팀. 리뷰는 사람이 아니라 코드/설계를 향하게.' },
251
+ ],
252
+ star_guides: [
253
+ { question: '가장 어려웠던 장애 대응 경험은?', situation: '월말 정산 배치가 지연되며 마감 시한을 넘길 위험에 처함', task: '4시간 내 정산을 정확히 완료하고 재발을 막아야 했음', action: '병목 배치를 병렬화하고 인덱스를 재설계, 핫픽스를 무중단 배포 후 검증 파이프라인으로 정합성 확인', result: '처리 시간을 38분으로 단축하고 이후 동일 장애 재발 0건, 정산 SLA를 안정화' },
254
+ { question: '주도적으로 팀의 문제를 개선한 경험은?', situation: '배포가 주 1회로 묶여 기능 출시가 느리고 롤백이 어려웠음', task: '배포 빈도를 높이면서도 안정성을 잃지 않아야 했음', action: 'GitHub Actions + ArgoCD로 카나리 배포 자동화, 헬스체크 기반 자동 롤백 구성', result: '배포 빈도 주1회 → 일5회, 롤백 평균 12분 → 90초로 단축' },
255
+ ],
256
+ },
257
+ ],
258
+
259
+ activities: [
260
+ { id: 'ac1', type: 'application_status_changed', entity_type: 'application', entity_id: 'a1', summary: '토스페이먼츠 — 면접 진행으로 상태를 변경했습니다.', created_at: '2026-06-13T10:00:00.000Z' },
261
+ { id: 'ac2', type: 'interview_prep_saved', entity_type: 'interview_prep', entity_id: 'iv1', summary: '토스페이먼츠 면접 준비 자료를 저장했습니다.', created_at: '2026-06-12T14:00:00.000Z' },
262
+ { id: 'ac3', type: 'application_status_changed', entity_type: 'application', entity_id: 'a4', summary: '네이버페이 — 서류 합격으로 상태를 변경했습니다.', created_at: '2026-06-12T10:00:00.000Z' },
263
+ { id: 'ac4', type: 'cover_letter_version_saved', entity_type: 'cover_letter', entity_id: 'c1', summary: '토스페이먼츠 지원동기 v2를 저장했습니다.', created_at: '2026-06-08T11:00:00.000Z' },
264
+ { id: 'ac5', type: 'fit_analysis_saved', entity_type: 'fit_analysis', entity_id: 'f1', summary: '토스페이먼츠 적합도 분석(88점)을 저장했습니다.', created_at: '2026-06-02T09:30:00.000Z' },
265
+ { id: 'ac6', type: 'job_saved', entity_type: 'job', entity_id: 'j6', summary: '카카오뱅크 공고를 저장했습니다.', created_at: '2026-06-10T09:00:00.000Z' },
266
+ { id: 'ac7', type: 'resume_added', entity_type: 'document', entity_id: 'd1', summary: '김하늘 이력서(2026)를 추가했습니다.', created_at: '2026-05-03T10:00:00.000Z' },
267
+ ],
268
+ };
269
+ }
@@ -19,6 +19,7 @@ const view = document.getElementById('view');
19
19
  const navEl = document.getElementById('nav');
20
20
  const titleEl = document.getElementById('topbar-title');
21
21
  const actionsEl = document.getElementById('topbar-actions');
22
+ const routeStatusEl = document.getElementById('route-status');
22
23
  const moduleCache = new Map();
23
24
 
24
25
  function parseHash() {
@@ -87,23 +88,54 @@ async function route() {
87
88
  if (token !== renderToken) return; // a newer navigation superseded this one
88
89
  clear(view);
89
90
  await mod.render(ctx);
91
+ // Announce just the new page name to screen readers (titleEl reflects any
92
+ // detail-page ctx.setTitle override).
93
+ if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || def.label;
90
94
  } catch (err) {
91
95
  if (token !== renderToken) return;
92
96
  clear(view);
93
97
  view.append(el('div', { class: 'card' }, el('div', { class: 'card__body' },
94
98
  el('h3', { style: { marginBottom: '8px' } }, '페이지를 불러오지 못했습니다'),
95
- el('p', { class: 'text-secondary' }, err instanceof Error ? err.message : String(err)))));
99
+ el('p', { class: 'text-secondary', style: { marginBottom: '14px' } }, err instanceof Error ? err.message : String(err)),
100
+ el('button', { class: 'btn btn--primary', type: 'button', onClick: () => route() }, '다시 시도'))));
96
101
  toastError(err);
97
102
  }
98
103
  }
99
104
 
105
+ // Mobile/narrow-window nav: the sidebar is the only navigation, so below 900px it
106
+ // becomes an off-canvas drawer toggled from the topbar. Without this a phone/narrow
107
+ // window is stranded on whatever page loaded first.
108
+ function setupMobileNav() {
109
+ const toggle = document.getElementById('nav-toggle');
110
+ const scrim = document.getElementById('nav-scrim');
111
+ const app = document.querySelector('.app');
112
+ if (!toggle || !app) return;
113
+ const setOpen = (open) => {
114
+ app.classList.toggle('nav-open', open);
115
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
116
+ toggle.setAttribute('aria-label', open ? '메뉴 닫기' : '메뉴 열기');
117
+ };
118
+ toggle.addEventListener('click', () => setOpen(!app.classList.contains('nav-open')));
119
+ scrim?.addEventListener('click', () => setOpen(false));
120
+ navEl.addEventListener('click', (e) => { if (e.target.closest('.nav__item')) setOpen(false); });
121
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && app.classList.contains('nav-open')) setOpen(false); });
122
+ }
123
+
100
124
  function initTheme() {
101
- const saved = localStorage.getItem('cf-theme');
125
+ // Read new key, falling back to the legacy 'cf-theme' so existing users keep their choice.
126
+ const saved = localStorage.getItem('careermate-theme') || localStorage.getItem('cf-theme');
102
127
  if (saved === 'light' || saved === 'dark') document.documentElement.dataset.theme = saved;
103
128
  }
104
129
 
105
130
  async function boot() {
106
131
  initTheme();
132
+ setupMobileNav();
133
+ // Skip link: jump keyboard focus past the sidebar to the page content. A hash
134
+ // anchor would collide with the router, so move focus directly instead.
135
+ document.getElementById('skip-link')?.addEventListener('click', () => {
136
+ view.setAttribute('tabindex', '-1');
137
+ view.focus();
138
+ });
107
139
  // Show data location in the footer (privacy: user can always see where data lives).
108
140
  try {
109
141
  const h = await get('/api/health');
@@ -13,8 +13,10 @@
13
13
  <link rel="stylesheet" href="/styles.css" />
14
14
  </head>
15
15
  <body>
16
+ <button class="skip-link" id="skip-link" type="button">본문으로 건너뛰기</button>
16
17
  <div class="app">
17
- <aside class="sidebar">
18
+ <div class="nav-scrim" id="nav-scrim"></div>
19
+ <aside class="sidebar" id="sidebar">
18
20
  <div class="sidebar__brand">
19
21
  <div class="sidebar__logo">C</div>
20
22
  <div>
@@ -31,12 +33,18 @@
31
33
 
32
34
  <div class="main">
33
35
  <header class="topbar">
36
+ <button class="nav-toggle" id="nav-toggle" type="button" aria-label="메뉴 열기" aria-expanded="false" aria-controls="sidebar">
37
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
38
+ </button>
34
39
  <div class="topbar__title" id="topbar-title">CareerMate</div>
35
40
  <div class="topbar__spacer"></div>
36
41
  <div id="topbar-actions" class="flex gap-2"></div>
37
42
  </header>
38
- <main id="view" class="view" aria-live="polite"></main>
43
+ <main id="view" class="view"></main>
39
44
  </div>
45
+ <!-- Concise route announcer: speaks only the new page name, not the whole view
46
+ (the old aria-live on #view re-read the entire page on every navigation). -->
47
+ <div id="route-status" class="sr-only" role="status" aria-live="polite"></div>
40
48
  </div>
41
49
 
42
50
  <script type="module" src="/app.js"></script>
@@ -10,6 +10,19 @@
10
10
 
11
11
  /* --------------------------------------------------------------- DOM helper */
12
12
 
13
+ /**
14
+ * Neutralize script-capable URL schemes before they reach an href/src. Career
15
+ * data (profile links, job URLs) is user-controlled and can arrive via the MCP
16
+ * path with no CSRF gate, so a stored `javascript:…` link would execute when
17
+ * clicked. http(s)/mailto and scheme-less/relative URLs pass through unchanged;
18
+ * any other explicit scheme is replaced with a harmless '#'.
19
+ */
20
+ export function safeUrl(v) {
21
+ const s = String(v).trim();
22
+ if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return /^(https?:|mailto:)/i.test(s) ? s : '#';
23
+ return s;
24
+ }
25
+
13
26
  /**
14
27
  * Create an element. `props` may include: class, id, type, value, placeholder,
15
28
  * href, title, disabled, dataset:{}, style:{}, attrs:{}, and on* event handlers
@@ -27,7 +40,19 @@ export function el(tag, props = {}, ...children) {
27
40
  else if (k === 'dataset') Object.assign(node.dataset, v);
28
41
  else if (k === 'style') Object.assign(node.style, v);
29
42
  else if (k === 'attrs') for (const [a, val] of Object.entries(v)) node.setAttribute(a, val);
43
+ else if (k === 'href' || k === 'src') node.setAttribute(k, safeUrl(v)); // block javascript:/data: URLs
30
44
  else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
45
+ else if (k === 'activate' && typeof v === 'function') {
46
+ // Make a non-native element behave like a button for keyboard + AT users:
47
+ // role, focusability, and Enter/Space activation. Use this instead of a bare
48
+ // onClick on clickable <div>/<span> so they aren't mouse-only dead zones.
49
+ if (!node.getAttribute('role')) node.setAttribute('role', 'button');
50
+ if (node.getAttribute('tabindex') == null) node.setAttribute('tabindex', '0');
51
+ node.addEventListener('click', v);
52
+ node.addEventListener('keydown', (e) => {
53
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); v(e); }
54
+ });
55
+ }
31
56
  else if (k in node) { try { node[k] = v; } catch { node.setAttribute(k, v); } }
32
57
  else node.setAttribute(k, v);
33
58
  }
@@ -40,7 +65,6 @@ function appendChildren(node, children) {
40
65
  node.append(c instanceof Node ? c : document.createTextNode(String(c)));
41
66
  }
42
67
  }
43
- export function frag(...children) { const f = document.createDocumentFragment(); appendChildren(f, children); return f; }
44
68
  export function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); return node; }
45
69
  export function mount(node, ...children) { clear(node); appendChildren(node, children); return node; }
46
70
 
@@ -94,19 +118,33 @@ const TOKEN = document.querySelector('meta[name="careermate-token"]')?.content |
94
118
 
95
119
  /** Call the JSON API. Throws Error(message) on non-2xx. */
96
120
  export async function api(method, path, body) {
97
- const res = await fetch(path, {
98
- method,
99
- headers: {
100
- 'Content-Type': 'application/json',
101
- ...(method !== 'GET' ? { 'x-careermate-token': TOKEN } : {}),
102
- },
103
- body: body !== undefined ? JSON.stringify(body) : undefined,
104
- });
121
+ let res;
122
+ try {
123
+ res = await fetch(path, {
124
+ method,
125
+ headers: {
126
+ 'Content-Type': 'application/json',
127
+ ...(method !== 'GET' ? { 'x-careermate-token': TOKEN } : {}),
128
+ },
129
+ body: body !== undefined ? JSON.stringify(body) : undefined,
130
+ });
131
+ } catch {
132
+ // Connection failure (server stopped, machine asleep) — never surface "Failed to fetch".
133
+ throw new Error('서버에 연결하지 못했어요. 대시보드가 실행 중인지 확인한 뒤 다시 시도해 주세요.');
134
+ }
105
135
  const text = await res.text();
106
- const data = text ? JSON.parse(text) : {};
107
- if (!res.ok) throw new Error(data.error || `요청 실패 (${res.status})`);
136
+ let data = {};
137
+ if (text) { try { data = JSON.parse(text); } catch { /* non-JSON error page */ } }
138
+ if (!res.ok) throw new Error(data.error || friendlyStatus(res.status));
108
139
  return data;
109
140
  }
141
+ // Friendly fallback when the server didn't supply a message — never show a raw status code.
142
+ function friendlyStatus(status) {
143
+ if (status === 404) return '요청한 항목을 찾을 수 없어요. 새로고침 후 다시 시도해 주세요.';
144
+ if (status === 403) return '권한 확인이 필요해요. 대시보드를 새로고침해 주세요.';
145
+ if (status >= 500) return '일시적인 오류가 발생했어요. 잠시 후 다시 시도해 주세요.';
146
+ return '요청을 처리하지 못했어요. 입력을 확인하고 다시 시도해 주세요.';
147
+ }
110
148
  export const get = (p) => api('GET', p);
111
149
  export const post = (p, b) => api('POST', p, b);
112
150
  export const put = (p, b) => api('PUT', p, b);
@@ -134,6 +172,22 @@ export function scoreClass(score) {
134
172
  if (score >= 50) return 'score-mid';
135
173
  return 'score-weak';
136
174
  }
175
+ /** Textarea (newline-separated) → trimmed array, empties dropped. */
176
+ export function linesToArray(str) { return String(str || '').split('\n').map((s) => s.trim()).filter(Boolean); }
177
+ /** CSV ("a, b, c") → trimmed array, empties dropped. */
178
+ export function csvToArray(str) { return String(str || '').split(',').map((s) => s.trim()).filter(Boolean); }
179
+
180
+ /**
181
+ * Application status code → saturated theme colour. Single source of truth so a
182
+ * colour means the same pipeline stage everywhere (home pipeline bar/dots,
183
+ * applications board dots, badges). MUST track the badge--{status} hues in CSS.
184
+ */
185
+ export const STATUS_COLOR = {
186
+ draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
187
+ document_passed: 'var(--teal)', interview: 'var(--amber)',
188
+ final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
189
+ };
190
+ export const statusColor = (code) => STATUS_COLOR[code] || 'var(--text-tertiary)';
137
191
 
138
192
  /* ---------------------------------------------------------------- routing */
139
193
  export function navigate(hash) { location.hash = hash.startsWith('#') ? hash : '#' + hash; }
@@ -149,17 +203,50 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
149
203
  export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
150
204
  return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
151
205
  }
206
+ /**
207
+ * A footer button for async saves. While `action()` runs the button is disabled
208
+ * (so a slow local round-trip can't be double-clicked into duplicate records),
209
+ * and if it throws the message is shown in the modal's persistent `.modal__error`
210
+ * slot (not a transient toast) so the user sees why the save failed and can fix
211
+ * and retry. `action` should THROW on failure (validation or API) and is expected
212
+ * to call close()/reload() itself on success.
213
+ */
214
+ export function SubmitBtn(label, action, { variant = 'primary', icon: ic } = {}) {
215
+ let busy = false;
216
+ const btn = Btn(label, {
217
+ variant,
218
+ icon: ic,
219
+ onClick: async () => {
220
+ if (busy) return;
221
+ busy = true;
222
+ btn.disabled = true;
223
+ const slot = btn.closest('.modal')?.querySelector('.modal__error');
224
+ if (slot) { slot.hidden = true; slot.textContent = ''; }
225
+ try {
226
+ await action();
227
+ } catch (e) {
228
+ const msg = e instanceof Error ? e.message : String(e);
229
+ if (slot) { slot.textContent = msg; slot.hidden = false; slot.scrollIntoView?.({ block: 'nearest' }); }
230
+ else toastError(e);
231
+ } finally {
232
+ busy = false;
233
+ btn.disabled = false;
234
+ }
235
+ },
236
+ });
237
+ return btn;
238
+ }
152
239
  export function Card({ title, sub, actions, body, clickable, onClick } = {}) {
153
240
  const head = (title || actions) && el('div', { class: 'card__head' },
154
241
  title && el('h3', {}, title),
155
242
  sub && el('span', { class: 'sub' }, sub),
156
243
  actions && el('div', { class: 'right' }, ...[].concat(actions)),
157
244
  );
158
- return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, onClick },
245
+ return el('div', { class: `card${clickable ? ' is-clickable' : ''}`, activate: onClick },
159
246
  head, el('div', { class: 'card__body' }, ...[].concat(body || [])));
160
247
  }
161
- export function Stat({ label, value, hint, iconName }) {
162
- return el('div', { class: 'stat' },
248
+ export function Stat({ label, value, hint, iconName, onClick }) {
249
+ return el('div', { class: 'stat', activate: onClick },
163
250
  el('div', { class: 'stat__label' }, iconName && icon(iconName), label),
164
251
  el('div', { class: 'stat__value tnum' }, value),
165
252
  hint && el('div', { class: 'stat__hint' }, hint),
@@ -178,7 +265,7 @@ export function Chips(items, { accent } = {}) {
178
265
  }
179
266
  /** Dense list row: leading slot → title + muted sub → right-aligned trailing. */
180
267
  export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
181
- return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, onClick },
268
+ return el('div', { class: `list-row${onClick ? ' is-clickable' : ''}`, activate: onClick },
182
269
  leading && el('div', { class: 'list-row__lead' }, leading),
183
270
  el('div', { class: 'list-row__main' },
184
271
  el('div', { class: 'list-row__title' }, title),
@@ -187,9 +274,11 @@ export function ListRow({ leading, title, sub, trailing, onClick } = {}) {
187
274
  }
188
275
  /** Onboarding checklist row: hollow dot (todo, links via onClick) or green check (done, struck through). */
189
276
  export function CheckRow({ done, label, onClick } = {}) {
190
- return el('div', { class: `check-row${done ? ' is-done' : ''}` },
277
+ // Whole row is the activation target (bigger hit area + keyboard) when it's a todo.
278
+ const interactive = !done && typeof onClick === 'function';
279
+ return el('div', { class: `check-row${done ? ' is-done' : ''}`, activate: interactive ? onClick : undefined },
191
280
  done ? icon('check', 'check-row__icon') : el('span', { class: 'check-row__dot' }),
192
- el('span', { class: `check-row__label${!done && onClick ? ' is-link' : ''}`, onClick: done ? null : onClick }, label));
281
+ el('span', { class: `check-row__label${interactive ? ' is-link' : ''}` }, label));
193
282
  }
194
283
  export function Field(label, control, hint) {
195
284
  return el('div', { class: 'field' }, label && el('label', {}, label), control, hint && el('div', { class: 'hint' }, hint));
@@ -222,6 +311,8 @@ export function toastOk(msg) { toast(msg, { type: 'success' }); }
222
311
 
223
312
  /* ------------------------------------------------------------------ modal */
224
313
  let modalRoot;
314
+ let modalReturnFocus = null;
315
+ const MODAL_FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
225
316
  function ensureModalRoot() {
226
317
  if (!modalRoot) {
227
318
  modalRoot = el('div', { class: 'modal-root' });
@@ -230,7 +321,11 @@ function ensureModalRoot() {
230
321
  }
231
322
  return modalRoot;
232
323
  }
233
- export function closeModal() { if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); } }
324
+ export function closeModal() {
325
+ if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); }
326
+ // Return focus to whatever opened the modal so keyboard users aren't dumped at the page top.
327
+ if (modalReturnFocus) { try { modalReturnFocus.focus(); } catch { /* element gone */ } modalReturnFocus = null; }
328
+ }
234
329
  /**
235
330
  * Open a modal. `render(close)` returns the modal body content. Provide title,
236
331
  * optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
@@ -238,18 +333,36 @@ export function closeModal() { if (modalRoot) { modalRoot.classList.remove('is-o
238
333
  export function openModal({ title, body, footer, size }) {
239
334
  const root = ensureModalRoot();
240
335
  const close = closeModal;
336
+ modalReturnFocus = document.activeElement; // restored by closeModal
241
337
  const closeBtn = IconBtn('plus', { title: '닫기', onClick: close }); // rotated to look like ×
242
338
  closeBtn.style.transform = 'rotate(45deg)';
243
- const modal = el('div', { class: `modal${size === 'lg' ? ' modal--lg' : ''}` },
244
- el('div', { class: 'modal__head' }, el('h3', {}, title), el('div', { class: 'topbar__spacer' }), closeBtn),
339
+ const modal = el('div', {
340
+ class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
341
+ attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
342
+ },
343
+ el('div', { class: 'modal__head' }, el('h3', { id: 'modal-title' }, title), el('div', { class: 'topbar__spacer' }), closeBtn),
245
344
  );
345
+ // Focus trap: keep Tab / Shift+Tab inside the dialog instead of leaking to the page behind.
346
+ modal.addEventListener('keydown', (e) => {
347
+ if (e.key !== 'Tab') return;
348
+ const f = [...modal.querySelectorAll(MODAL_FOCUSABLE)].filter((x) => x.offsetParent !== null);
349
+ if (!f.length) return;
350
+ const first = f[0], last = f[f.length - 1];
351
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
352
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
353
+ });
246
354
  const bodyNode = el('div', { class: 'modal__body' });
247
355
  appendChildren(bodyNode, [typeof body === 'function' ? body(close) : body]);
248
356
  modal.append(bodyNode);
357
+ // Persistent inline error slot so a failed save shows a clear reason instead of
358
+ // relying on a 3.8s toast. Placed between body and footer (always visible). Any
359
+ // SubmitBtn inside the modal writes here — including inline editors in bodies
360
+ // that have no footer; see SubmitBtn.
361
+ modal.append(el('div', { class: 'modal__error', hidden: true, attrs: { role: 'alert', 'aria-live': 'assertive' } }));
249
362
  if (footer) modal.append(el('div', { class: 'modal__foot' }, ...[].concat(footer(close))));
250
363
  mount(root, el('div', { class: 'modal__scrim', onClick: close }), modal);
251
364
  root.classList.add('is-open');
252
- setTimeout(() => bodyNode.querySelector('input,textarea,select,button')?.focus(), 30);
365
+ setTimeout(() => (bodyNode.querySelector('input,textarea,select') || modal.querySelector(MODAL_FOCUSABLE))?.focus(), 30);
253
366
  }
254
367
  /** Confirmation dialog. Returns a Promise<boolean>. */
255
368
  export function confirmDialog({ title = '확인', message, confirmLabel = '확인', danger } = {}) {
@@ -275,7 +388,6 @@ export function downloadUrl(url) {
275
388
  const a = el('a', { href: url, download: '' });
276
389
  document.body.append(a); a.click(); a.remove();
277
390
  }
278
- export function debounce(fn, ms = 250) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
279
391
 
280
392
  /** Shared meta (statuses, document kinds) loaded once. */
281
393
  let _meta = null;