careermate 0.1.0 → 0.1.2

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 (134) hide show
  1. package/README.md +22 -17
  2. package/THIRD_PARTY_NOTICES.md +15 -0
  3. package/apps/mcp/src/index.ts +4 -3
  4. package/apps/web/DESIGN_GUIDE.md +2 -2
  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/icon-180.png +0 -0
  10. package/apps/web/public/index.html +14 -3
  11. package/apps/web/public/lib.js +135 -23
  12. package/apps/web/public/pages/applications.js +18 -9
  13. package/apps/web/public/pages/documents.js +66 -50
  14. package/apps/web/public/pages/home.js +12 -11
  15. package/apps/web/public/pages/interview.js +9 -12
  16. package/apps/web/public/pages/jobs.js +39 -36
  17. package/apps/web/public/pages/profile.js +66 -86
  18. package/apps/web/public/pages/settings.js +21 -14
  19. package/apps/web/public/styles.css +105 -38
  20. package/apps/web/src/http.ts +31 -4
  21. package/apps/web/src/info.ts +9 -22
  22. package/apps/web/src/routes.ts +19 -14
  23. package/apps/web/src/server.ts +34 -29
  24. package/apps/web/src/settings.ts +35 -17
  25. package/bin/careermate.mjs +8 -5
  26. package/dist/doctor.mjs +78908 -0
  27. package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
  28. package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
  29. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
  30. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
  31. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
  32. package/dist/install-page/favicon.svg +1 -0
  33. package/dist/install-page/icon-180.png +0 -0
  34. package/dist/install-page/icon-512.png +0 -0
  35. package/dist/install-page/index.html +328 -225
  36. package/dist/install-page/landing.js +54 -0
  37. package/dist/install-page/llms-install.txt +202 -0
  38. package/dist/install-page/og.png +0 -0
  39. package/dist/install-page/shots/applications-dark.png +0 -0
  40. package/dist/install-page/shots/applications-light.png +0 -0
  41. package/dist/install-page/shots/documents-dark.png +0 -0
  42. package/dist/install-page/shots/documents-light.png +0 -0
  43. package/dist/install-page/shots/home-dark.png +0 -0
  44. package/dist/install-page/shots/home-light.png +0 -0
  45. package/dist/install-page/shots/interview-dark.png +0 -0
  46. package/dist/install-page/shots/interview-light.png +0 -0
  47. package/dist/install-page/shots/jobs-dark.png +0 -0
  48. package/dist/install-page/shots/jobs-light.png +0 -0
  49. package/dist/install-page/shots/settings-dark.png +0 -0
  50. package/dist/install-page/shots/settings-light.png +0 -0
  51. package/dist/install-page/start.html +323 -0
  52. package/dist/install-page/style.css +128 -4
  53. package/dist/mcp.mjs +74225 -2007
  54. package/dist/{mcpb-stage/dist/web.mjs → migrate.mjs} +142 -2660
  55. package/dist/pdf.worker.min.mjs +28 -0
  56. package/dist/public/app.js +34 -2
  57. package/dist/public/icon-180.png +0 -0
  58. package/dist/public/index.html +14 -3
  59. package/dist/public/lib.js +135 -23
  60. package/dist/public/pages/applications.js +18 -9
  61. package/dist/public/pages/documents.js +66 -50
  62. package/dist/public/pages/home.js +12 -11
  63. package/dist/public/pages/interview.js +9 -12
  64. package/dist/public/pages/jobs.js +39 -36
  65. package/dist/public/pages/profile.js +66 -86
  66. package/dist/public/pages/settings.js +21 -14
  67. package/dist/public/styles.css +105 -38
  68. package/dist/web.mjs +39950 -369
  69. package/docs/ARCHITECTURE.md +5 -5
  70. package/docs/DECISIONS.md +1 -1
  71. package/docs/DEMO.md +6 -6
  72. package/docs/MCP_TOOLS.md +68 -9
  73. package/docs/ROADMAP.md +11 -10
  74. package/docs/RUNTIME_DISTRIBUTION_RESEARCH.md +135 -0
  75. package/docs/START_WORKFLOW.md +19 -7
  76. package/docs/SUPPORTED_AI_APPS.md +85 -33
  77. package/docs/TODO.md +1 -1
  78. package/docs/WORKFLOWS.md +8 -6
  79. package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
  80. package/package.json +8 -3
  81. package/packages/core/src/services.ts +42 -1
  82. package/packages/db/src/connection.ts +30 -0
  83. package/packages/db/src/index.ts +1 -0
  84. package/packages/db/src/paths.ts +11 -0
  85. package/packages/db/src/repositories.ts +109 -63
  86. package/packages/db/src/schema.ts +118 -0
  87. package/packages/exporters/src/html.ts +9 -12
  88. package/packages/mcp-tools/src/bridge.ts +55 -32
  89. package/packages/mcp-tools/src/index.ts +9 -0
  90. package/packages/mcp-tools/src/tools.ts +269 -65
  91. package/packages/mcp-tools/src/update.ts +330 -0
  92. package/packages/parsers/src/documents.ts +521 -0
  93. package/packages/parsers/src/index.ts +8 -5
  94. package/packages/prompts/src/onboarding.ts +2 -2
  95. package/packages/shared/src/build-info.ts +22 -0
  96. package/packages/shared/src/enums.ts +39 -0
  97. package/packages/shared/src/index.ts +1 -0
  98. package/packages/shared/src/schemas.ts +24 -3
  99. package/packages/workflows/src/definitions.ts +4 -2
  100. package/scripts/build-demo.mjs +130 -0
  101. package/scripts/build-dist.mjs +46 -8
  102. package/scripts/build-mcpb.mjs +60 -4
  103. package/scripts/doctor.ts +16 -3
  104. package/scripts/gen-og.mjs +86 -0
  105. package/scripts/init.ts +32 -3
  106. package/scripts/seed.ts +5 -5
  107. package/scripts/shots-seed.ts +192 -0
  108. package/scripts/shots.ts +89 -0
  109. package/tsconfig.json +3 -1
  110. package/dist/careermate.mcpb +0 -0
  111. package/dist/mcp-smoke.err +0 -3
  112. package/dist/mcpb-stage/README.md +0 -219
  113. package/dist/mcpb-stage/dist/install-page/index.html +0 -434
  114. package/dist/mcpb-stage/dist/install-page/style.css +0 -407
  115. package/dist/mcpb-stage/dist/install-page/vercel.json +0 -20
  116. package/dist/mcpb-stage/dist/mcp.mjs +0 -23704
  117. package/dist/mcpb-stage/dist/public/app.js +0 -118
  118. package/dist/mcpb-stage/dist/public/index.html +0 -41
  119. package/dist/mcpb-stage/dist/public/lib.js +0 -282
  120. package/dist/mcpb-stage/dist/public/pages/applications.js +0 -98
  121. package/dist/mcpb-stage/dist/public/pages/documents.js +0 -446
  122. package/dist/mcpb-stage/dist/public/pages/home.js +0 -263
  123. package/dist/mcpb-stage/dist/public/pages/interview.js +0 -230
  124. package/dist/mcpb-stage/dist/public/pages/jobs.js +0 -494
  125. package/dist/mcpb-stage/dist/public/pages/profile.js +0 -576
  126. package/dist/mcpb-stage/dist/public/pages/settings.js +0 -233
  127. package/dist/mcpb-stage/dist/public/styles.css +0 -420
  128. package/dist/mcpb-stage/manifest.json +0 -40
  129. package/docs/INSTALL.md +0 -148
  130. package/docs/INSTALL_AND_USAGE.md +0 -99
  131. package/install-page/index.html +0 -474
  132. package/install-page/style.css +0 -391
  133. package/install-page/vercel.json +0 -20
  134. /package/dist/{mcpb-stage/dist/public → install-page}/fonts/PretendardVariable.woff2 +0 -0
