careermate 0.1.2 → 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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -1
  3. package/apps/web/public/pages/documents.js +7 -2
  4. package/apps/web/public/pages/home.js +4 -7
  5. package/apps/web/public/pages/jobs.js +1 -5
  6. package/apps/web/public/pages/profile.js +3 -3
  7. package/apps/web/public/styles.css +7 -2
  8. package/dist/doctor.mjs +112 -90
  9. package/dist/install-page/analytics.js +97 -0
  10. package/dist/install-page/index.html +77 -56
  11. package/dist/install-page/og.png +0 -0
  12. package/dist/install-page/robots.txt +4 -0
  13. package/dist/install-page/sitemap.xml +21 -0
  14. package/dist/install-page/start.html +13 -10
  15. package/dist/install-page/style.css +75 -40
  16. package/dist/mcp.mjs +112 -90
  17. package/dist/migrate.mjs +95 -82
  18. package/dist/public/pages/documents.js +7 -2
  19. package/dist/public/pages/home.js +4 -7
  20. package/dist/public/pages/jobs.js +1 -5
  21. package/dist/public/pages/profile.js +3 -3
  22. package/dist/public/styles.css +7 -2
  23. package/dist/web.mjs +113 -86
  24. package/package.json +1 -2
  25. package/packages/db/src/repositories.ts +18 -1
  26. package/packages/mcp-tools/src/tools.ts +5 -5
  27. package/packages/parsers/src/documents.ts +13 -1
  28. package/packages/shared/src/schemas.ts +115 -81
  29. package/scripts/build-dist.mjs +7 -1
  30. package/scripts/gen-og.mjs +1 -1
  31. package/scripts/test.ts +25 -0
  32. package/scripts/ui-smoke.ts +32 -4
  33. package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +0 -12
  34. package/dist/install-page/.omc/state/idle-notif-cooldown.json +0 -3
  35. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +0 -147
  36. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +0 -22
  37. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +0 -44
  38. package/docs/ARCHITECTURE.md +0 -208
  39. package/docs/CHANGES_V1.md +0 -103
  40. package/docs/DATA_MODEL.md +0 -460
  41. package/docs/DECISIONS.md +0 -277
  42. package/docs/DEMO.md +0 -242
  43. package/docs/MCP_TOOLS.md +0 -292
  44. package/docs/ROADMAP.md +0 -135
  45. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +0 -135
  46. package/docs/START_WORKFLOW.md +0 -137
  47. package/docs/SUPPORTED_AI_APPS.md +0 -112
  48. package/docs/TODO.md +0 -57
  49. package/docs/UX_NOTES.md +0 -247
  50. package/docs/WORKFLOWS.md +0 -202
  51. package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +0 -64
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CareerMate
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -240,7 +240,9 @@ CareerMate/
240
240
  - [`AGENTS.md`](AGENTS.md) — Codex용 영속 지침. / [`CLAUDE.md`](CLAUDE.md) — Claude Code용 영속 지침.