@@ -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');
Binary file
@@ -5,13 +5,18 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>CareerMate — 내 커리어 흐름 관리</title>
7
7
  <meta name="description" content="ChatGPT/Claude와 함께 쓰는 로컬 커리어 관리 도구" />
8
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%234f46e5'/><text x='12' y='17' font-size='14' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>C</text></svg>" />
8
+ <meta name="theme-color" content="#faf8f4" media="(prefers-color-scheme: light)" />
9
+ <meta name="theme-color" content="#14130f" media="(prefers-color-scheme: dark)" />
10
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><rect width='24' height='24' rx='6' fill='%230f7256'/><text x='12' y='17' font-size='14' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>C</text></svg>" />
11
+ <link rel="apple-touch-icon" href="/icon-180.png" />
9
12
  <link rel="preload" href="/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin />
10
13
  <link rel="stylesheet" href="/styles.css" />
11
14
  </head>
12
15
  <body>
16
+ <button class="skip-link" id="skip-link" type="button">본문으로 건너뛰기</button>
13
17
  <div class="app">
14
- <aside class="sidebar">
18
+ <div class="nav-scrim" id="nav-scrim"></div>
19
+ <aside class="sidebar" id="sidebar">
15
20
  <div class="sidebar__brand">
16
21
  <div class="sidebar__logo">C</div>
17
22
  <div>
@@ -28,12 +33,18 @@
28
33
 
29
34
  <div class="main">
30
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>
31
39
  <div class="topbar__title" id="topbar-title">CareerMate</div>
32
40
  <div class="topbar__spacer"></div>
33
41
  <div id="topbar-actions" class="flex gap-2"></div>
34
42
  </header>
35
- <main id="view" class="view" aria-live="polite"></main>
43
+ <main id="view" class="view"></main>
36
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>
37
48
  </div>
38
49
 
39
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
 
@@ -49,7 +73,7 @@ export function mount(node, ...children) { clear(node); appendChildren(node, chi
49
73
  const ICONS = {
50
74
  home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>',
51
75
  user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/>',
52
- briefcase: '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/>',
76
+ briefcase: '<rect x="3" y="8" width="18" height="11" rx="2.5"/><path d="M8.5 8V6.5a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2V8"/><path d="M3 13h18"/>',
53
77
  layers: '<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 13 9 5 9-5"/>',
54
78
  file: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/>',
55
79
  mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
@@ -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;
@@ -2,15 +2,21 @@
2
2
  // (no horizontal scroll). Change a status inline to move an item between groups.
3
3
  import {
4
4
  el, get, put, navigate, EmptyState, Btn, ListRow,
5
- Select, scoreClass, mount, toast, toastOk, toastError, meta,
5
+ Select, scoreClass, statusColor, mount, toast, toastOk, toastError, meta,
6
6
  } from '/lib.js';
7
7
 
8
- // status code → theme colour (matches the badge--{status} hues)
9
- const STATUS_COLOR = {
10
- draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
11
- document_passed: 'var(--teal)', interview: 'var(--amber)',
12
- final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
13
- };
8
+ // deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
9
+ function ddayLabel(deadline) {
10
+ if (!deadline) return null;
11
+ const d = new Date(`${deadline}T00:00:00`);
12
+ if (isNaN(d.getTime())) return null;
13
+ const today = new Date();
14
+ today.setHours(0, 0, 0, 0);
15
+ const days = Math.round((d.getTime() - today.getTime()) / 86400000);
16
+ if (days > 0) return `마감 D-${days}`;
17
+ if (days === 0) return '마감 D-day';
18
+ return '마감 지남';
19
+ }
14
20
 
15
21
  export async function render(ctx) {
16
22
  ctx.setActions([]);
@@ -50,7 +56,7 @@ export async function render(ctx) {
50
56
  function StatusGroup(code, apps) {
51
57
  return el('div', {},
52
58
  el('div', { class: 'app-group__head' },
53
- el('span', { class: 'app-group__dot', style: { background: STATUS_COLOR[code] || 'var(--text-tertiary)' } }),
59
+ el('span', { class: 'app-group__dot', style: { background: statusColor(code) } }),
54
60
  el('span', { class: 'app-group__title' }, labelByCode[code] || code),
55
61
  el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
56
62
  el('div', {}, ...apps.map(AppRow)));
@@ -58,6 +64,9 @@ export async function render(ctx) {
58
64
 
59
65
  function AppRow(app) {
60
66
  const job = app.job || {};
67
+ const subBits = [job.position, job.location].filter(Boolean);
68
+ const dday = ddayLabel(job.deadline);
69
+ if (dday) subBits.push(dday);
61
70
  const sel = Select(
62
71
  statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
63
72
  {
@@ -69,7 +78,7 @@ export async function render(ctx) {
69
78
  );
70
79
  return ListRow({
71
80
  title: job.company || '—',
72
- sub: job.position || '',
81
+ sub: subBits.join(' · '),
73
82
  trailing: [
74
83
  app.fit_score != null
75
84
  ? el('span', { class: `strong tnum ${scoreClass(app.fit_score)}` }, `${app.fit_score}점`)