241
241
  - [`docs/SUPPORTED_AI_APPS.md`](docs/SUPPORTED_AI_APPS.md) — **설치 레퍼런스 허브**: 지원 앱 매트릭스 + 설치 3방식(에이전트/npx/.mcpb) + 클라이언트별 수동 MCP 설정 + 알려진 제약. (사람용 안내는 https://careermate.life)
242
242
  - [`docs/START_WORKFLOW.md`](docs/START_WORKFLOW.md) — 등록부터 면접 준비까지 단계별 작업 런북.
243
+ - [`docs/FAQ.md`](docs/FAQ.md) — **자주 묻는 질문 / 문제 해결**: "PDF가 안 읽혀요", "분석이 안 돼요", "공고 URL은 어떻게 넣어요" 등.
243
244
  - [`docs/TODO.md`](docs/TODO.md) — 남은 작업과 v1 범위 밖 항목.
245
+ - [`CHANGELOG.md`](CHANGELOG.md) — 버전별 변경 이력. / [`CONTRIBUTING.md`](CONTRIBUTING.md) · [`SECURITY.md`](SECURITY.md) · [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) — 기여·보안·행동 강령.
244
246
 
245
247
  ---
246
248
 
@@ -258,4 +260,4 @@ npm run typecheck
258
260
 
259
261
  ## 라이선스
260
262
 
261
- MIT 라이선스 — npm으로 배포되는 패키지 코드에 적용됩니다. (개발용 GitHub 저장소는 현재 비공개입니다.)
263
+ MIT 라이선스 — 전문은 [`LICENSE`](LICENSE) 파일을 참고하세요. (개발용 GitHub 저장소는 현재 비공개입니다.)
@@ -23,6 +23,7 @@ export async function render(ctx) {
23
23
  root.append(el('div', { class: 'tabs' }, tabCover, tabDocs), panel);
24
24
 
25
25
  let query = '';
26
+ let searchInput = null;
26
27
  function selectTab(next) {
27
28
  tab = next;
28
29
  query = '';
@@ -43,9 +44,12 @@ export async function render(ctx) {
43
44
  if (!hide) shown += 1;
44
45
  }
45
46
  const existing = panel.querySelector('.search-empty');
47
+ if (existing) existing.remove();
46
48
  if (q && cards.length && shown === 0) {
47
- if (!existing) panel.append(el('p', { class: 'search-empty muted', style: { textAlign: 'center', margin: '8px 0' } }, '검색 결과가 없어요.'));
48
- } else if (existing) existing.remove();
49
+ panel.append(el('div', { class: 'search-empty empty' },
50
+ el('p', { class: 'muted', style: { margin: '0 0 12px' } }, `‘${query.trim()}’에 맞는 항목이 없어요.`),
51
+ Btn('필터 초기화', { sm: true, variant: 'ghost', onClick: () => { query = ''; if (searchInput) searchInput.value = ''; applyFilter(); } })));
52
+ }
49
53
  }
50
54
 
51
55
  async function reload() {
@@ -70,6 +74,7 @@ export async function render(ctx) {
70
74
  function setActions() {
71
75
  const search = Input({ type: 'search', placeholder: tab === 'cover' ? '자기소개서 검색' : '문서 검색', value: query, attrs: { 'aria-label': '검색' } });
72
76
  search.classList.add('input--inline');
77
+ searchInput = search;
73
78
  search.addEventListener('input', () => { query = search.value; applyFilter(); });
74
79
  const createBtn = tab === 'cover'
75
80
  ? Btn('새 자기소개서', { icon: 'plus', variant: 'primary', onClick: () => openCoverCreate(reload) })
@@ -4,7 +4,7 @@
4
4
  // the 시작하기 card. Every empty state is ONE screen-specific sentence; never repeat
5
5
  // the same "AI에게 보내면 저장됩니다" instruction across surfaces.
6
6
  import {
7
- el, get, icon, navigate, Card, Badge, Btn, EmptyState, ListRow, CheckRow,
7
+ el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
8
8
  Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
9
9
  } from '/lib.js';
10
10
 
@@ -59,7 +59,7 @@ function Greeting(name, s, firstRun) {
59
59
  let focus;
60
60
  if (firstRun) focus = '시작해 볼까요 — 먼저 기본 정보를 채워 주세요.';
61
61
  else if (s.interview_todo.length) focus = `면접 준비가 필요한 지원 ${s.interview_todo.length}건이 있어요.`;
62
- else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건을 확인하세요.`;
62
+ else if (s.in_progress.length) focus = `진행 중인 지원 ${s.in_progress.length}건이 있어요.`;
63
63
  else if (s.counts.jobs) focus = `저장된 공고 ${s.counts.jobs}건 · 새 적합도 분석을 받아보세요.`;
64
64
  else focus = '오늘도 한 걸음 나아가 볼까요.';
65
65
  return PageHead({ title: name ? `${name}님, 안녕하세요` : '환영합니다', desc: focus });
@@ -87,7 +87,7 @@ function GettingStarted(o) {
87
87
  el('div', { class: 'callout__body' },
88
88
  '공고 분석·자기소개서·면접 준비는 평소 쓰던 AI에게 말로 시키면, 결과가 이 대시보드에 쌓여요.'))),
89
89
  el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
90
- el('p', { class: 'text-secondary text-sm', style: { margin: '12px 0 8px' } },
90
+ el('p', { class: 'text-secondary text-sm mt-3 mb-2' },
91
91
  '완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
92
92
  el('div', {}, ...steps.map((it) => CheckRow({ done: it.done, label: it.label, onClick: () => navigate(it.to) }))),
93
93
  ],
@@ -201,10 +201,7 @@ function RecentJobs(jobs) {
201
201
  if (!jobs.length) {
202
202
  return Card({
203
203
  title: '최근 공고',
204
- body: EmptyState({
205
- iconName: 'briefcase', title: '저장된 공고가 아직 없어요',
206
- action: Btn('공고 추가', { icon: 'plus', variant: 'ghost', onClick: () => navigate('/jobs') }),
207
- }),
204
+ body: el('p', { class: 'muted', style: { margin: 0 } }, '저장된 공고가 아직 없어요. 새 공고를 추가해 보세요.'),
208
205
  });
209
206
  }
210
207
  const rows = jobs.map((j) => ListRow({
@@ -365,11 +365,7 @@ function FitCard(fit) {
365
365
  if (!fit) {
366
366
  return Card({
367
367
  title: '적합도 분석',
368
- body: EmptyState({
369
- iconName: 'target',
370
- title: '적합도 분석 전이에요',
371
- body: '이 공고와 내 프로필을 비교한 분석 결과가 여기에 표시됩니다.',
372
- }),
368
+ body: el('p', { class: 'muted', style: { margin: 0 } }, '아직 분석 전이에요. 이 공고와 내 프로필을 비교한 결과가 여기에 표시됩니다.'),
373
369
  });
374
370
  }
375
371
 
@@ -264,7 +264,7 @@ function editWriting(p, reload) {
264
264
  /* ---------------------------------------------------- 4. 경력 (Experiences) */
265
265
 
266
266
  function ExperiencesCard(items, reload) {
267
- 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) });
268
268
 
269
269
  if (!items.length) {
270
270
  return Card({
@@ -368,7 +368,7 @@ function editExperience(x, reload) {
368
368
  /* ----------------------------------------------------- 5. 프로젝트 (Projects) */
369
369
 
370
370
  function ProjectsCard(items, reload) {
371
- 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) });
372
372
 
373
373
  if (!items.length) {
374
374
  return Card({
@@ -469,7 +469,7 @@ function editProject(x, reload) {
469
469
  /* -------------------------------------------------------- 6. 기술 (Skills) */
470
470
 
471
471
  function SkillsCard(items, reload) {
472
- 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) });
473
473
 
474
474
  if (!items.length) {
475
475
  return Card({
@@ -403,7 +403,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
403
403
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px 26px; max-height: 60vh; overflow-y: auto; }
404
404
  .kv { display: grid; grid-template-columns: 132px 1fr; gap: 8px 16px; }
405
405
  .kv dt { color: var(--text-tertiary); font-size: 13px; }
406
- .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; }
407
409
 
408
410
  /* ----------------------------------------------------------------- modal */
409
411
  .modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
@@ -450,7 +452,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
450
452
  .between { justify-content: space-between; } .center { align-items: center; } .wrap { flex-wrap: wrap; }
451
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;}
452
454
  .tnum { font-variant-numeric: tabular-nums; }
453
- .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; }
454
459
  .divider { height: 1px; background: var(--border); margin: 16px 0; }
455
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; }
456
461
  @keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
package/dist/doctor.mjs CHANGED
@@ -75561,29 +75561,42 @@ function isSafeUrl(u) {
75561
75561
  return !/^[a-z][a-z0-9+.-]*:/i.test(s) || /^(https?:|mailto:)/i.test(s);
75562
75562
  }
75563
75563
  var UNSAFE_URL_MSG = "javascript: \uAC19\uC740 \uC704\uD5D8\uD55C \uB9C1\uD06C\uB294 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. http(s) \uB9C1\uD06C\uB97C \uC0AC\uC6A9\uD558\uC138\uC694.";
75564
- var SafeUrl = external_exports.string().min(1).refine(isSafeUrl, { message: UNSAFE_URL_MSG });
75565
- var SafeUrlOptional = external_exports.string().optional().refine((u) => u == null || u === "" || isSafeUrl(u), { message: UNSAFE_URL_MSG });
75564
+ var MAX_URL = 2e3;
75565
+ var SafeUrl = external_exports.string().min(1).max(MAX_URL).refine(isSafeUrl, { message: UNSAFE_URL_MSG });
75566
+ var SafeUrlOptional = external_exports.string().max(MAX_URL).optional().refine((u) => u == null || u === "" || isSafeUrl(u), { message: UNSAFE_URL_MSG });
75567
+ var MAX_LINE = 500;
75568
+ var MAX_NOTE = 1e4;
75569
+ var MAX_BODY = 2e5;
75570
+ var MAX_ITEM = 2e3;
75571
+ var MAX_ITEMS = 500;
75572
+ var EMPTY_MSG = "\uB0B4\uC6A9\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4(\uACF5\uBC31\uB9CC\uC73C\uB85C\uB294 \uC800\uC7A5\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4).";
75573
+ var reqLine = external_exports.string().trim().min(1).max(MAX_LINE);
75574
+ var optLine = external_exports.string().max(MAX_LINE).optional();
75575
+ var optNote = external_exports.string().max(MAX_NOTE).optional();
75576
+ var optBody = external_exports.string().max(MAX_BODY).optional();
75577
+ var reqBody = external_exports.string().max(MAX_BODY).refine((s) => s.trim().length > 0, { message: EMPTY_MSG });
75578
+ var strList = external_exports.array(external_exports.string().max(MAX_ITEM)).max(MAX_ITEMS);
75566
75579
  var baseRecord = {
75567
75580
  id,
75568
75581
  created_at: isoDateTime,
75569
75582
  updated_at: isoDateTime
75570
75583
  };
75571
75584
  var LinkSchema = external_exports.object({
75572
- label: external_exports.string().min(1),
75585
+ label: reqLine,
75573
75586
  url: SafeUrl
75574
75587
  });
75575
75588
  var ProfileInputSchema = external_exports.object({
75576
- name: external_exports.string().min(1).optional(),
75577
- email: external_exports.string().optional(),
75578
- phone: external_exports.string().optional(),
75579
- location: external_exports.string().optional(),
75580
- headline: external_exports.string().optional().describe("\uD55C \uC904 \uC18C\uAC1C / \uC9C1\uBB34 \uD0C0\uC774\uD2C0"),
75581
- summary: external_exports.string().optional().describe("\uC790\uAE30\uC18C\uAC1C \uC694\uC57D"),
75582
- desired_roles: external_exports.array(external_exports.string()).optional().describe("\uD76C\uB9DD \uC9C1\uBB34"),
75583
- desired_conditions: external_exports.string().optional().describe("\uD76C\uB9DD \uADFC\uBB34 \uC870\uAC74 (\uC5F0\uBD09, \uC9C0\uC5ED, \uADFC\uBB34\uD615\uD0DC \uB4F1)"),
75584
- preferred_tone: external_exports.string().optional().describe("\uC790\uAE30\uC18C\uAC1C\uC11C \uC120\uD638 \uBB38\uCCB4 (\uC608: \uB2F4\uBC31\uD558\uACE0 \uAD6C\uCCB4\uC801)"),
75585
- emphasis_points: external_exports.array(external_exports.string()).optional().describe("\uAC15\uC870\uD558\uACE0 \uC2F6\uC740 \uD575\uC2EC \uD3EC\uC778\uD2B8"),
75586
- links: external_exports.array(LinkSchema).optional().describe("\uD3EC\uD2B8\uD3F4\uB9AC\uC624/\uAE43\uD5C8\uBE0C/\uB9C1\uD06C\uB4DC\uC778 \uB4F1")
75589
+ name: reqLine.optional(),
75590
+ email: optLine,
75591
+ phone: optLine,
75592
+ location: optLine,
75593
+ headline: external_exports.string().max(MAX_LINE).optional().describe("\uD55C \uC904 \uC18C\uAC1C / \uC9C1\uBB34 \uD0C0\uC774\uD2C0"),
75594
+ summary: external_exports.string().max(MAX_NOTE).optional().describe("\uC790\uAE30\uC18C\uAC1C \uC694\uC57D"),
75595
+ desired_roles: strList.optional().describe("\uD76C\uB9DD \uC9C1\uBB34"),
75596
+ desired_conditions: external_exports.string().max(MAX_NOTE).optional().describe("\uD76C\uB9DD \uADFC\uBB34 \uC870\uAC74 (\uC5F0\uBD09, \uC9C0\uC5ED, \uADFC\uBB34\uD615\uD0DC \uB4F1)"),
75597
+ preferred_tone: external_exports.string().max(MAX_LINE).optional().describe("\uC790\uAE30\uC18C\uAC1C\uC11C \uC120\uD638 \uBB38\uCCB4 (\uC608: \uB2F4\uBC31\uD558\uACE0 \uAD6C\uCCB4\uC801)"),
75598
+ emphasis_points: strList.optional().describe("\uAC15\uC870\uD558\uACE0 \uC2F6\uC740 \uD575\uC2EC \uD3EC\uC778\uD2B8"),
75599
+ links: external_exports.array(LinkSchema).max(MAX_ITEMS).optional().describe("\uD3EC\uD2B8\uD3F4\uB9AC\uC624/\uAE43\uD5C8\uBE0C/\uB9C1\uD06C\uB4DC\uC778 \uB4F1")
75587
75600
  });
75588
75601
  var ProfileRecordSchema = external_exports.object({
75589
75602
  ...baseRecord,
@@ -75600,15 +75613,15 @@ var ProfileRecordSchema = external_exports.object({
75600
75613
  links: external_exports.array(LinkSchema)
75601
75614
  });
75602
75615
  var ExperienceInputSchema = external_exports.object({
75603
- company: external_exports.string().min(1),
75604
- role: external_exports.string().optional(),
75605
- employment_type: external_exports.string().optional().describe("\uC815\uADDC\uC9C1/\uACC4\uC57D\uC9C1/\uC778\uD134/\uD504\uB9AC\uB79C\uC11C \uB4F1"),
75606
- start_date: external_exports.string().optional().describe("YYYY-MM \uB610\uB294 YYYY-MM-DD"),
75607
- end_date: external_exports.string().optional().describe("\uC7AC\uC9C1 \uC911\uC774\uBA74 \uBE44\uC6CC\uB450\uAE30"),
75616
+ company: reqLine,
75617
+ role: optLine,
75618
+ employment_type: external_exports.string().max(MAX_LINE).optional().describe("\uC815\uADDC\uC9C1/\uACC4\uC57D\uC9C1/\uC778\uD134/\uD504\uB9AC\uB79C\uC11C \uB4F1"),
75619
+ start_date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM \uB610\uB294 YYYY-MM-DD"),
75620
+ end_date: external_exports.string().max(MAX_LINE).optional().describe("\uC7AC\uC9C1 \uC911\uC774\uBA74 \uBE44\uC6CC\uB450\uAE30"),
75608
75621
  is_current: external_exports.boolean().optional(),
75609
- description: external_exports.string().optional(),
75610
- achievements: external_exports.array(external_exports.string()).optional().describe("\uC131\uACFC/\uC5C5\uC801 (\uC815\uB7C9 \uC9C0\uD45C \uAD8C\uC7A5)"),
75611
- tech: external_exports.array(external_exports.string()).optional().describe("\uC0AC\uC6A9 \uAE30\uC220/\uB3C4\uAD6C"),
75622
+ description: optBody,
75623
+ achievements: strList.optional().describe("\uC131\uACFC/\uC5C5\uC801 (\uC815\uB7C9 \uC9C0\uD45C \uAD8C\uC7A5)"),
75624
+ tech: strList.optional().describe("\uC0AC\uC6A9 \uAE30\uC220/\uB3C4\uAD6C"),
75612
75625
  order_index: external_exports.number().optional()
75613
75626
  });
75614
75627
  var ExperienceRecordSchema = external_exports.object({
@@ -75625,14 +75638,14 @@ var ExperienceRecordSchema = external_exports.object({
75625
75638
  order_index: external_exports.number()
75626
75639
  });
75627
75640
  var ProjectInputSchema = external_exports.object({
75628
- name: external_exports.string().min(1),
75629
- role: external_exports.string().optional(),
75630
- description: external_exports.string().optional(),
75631
- highlights: external_exports.array(external_exports.string()).optional(),
75632
- tech: external_exports.array(external_exports.string()).optional(),
75641
+ name: reqLine,
75642
+ role: optLine,
75643
+ description: optBody,
75644
+ highlights: strList.optional(),
75645
+ tech: strList.optional(),
75633
75646
  url: SafeUrlOptional,
75634
- start_date: external_exports.string().optional(),
75635
- end_date: external_exports.string().optional(),
75647
+ start_date: optLine,
75648
+ end_date: optLine,
75636
75649
  order_index: external_exports.number().optional()
75637
75650
  });
75638
75651
  var ProjectRecordSchema = external_exports.object({
@@ -75648,9 +75661,9 @@ var ProjectRecordSchema = external_exports.object({
75648
75661
  order_index: external_exports.number()
75649
75662
  });
75650
75663
  var SkillInputSchema = external_exports.object({
75651
- name: external_exports.string().min(1),
75652
- category: external_exports.string().optional().describe("\uC5B8\uC5B4/\uD504\uB808\uC784\uC6CC\uD06C/\uD234/\uC18C\uD504\uD2B8\uC2A4\uD0AC \uB4F1"),
75653
- level: external_exports.string().optional().describe("\uC0C1/\uC911/\uD558 \uB610\uB294 \uC790\uC720 \uC11C\uC220"),
75664
+ name: reqLine,
75665
+ category: external_exports.string().max(MAX_LINE).optional().describe("\uC5B8\uC5B4/\uD504\uB808\uC784\uC6CC\uD06C/\uD234/\uC18C\uD504\uD2B8\uC2A4\uD0AC \uB4F1"),
75666
+ level: external_exports.string().max(MAX_LINE).optional().describe("\uC0C1/\uC911/\uD558 \uB610\uB294 \uC790\uC720 \uC11C\uC220"),
75654
75667
  years: external_exports.number().optional(),
75655
75668
  order_index: external_exports.number().optional()
75656
75669
  });
@@ -75664,11 +75677,11 @@ var SkillRecordSchema = external_exports.object({
75664
75677
  });
75665
75678
  var DocumentInputSchema = external_exports.object({
75666
75679
  kind: external_exports.enum(DOCUMENT_KINDS),
75667
- title: external_exports.string().min(1),
75668
- content: external_exports.string().describe("Markdown \uB610\uB294 \uC77C\uBC18 \uD14D\uC2A4\uD2B8 \uBCF8\uBB38"),
75680
+ title: reqLine,
75681
+ content: reqBody.describe("Markdown \uB610\uB294 \uC77C\uBC18 \uD14D\uC2A4\uD2B8 \uBCF8\uBB38"),
75669
75682
  source: external_exports.enum(CONTENT_SOURCES).optional(),
75670
75683
  is_primary: external_exports.boolean().optional().describe("\uB300\uD45C \uBB38\uC11C \uC5EC\uBD80"),
75671
- tags: external_exports.array(external_exports.string()).optional()
75684
+ tags: strList.optional()
75672
75685
  });
75673
75686
  var DocumentRecordSchema = external_exports.object({
75674
75687
  ...baseRecord,
@@ -75680,19 +75693,19 @@ var DocumentRecordSchema = external_exports.object({
75680
75693
  tags: external_exports.array(external_exports.string())
75681
75694
  });
75682
75695
  var CoverLetterInputSchema = external_exports.object({
75683
- title: external_exports.string().min(1),
75684
- job_id: external_exports.string().optional().describe("\uD2B9\uC815 \uACF5\uACE0\uC5D0 \uC5F0\uACB0\uD560 \uACBD\uC6B0"),
75696
+ title: reqLine,
75697
+ job_id: optLine.describe("\uD2B9\uC815 \uACF5\uACE0\uC5D0 \uC5F0\uACB0\uD560 \uACBD\uC6B0"),
75685
75698
  is_primary: external_exports.boolean().optional(),
75686
- content: external_exports.string().optional().describe("\uCD08\uAE30 \uBC84\uC804 \uBCF8\uBB38 (\uC788\uC73C\uBA74 v1\uB85C \uC800\uC7A5)"),
75699
+ content: optBody.describe("\uCD08\uAE30 \uBC84\uC804 \uBCF8\uBB38 (\uC788\uC73C\uBA74 v1\uB85C \uC800\uC7A5)"),
75687
75700
  source: external_exports.enum(CONTENT_SOURCES).optional(),
75688
- note: external_exports.string().optional().describe("\uCD08\uAE30 \uBC84\uC804 \uBA54\uBAA8")
75701
+ note: optNote.describe("\uCD08\uAE30 \uBC84\uC804 \uBA54\uBAA8")
75689
75702
  });
75690
75703
  var CoverLetterVersionInputSchema = external_exports.object({
75691
- cover_letter_id: external_exports.string().optional().describe("\uAE30\uC874 \uC790\uAE30\uC18C\uAC1C\uC11C\uC5D0 \uC0C8 \uBC84\uC804 \uCD94\uAC00 \uC2DC"),
75692
- title: external_exports.string().optional().describe("\uC0C8 \uC790\uAE30\uC18C\uAC1C\uC11C\uB97C \uB9CC\uB4E4 \uB54C \uC81C\uBAA9"),
75693
- job_id: external_exports.string().optional(),
75694
- content: external_exports.string().min(1),
75695
- note: external_exports.string().optional().describe("\uC774 \uBC84\uC804\uC758 \uBCC0\uACBD \uC694\uC57D (\uC608: \uC9C0\uC6D0\uB3D9\uAE30 \uBCF4\uAC15)"),
75704
+ cover_letter_id: optLine.describe("\uAE30\uC874 \uC790\uAE30\uC18C\uAC1C\uC11C\uC5D0 \uC0C8 \uBC84\uC804 \uCD94\uAC00 \uC2DC"),
75705
+ title: external_exports.string().max(MAX_LINE).optional().describe("\uC0C8 \uC790\uAE30\uC18C\uAC1C\uC11C\uB97C \uB9CC\uB4E4 \uB54C \uC81C\uBAA9"),
75706
+ job_id: optLine,
75707
+ content: reqBody,
75708
+ note: optNote.describe("\uC774 \uBC84\uC804\uC758 \uBCC0\uACBD \uC694\uC57D (\uC608: \uC9C0\uC6D0\uB3D9\uAE30 \uBCF4\uAC15)"),
75696
75709
  source: external_exports.enum(CONTENT_SOURCES).optional(),
75697
75710
  set_current: external_exports.boolean().optional().describe("\uC774 \uBC84\uC804\uC744 \uD604\uC7AC \uBC84\uC804\uC73C\uB85C \uC9C0\uC815 (\uAE30\uBCF8 true)")
75698
75711
  });
@@ -75716,16 +75729,16 @@ var CoverLetterRecordSchema = external_exports.object({
75716
75729
  versions: external_exports.array(CoverLetterVersionRecordSchema).optional()
75717
75730
  });
75718
75731
  var JobInputSchema = external_exports.object({
75719
- company: external_exports.string().min(1),
75720
- position: external_exports.string().min(1).describe("\uC9C1\uBB34/\uD3EC\uC9C0\uC158\uBA85"),
75732
+ company: reqLine,
75733
+ position: reqLine.describe("\uC9C1\uBB34/\uD3EC\uC9C0\uC158\uBA85"),
75721
75734
  url: SafeUrlOptional,
75722
- location: external_exports.string().optional(),
75723
- employment_type: external_exports.string().optional(),
75724
- description: external_exports.string().optional().describe("\uACF5\uACE0 \uC6D0\uBB38 \uB610\uB294 \uC815\uB9AC\uB41C \uD14D\uC2A4\uD2B8"),
75725
- requirements: external_exports.array(external_exports.string()).optional().describe("\uC790\uACA9\uC694\uAC74/\uC6B0\uB300\uC0AC\uD56D \uD575\uC2EC"),
75726
- keywords: external_exports.array(external_exports.string()).optional().describe("\uD575\uC2EC \uD0A4\uC6CC\uB4DC"),
75727
- deadline: external_exports.string().optional().describe("\uB9C8\uAC10\uC77C YYYY-MM-DD"),
75728
- source: external_exports.string().optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)")
75735
+ location: optLine,
75736
+ employment_type: optLine,
75737
+ description: optBody.describe("\uACF5\uACE0 \uC6D0\uBB38 \uB610\uB294 \uC815\uB9AC\uB41C \uD14D\uC2A4\uD2B8"),
75738
+ requirements: strList.optional().describe("\uC790\uACA9\uC694\uAC74/\uC6B0\uB300\uC0AC\uD56D \uD575\uC2EC"),
75739
+ keywords: strList.optional().describe("\uD575\uC2EC \uD0A4\uC6CC\uB4DC"),
75740
+ deadline: external_exports.string().max(MAX_LINE).optional().describe("\uB9C8\uAC10\uC77C YYYY-MM-DD"),
75741
+ source: external_exports.string().max(MAX_LINE).optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)")
75729
75742
  });
75730
75743
  var JobRecordSchema = external_exports.object({
75731
75744
  ...baseRecord,
@@ -75741,14 +75754,14 @@ var JobRecordSchema = external_exports.object({
75741
75754
  source: external_exports.string().nullable()
75742
75755
  });
75743
75756
  var FitAnalysisInputSchema = external_exports.object({
75744
- job_id: external_exports.string().describe("\uBD84\uC11D \uB300\uC0C1 \uACF5\uACE0 ID"),
75757
+ job_id: reqLine.describe("\uBD84\uC11D \uB300\uC0C1 \uACF5\uACE0 ID"),
75745
75758
  score: external_exports.number().min(0).max(100).optional().describe("\uC885\uD569 \uC801\uD569\uB3C4 0~100"),
75746
- summary: external_exports.string().optional().describe("\uD55C\uB450 \uBB38\uB2E8 \uC694\uC57D"),
75747
- strengths: external_exports.array(external_exports.string()).optional().describe("\uAC15\uC810 / \uC798 \uB9DE\uB294 \uBD80\uBD84"),
75748
- gaps: external_exports.array(external_exports.string()).optional().describe("\uBD80\uC871\uD55C \uBD80\uBD84 / \uBCF4\uC644 \uD544\uC694"),
75749
- matched_keywords: external_exports.array(external_exports.string()).optional(),
75750
- missing_keywords: external_exports.array(external_exports.string()).optional(),
75751
- recommendations: external_exports.array(external_exports.string()).optional().describe("\uC790\uAE30\uC18C\uAC1C\uC11C/\uC9C0\uC6D0 \uC804\uB7B5 \uC81C\uC548")
75759
+ summary: external_exports.string().max(MAX_NOTE).optional().describe("\uD55C\uB450 \uBB38\uB2E8 \uC694\uC57D"),
75760
+ strengths: strList.optional().describe("\uAC15\uC810 / \uC798 \uB9DE\uB294 \uBD80\uBD84"),
75761
+ gaps: strList.optional().describe("\uBD80\uC871\uD55C \uBD80\uBD84 / \uBCF4\uC644 \uD544\uC694"),
75762
+ matched_keywords: strList.optional(),
75763
+ missing_keywords: strList.optional(),
75764
+ recommendations: strList.optional().describe("\uC790\uAE30\uC18C\uAC1C\uC11C/\uC9C0\uC6D0 \uC804\uB7B5 \uC81C\uC548")
75752
75765
  });
75753
75766
  var FitAnalysisRecordSchema = external_exports.object({
75754
75767
  ...baseRecord,
@@ -75762,18 +75775,18 @@ var FitAnalysisRecordSchema = external_exports.object({
75762
75775
  recommendations: external_exports.array(external_exports.string())
75763
75776
  });
75764
75777
  var ApplicationInputSchema = external_exports.object({
75765
- job_id: external_exports.string(),
75778
+ job_id: reqLine,
75766
75779
  status: external_exports.enum(APPLICATION_STATUSES).optional(),
75767
- resume_id: external_exports.string().optional(),
75768
- cover_letter_id: external_exports.string().optional(),
75769
- applied_at: external_exports.string().optional(),
75770
- notes: external_exports.string().optional()
75780
+ resume_id: optLine,
75781
+ cover_letter_id: optLine,
75782
+ applied_at: optLine,
75783
+ notes: optNote
75771
75784
  });
75772
75785
  var ApplicationStatusUpdateSchema = external_exports.object({
75773
- job_id: external_exports.string().optional(),
75774
- application_id: external_exports.string().optional(),
75786
+ job_id: optLine,
75787
+ application_id: optLine,
75775
75788
  status: external_exports.enum(APPLICATION_STATUSES),
75776
- note: external_exports.string().optional().describe("\uC0C1\uD0DC \uBCC0\uACBD \uC0AC\uC720/\uBA54\uBAA8")
75789
+ note: optNote.describe("\uC0C1\uD0DC \uBCC0\uACBD \uC0AC\uC720/\uBA54\uBAA8")
75777
75790
  });
75778
75791
  var ApplicationRecordSchema = external_exports.object({
75779
75792
  ...baseRecord,
@@ -75785,24 +75798,24 @@ var ApplicationRecordSchema = external_exports.object({
75785
75798
  notes: external_exports.string().nullable()
75786
75799
  });
75787
75800
  var StarGuideSchema = external_exports.object({
75788
- question: external_exports.string(),
75789
- situation: external_exports.string().optional(),
75790
- task: external_exports.string().optional(),
75791
- action: external_exports.string().optional(),
75792
- result: external_exports.string().optional()
75801
+ question: external_exports.string().max(MAX_NOTE),
75802
+ situation: optNote,
75803
+ task: optNote,
75804
+ action: optNote,
75805
+ result: optNote
75793
75806
  });
75794
75807
  var InterviewQuestionSchema = external_exports.object({
75795
- question: external_exports.string(),
75796
- intent: external_exports.string().optional().describe("\uC9C8\uBB38 \uC758\uB3C4"),
75797
- followups: external_exports.array(external_exports.string()).optional().describe("\uC608\uC0C1 \uAF2C\uB9AC \uC9C8\uBB38"),
75798
- answer_outline: external_exports.string().optional().describe("\uB2F5\uBCC0 \uAC00\uC774\uB4DC/\uD575\uC2EC \uD3EC\uC778\uD2B8")
75808
+ question: external_exports.string().max(MAX_NOTE),
75809
+ intent: external_exports.string().max(MAX_NOTE).optional().describe("\uC9C8\uBB38 \uC758\uB3C4"),
75810
+ followups: strList.optional().describe("\uC608\uC0C1 \uAF2C\uB9AC \uC9C8\uBB38"),
75811
+ answer_outline: optBody.describe("\uB2F5\uBCC0 \uAC00\uC774\uB4DC/\uD575\uC2EC \uD3EC\uC778\uD2B8")
75799
75812
  });
75800
75813
  var InterviewPrepInputSchema = external_exports.object({
75801
- job_id: external_exports.string(),
75802
- questions: external_exports.array(InterviewQuestionSchema).optional(),
75803
- star_guides: external_exports.array(StarGuideSchema).optional(),
75804
- self_introduction: external_exports.string().optional().describe("1\uBD84 \uC790\uAE30\uC18C\uAC1C \uCD08\uC548"),
75805
- notes: external_exports.string().optional().describe("\uBA74\uC811 \uD6C4\uAE30/\uBA54\uBAA8")
75814
+ job_id: reqLine,
75815
+ questions: external_exports.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
75816
+ star_guides: external_exports.array(StarGuideSchema).max(MAX_ITEMS).optional(),
75817
+ self_introduction: optBody.describe("1\uBD84 \uC790\uAE30\uC18C\uAC1C \uCD08\uC548"),
75818
+ notes: optNote.describe("\uBA74\uC811 \uD6C4\uAE30/\uBA54\uBAA8")
75806
75819
  });
75807
75820
  var InterviewPrepRecordSchema = external_exports.object({
75808
75821
  ...baseRecord,
@@ -75856,7 +75869,7 @@ var ApplicationContextSchema = external_exports.object({
75856
75869
  });
75857
75870
 
75858
75871
  // packages/shared/src/build-info.ts
75859
- var APP_VERSION = true ? "0.1.2" : "0.0.0-dev";
75872
+ var APP_VERSION = true ? "0.1.3" : "0.0.0-dev";
75860
75873
  var BUNDLED = true;
75861
75874
 
75862
75875
  // packages/shared/src/index.ts
@@ -77931,9 +77944,19 @@ async function finishPdf(buf, base) {
77931
77944
  });
77932
77945
  return base("pdf", { text, bytes: buf.length, warnings: pageWarnings });
77933
77946
  } catch (e) {
77947
+ const name = e && typeof e === "object" && "name" in e ? String(e.name) : "";
77948
+ if (name === "PasswordException") {
77949
+ return base("unsupported", {
77950
+ bytes: buf.length,
77951
+ warnings: [
77952
+ "\uC774 PDF\uB294 \uC554\uD638(\uBE44\uBC00\uBC88\uD638)\uB85C \uBCF4\uD638\uB418\uC5B4 \uC788\uC5B4 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. PDF \uBDF0\uC5B4\uC5D0\uC11C \uC554\uD638\uB97C \uD574\uC81C\uD574 \uB2E4\uC2DC \uC800\uC7A5\uD558\uAC70\uB098, \uB0B4\uC6A9\uC744 \uD14D\uC2A4\uD2B8\uB85C \uBD99\uC5EC\uB123\uC5B4 \uC8FC\uC138\uC694."
77953
+ ],
77954
+ unsupported: true
77955
+ });
77956
+ }
77934
77957
  return base("unsupported", {
77935
77958
  bytes: buf.length,
77936
- warnings: [`PDF\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4(${errMsg(e)}). \uC554\uD638\uD654\uB418\uC5C8\uAC70\uB098 \uC190\uC0C1\uB41C PDF\uC77C \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uD14D\uC2A4\uD2B8\uB85C \uBD99\uC5EC\uB123\uC5B4 \uC8FC\uC138\uC694.`],
77959
+ warnings: [`PDF\uB97C \uC77D\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4(${errMsg(e)}). \uC190\uC0C1\uB418\uC5C8\uAC70\uB098 \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 PDF\uC77C \uC218 \uC788\uC2B5\uB2C8\uB2E4. \uD14D\uC2A4\uD2B8\uB85C \uBD99\uC5EC\uB123\uC5B4 \uC8FC\uC138\uC694.`],
77937
77960
  unsupported: true
77938
77961
  });
77939
77962
  }
@@ -78487,13 +78510,12 @@ ${lines}`;
78487
78510
  name: "add_resume",
78488
78511
  title: "\uC774\uB825\uC11C/\uACBD\uB825\uAE30\uC220\uC11C \uCD94\uAC00",
78489
78512
  description: "\uC774\uB825\uC11C, \uACBD\uB825\uAE30\uC220\uC11C, \uD3EC\uD2B8\uD3F4\uB9AC\uC624 \uB4F1 \uBB38\uC11C\uB97C \uC800\uC7A5\uD569\uB2C8\uB2E4. content\uC5D0\uB294 \uC0AC\uC6A9\uC790\uAC00 \uC5C5\uB85C\uB4DC/\uBD99\uC5EC\uB123\uC740 \uD14D\uC2A4\uD2B8(\uB610\uB294 \uC815\uB9AC\uD55C Markdown)\uB97C \uB123\uC73C\uC138\uC694. kind \uAE30\uBCF8\uAC12\uC740 resume\uC785\uB2C8\uB2E4. is_primary=true\uB85C \uB300\uD45C \uBB38\uC11C\uB97C \uC9C0\uC815\uD558\uBA74 get_application_context\uC758 primary_resume\uB85C \uB178\uCD9C\uB429\uB2C8\uB2E4.",
78513
+ // Reuse the shared (length-bounded, whitespace-rejecting) Document schema so
78514
+ // the MCP ingestion path — the primary route for résumé text — inherits the
78515
+ // same caps as the web API. kind stays optional here (defaults to resume).
78490
78516
  inputSchema: {
78491
- title: external_exports.string().describe("\uBB38\uC11C \uC81C\uBAA9"),
78492
- content: external_exports.string().describe("\uBCF8\uBB38 (Markdown/\uC77C\uBC18 \uD14D\uC2A4\uD2B8)"),
78493
- kind: external_exports.enum(DOCUMENT_KINDS).optional().describe("resume(\uC774\uB825\uC11C)/career_description(\uACBD\uB825\uAE30\uC220\uC11C)/portfolio/other"),
78494
- source: external_exports.enum(CONTENT_SOURCES).optional(),
78495
- is_primary: external_exports.boolean().optional().describe("\uB300\uD45C \uBB38\uC11C \uC5EC\uBD80"),
78496
- tags: external_exports.array(external_exports.string()).optional()
78517
+ ...DocumentInputSchema.omit({ kind: true }).shape,
78518
+ kind: external_exports.enum(DOCUMENT_KINDS).optional().describe("resume(\uC774\uB825\uC11C)/career_description(\uACBD\uB825\uAE30\uC220\uC11C)/portfolio/other")
78497
78519
  },
78498
78520
  handler: (args) => {
78499
78521
  const doc = addResume(args);
@@ -0,0 +1,97 @@
1
+ // =============================================================================
2
+ // CareerMate marketing-site analytics — careermate.life ONLY.
3
+ //
4
+ // SCOPE: this file loads on the PUBLIC marketing/demo site (install-page/) that
5
+ // Vercel serves. It must NEVER be loaded by the local dashboard (apps/web, bound
6
+ // to 127.0.0.1) — that would break the core promise in AGENTS.md:
7
+ // "절대 사용자 데이터를 외부로 업로드하지 마라."
8
+ // There is no user career data on this site; only anonymous page/traffic signals.
9
+ //
10
+ // What runs here:
11
+ // 1) Vercel Web Analytics — cookieless, no PII, no consent banner required.
12
+ // 2) Vercel Speed Insights — Core Web Vitals, also cookieless.
13
+ // 3) Google Analytics 4 — OPTIONAL. Off until GA_MEASUREMENT_ID is filled in.
14
+ // Runs under Google Consent Mode v2 with analytics_storage DENIED by default,
15
+ // i.e. cookieless pings, so it needs no consent banner and stays consistent
16
+ // with the local-first / privacy-first brand. (Flip consent to 'granted'
17
+ // later only if you add a real consent UI.)
18
+ //
19
+ // The marketing pages (index.html, start.html) are classic multi-page navigations,
20
+ // so a single pageview on load is enough. The demo (/demo/) is a hash-router SPA,
21
+ // so we emit a virtual pageview on every hashchange — otherwise every sub-route
22
+ // (#/jobs, #/applications, …) would collapse into one "/demo" view.
23
+ // =============================================================================
24
+ (function () {
25
+ 'use strict';
26
+
27
+ // ── 1. Vercel Web Analytics (cookieless) ───────────────────────────────────
28
+ // Queue shim per Vercel's HTML snippet; the injected script drains window.vaq.
29
+ window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); };
30
+ loadScript('/_vercel/insights/script.js');
31
+
32
+ // ── 2. Vercel Speed Insights (cookieless) — OFF by default ──────────────────
33
+ // Vercel's Hobby plan allows Speed Insights on only ONE project at a time, and it's
34
+ // more valuable on the real app than on this rarely-changing static site (use the free
35
+ // PageSpeed Insights for ad-hoc checks here). Flip to true to load it (needs Enable in
36
+ // the Vercel dashboard too); leaving it false avoids a dead 404 script request.
37
+ var ENABLE_SPEED_INSIGHTS = false;
38
+ if (ENABLE_SPEED_INSIGHTS) {
39
+ window.si = window.si || function () { (window.siq = window.siq || []).push(arguments); };
40
+ loadScript('/_vercel/speed-insights/script.js');
41
+ }
42
+
43
+ // ── 3. Google Analytics 4 (optional, cookieless via Consent Mode v2) ────────
44
+ // Paste your GA4 measurement ID (looks like "G-XXXXXXXXXX") here to turn it on.
45
+ // Leaving it blank keeps GA off entirely — Vercel analytics above still runs.
46
+ var GA_MEASUREMENT_ID = 'G-HK9KWTYCBD';
47
+ if (/^G-[A-Z0-9]+$/.test(GA_MEASUREMENT_ID)) initGoogleAnalytics(GA_MEASUREMENT_ID);
48
+
49
+ // ── Demo SPA: report hash route changes to Vercel as a custom event ─────────
50
+ // Vercel's script auto-tracks History API navigations but not hashchange.
51
+ if (location.pathname.indexOf('/demo') === 0) {
52
+ window.addEventListener('hashchange', function () {
53
+ window.va('event', { name: 'demo_route', data: { route: currentRoute() } });
54
+ });
55
+ }
56
+
57
+ // ── helpers ─────────────────────────────────────────────────────────────────
58
+ function loadScript(src) {
59
+ var s = document.createElement('script');
60
+ s.src = src;
61
+ s.defer = true;
62
+ document.head.appendChild(s);
63
+ }
64
+
65
+ function currentRoute() {
66
+ return (location.hash || '').replace(/^#\/?/, '').split('/')[0] || 'home';
67
+ }
68
+
69
+ function initGoogleAnalytics(id) {
70
+ window.dataLayer = window.dataLayer || [];
71
+ function gtag() { window.dataLayer.push(arguments); }
72
+ window.gtag = gtag;
73
+ // Deny storage by default → GA runs cookieless. No banner required.
74
+ gtag('consent', 'default', {
75
+ ad_storage: 'denied',
76
+ ad_user_data: 'denied',
77
+ ad_personalization: 'denied',
78
+ analytics_storage: 'denied',
79
+ });
80
+ gtag('js', new Date());
81
+ // send_page_view:false so we control pageviews (incl. the demo's hash routes).
82
+ gtag('config', id, { send_page_view: false, anonymize_ip: true });
83
+ loadScript('https://www.googletagmanager.com/gtag/js?id=' + id);
84
+
85
+ sendGaPageview();
86
+ window.addEventListener('hashchange', sendGaPageview);
87
+ }
88
+
89
+ function sendGaPageview() {
90
+ if (!window.gtag) return;
91
+ window.gtag('event', 'page_view', {
92
+ page_path: location.pathname + location.search + location.hash,
93
+ page_location: location.href,
94
+ page_title: document.title,
95
+ });
96
+ }
97
+ })();