careermate 0.1.1 → 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 (71) hide show
  1. package/README.md +12 -10
  2. package/THIRD_PARTY_NOTICES.md +15 -0
  3. package/apps/mcp/src/index.ts +4 -5
  4. package/apps/web/demo/demo-shim.js +536 -0
  5. package/apps/web/demo/demo.css +75 -0
  6. package/apps/web/demo/seed.js +269 -0
  7. package/apps/web/public/app.js +34 -2
  8. package/apps/web/public/index.html +10 -2
  9. package/apps/web/public/lib.js +134 -22
  10. package/apps/web/public/pages/applications.js +2 -9
  11. package/apps/web/public/pages/documents.js +66 -50
  12. package/apps/web/public/pages/home.js +12 -11
  13. package/apps/web/public/pages/interview.js +9 -12
  14. package/apps/web/public/pages/jobs.js +39 -36
  15. package/apps/web/public/pages/profile.js +66 -86
  16. package/apps/web/public/pages/settings.js +15 -10
  17. package/apps/web/public/styles.css +56 -2
  18. package/apps/web/src/http.ts +31 -4
  19. package/apps/web/src/info.ts +3 -3
  20. package/apps/web/src/routes.ts +2 -1
  21. package/apps/web/src/server.ts +1 -4
  22. package/dist/doctor.mjs +72607 -611
  23. package/dist/install-page/.omc/state/agent-replay-55a58c0d-bb6e-43ea-8a05-4b6c93b80b34.jsonl +12 -0
  24. package/dist/install-page/.omc/state/idle-notif-cooldown.json +3 -0
  25. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/mission-state.json +147 -0
  26. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/pre-tool-advisory-throttle.json +22 -0
  27. package/dist/install-page/.omc/state/sessions/55a58c0d-bb6e-43ea-8a05-4b6c93b80b34/subagent-tracking-state.json +44 -0
  28. package/dist/install-page/index.html +31 -70
  29. package/dist/install-page/landing.js +54 -0
  30. package/dist/install-page/start.html +13 -36
  31. package/dist/install-page/style.css +34 -1
  32. package/dist/mcp.mjs +73936 -1976
  33. package/dist/migrate.mjs +17 -10
  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 +66 -50
  40. package/dist/public/pages/home.js +12 -11
  41. package/dist/public/pages/interview.js +9 -12
  42. package/dist/public/pages/jobs.js +39 -36
  43. package/dist/public/pages/profile.js +66 -86
  44. package/dist/public/pages/settings.js +15 -10
  45. package/dist/public/styles.css +56 -2
  46. package/dist/web.mjs +39631 -244
  47. package/docs/ARCHITECTURE.md +5 -5
  48. package/docs/DECISIONS.md +1 -1
  49. package/docs/DEMO.md +3 -3
  50. package/docs/MCP_TOOLS.md +49 -9
  51. package/docs/ROADMAP.md +8 -7
  52. package/docs/START_WORKFLOW.md +13 -7
  53. package/docs/TODO.md +1 -1
  54. package/docs/WORKFLOWS.md +7 -5
  55. package/docs/superpowers/specs/2026-06-16-demo-dashboard-deploy-design.md +64 -0
  56. package/package.json +6 -2
  57. package/packages/db/src/paths.ts +11 -0
  58. package/packages/mcp-tools/src/bridge.ts +55 -32
  59. package/packages/mcp-tools/src/index.ts +9 -0
  60. package/packages/mcp-tools/src/tools.ts +193 -70
  61. package/packages/mcp-tools/src/update.ts +330 -0
  62. package/packages/parsers/src/documents.ts +521 -0
  63. package/packages/parsers/src/index.ts +8 -5
  64. package/packages/prompts/src/onboarding.ts +2 -2
  65. package/packages/shared/src/build-info.ts +22 -0
  66. package/packages/shared/src/index.ts +1 -0
  67. package/packages/shared/src/schemas.ts +24 -3
  68. package/packages/workflows/src/definitions.ts +4 -2
  69. package/scripts/build-demo.mjs +130 -0
  70. package/scripts/build-dist.mjs +20 -1
  71. package/scripts/doctor.ts +16 -3
@@ -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;
@@ -2,16 +2,9 @@
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
- };
14
-
15
8
  // deadline(YYYY-MM-DD) → "마감 D-7" / "마감 D-day" / "마감 지남" (null if absent/invalid)
16
9
  function ddayLabel(deadline) {
17
10
  if (!deadline) return null;
@@ -63,7 +56,7 @@ export async function render(ctx) {
63
56
  function StatusGroup(code, apps) {
64
57
  return el('div', {},
65
58
  el('div', { class: 'app-group__head' },
66
- 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) } }),
67
60
  el('span', { class: 'app-group__title' }, labelByCode[code] || code),
68
61
  el('span', { class: 'app-group__count tnum' }, `${apps.length}건`)),
69
62
  el('div', {}, ...apps.map(AppRow)));
@@ -1,7 +1,7 @@
1
1
  // Documents — cover letters (with version history) and résumés/other documents.
2
2
  // XSS-safe: all DB/user text rendered via el() textContent / .doc-preview (never innerHTML).
3
3
  import {
4
- el, get, post, put, del, Card, Badge, Btn, IconBtn, EmptyState,
4
+ el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
5
5
  Field, Input, Textarea, Select, openModal, confirmDialog,
6
6
  toastOk, toastError, copyText, downloadUrl, fmtRelative, mount, meta,
7
7
  } from '/lib.js';
@@ -9,7 +9,10 @@ import {
9
9
  const SOURCE_LABELS = { manual: '직접 입력', upload: '파일 업로드', ai: 'AI 생성', edit: '직접 수정' };
10
10
  const sourceLabel = (s) => SOURCE_LABELS[s] || s || '';
11
11
 
12
- let tab = 'cover'; // 'cover' | 'docs'
12
+ // Module-scoped on purpose: remember the last-viewed tab ('cover' | 'docs') across
13
+ // navigations so returning to Documents reopens where the user left off. The search
14
+ // query is intentionally NOT persisted (it resets per visit).
15
+ let tab = 'cover';
13
16
 
14
17
  export async function render(ctx) {
15
18
  const root = el('div', { class: 'stack-4' });
@@ -101,7 +104,7 @@ async function CoverLettersTab(reload) {
101
104
  function CoverRow(cl, reload) {
102
105
  const metaRow = el('div', { class: 'flex gap-2 wrap', style: { marginTop: '4px' } },
103
106
  el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
104
- cl.is_primary ? Badge('final_passed', '대표') : null,
107
+ cl.is_primary ? Badge('accent', '대표') : null,
105
108
  );
106
109
 
107
110
  const actions = el('div', { class: 'flex gap-2 wrap' },
@@ -129,7 +132,7 @@ function CoverRow(cl, reload) {
129
132
  async function setPrimaryCover(id, reload) {
130
133
  try {
131
134
  await put(`/api/cover-letters/${id}/primary`, {});
132
- toastOk('대표 자기소개서로 지정했습니다.');
135
+ toastOk('대표 자기소개서로 지정했어요.');
133
136
  await reload();
134
137
  } catch (err) { toastError(err); }
135
138
  }
@@ -143,45 +146,56 @@ async function removeCover(cl, reload) {
143
146
  if (!ok) return;
144
147
  try {
145
148
  await del(`/api/cover-letters/${cl.id}`);
146
- toastOk('자기소개서를 삭제했습니다.');
149
+ toastOk('자기소개서를 삭제했어요.');
147
150
  await reload();
148
151
  } catch (err) { toastError(err); }
149
152
  }
150
153
 
151
- function openCoverCreate(reload) {
154
+ async function openCoverCreate(reload) {
155
+ // Let the user PICK the job to link instead of typing an internal ID they've never seen.
156
+ let jobs = [];
157
+ try { ({ jobs } = await get('/api/jobs')); } catch { /* dropdown falls back to "연결 안 함" */ }
152
158
  const title = Input({ placeholder: '예: 백엔드 개발자 자기소개서', attrs: { maxlength: '200' } });
153
159
  const content = Textarea({ placeholder: '내용을 붙여넣으세요. 비워 두면 빈 자기소개서가 만들어집니다.', style: { minHeight: '200px' } });
154
- const jobId = Input({ placeholder: '연결할 공고 ID (선택)' });
160
+ const jobPick = Select([
161
+ { value: '', label: '연결 안 함', selected: true },
162
+ ...jobs.map((j) => ({ value: j.id, label: `${j.company} · ${j.position}` })),
163
+ ]);
155
164
 
156
165
  openModal({
157
166
  title: '새 자기소개서',
158
167
  size: 'lg',
159
168
  body: el('div', { class: 'stack-3' },
160
169
  Field('제목 (필수)', title),
161
- Field('내용 (선택)', content, '내용을 입력하면 v1 버전으로 저장됩니다.'),
162
- Field('공고 ID (선택)', jobId, '특정 공고에 연결하려면 입력하세요.'),
170
+ Field('내용 (선택)', content, '내용을 입력하면 첫 버전으로 저장됩니다.'),
171
+ Field('연결할 공고 (선택)', jobPick,
172
+ jobs.length ? '특정 공고에 연결하면 그 공고 화면에서 함께 보여요.' : '저장된 공고가 아직 없어요.'),
163
173
  ),
164
174
  footer: (close) => [
165
175
  Btn('취소', { onClick: close }),
166
- Btn('자기소개서 저장', { variant: 'primary', onClick: async () => {
176
+ SubmitBtn('자기소개서 저장', async () => {
167
177
  const t = title.value.trim();
168
- if (!t) { toastError(new Error('제목을 입력해 주세요.')); title.focus(); return; }
169
- try {
170
- const body = { title: t, source: 'manual' };
171
- if (content.value.trim()) body.content = content.value;
172
- if (jobId.value.trim()) body.job_id = jobId.value.trim();
173
- await post('/api/cover-letters', body);
174
- toastOk('자기소개서를 추가했습니다.');
175
- close();
176
- await reload();
177
- } catch (err) { toastError(err); }
178
- } }),
178
+ if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
179
+ const body = { title: t, source: 'manual' };
180
+ if (content.value.trim()) body.content = content.value;
181
+ if (jobPick.value) body.job_id = jobPick.value;
182
+ await post('/api/cover-letters', body);
183
+ toastOk('자기소개서를 추가했어요.');
184
+ close();
185
+ await reload();
186
+ }),
179
187
  ],
180
188
  });
181
189
  }
182
190
 
183
191
  async function openCoverDetail(id, reload) {
184
- let data = (await get(`/api/cover-letters/${id}`)).cover_letter;
192
+ let data;
193
+ try {
194
+ data = (await get(`/api/cover-letters/${id}`)).cover_letter;
195
+ } catch (err) {
196
+ toastError(err);
197
+ return;
198
+ }
185
199
 
186
200
  openModal({
187
201
  title: data.title,
@@ -210,7 +224,7 @@ function CoverDetailBody(cl, { refresh }) {
210
224
  wrap.append(el('div', { class: 'flex between wrap gap-2' },
211
225
  el('div', { class: 'flex gap-2 wrap' },
212
226
  el('span', { class: 'chip' }, `버전 ${cl.version_count}개`),
213
- cl.is_primary ? Badge('final_passed', '대표') : null,
227
+ cl.is_primary ? Badge('accent', '대표') : null,
214
228
  ),
215
229
  el('div', { class: 'flex gap-2 wrap' },
216
230
  Btn('MD 내보내기', { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=md`) }),
@@ -277,7 +291,7 @@ function VersionItem(cl, v, { isCurrent, setPreview, refresh }) {
277
291
  async function makeCurrent(id, versionId, refresh) {
278
292
  try {
279
293
  await put(`/api/cover-letters/${id}/current-version`, { version_id: versionId });
280
- toastOk('현재 버전을 변경했습니다.');
294
+ toastOk('현재 버전을 변경했어요.');
281
295
  await refresh();
282
296
  } catch (err) { toastError(err); }
283
297
  }
@@ -292,16 +306,14 @@ function EditNewVersion(cl, refresh) {
292
306
  Field('내용', content),
293
307
  Field('변경 메모 (선택)', note),
294
308
  el('div', { class: 'flex gap-2' },
295
- Btn('새 버전으로 저장', { variant: 'primary', icon: 'check', onClick: async () => {
296
- if (!content.value.trim()) { toastError(new Error('내용을 입력해 주세요.')); content.focus(); return; }
297
- try {
298
- await post(`/api/cover-letters/${cl.id}/versions`, {
299
- content: content.value, note: note.value.trim() || undefined, source: 'edit',
300
- });
301
- toastOk('새 버전을 저장했습니다.');
302
- await refresh();
303
- } catch (err) { toastError(err); }
304
- } }),
309
+ SubmitBtn('새 버전으로 저장', async () => {
310
+ if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
311
+ await post(`/api/cover-letters/${cl.id}/versions`, {
312
+ content: content.value, note: note.value.trim() || undefined, source: 'edit',
313
+ });
314
+ toastOk('새 버전을 저장했어요.');
315
+ await refresh();
316
+ }, { icon: 'check' }),
305
317
  Btn('취소', { variant: 'ghost', onClick: () => { editor.style.display = 'none'; toggle.style.display = ''; } }),
306
318
  ),
307
319
  );
@@ -344,7 +356,7 @@ function DocRow(doc, kindLabels, m, reload) {
344
356
  el('div', { class: 'flex gap-2 wrap center' },
345
357
  el('span', { class: 'strong' }, doc.title),
346
358
  el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
347
- doc.is_primary ? Badge('final_passed', '대표') : null,
359
+ doc.is_primary ? Badge('accent', '대표') : null,
348
360
  ),
349
361
  el('span', { class: 'muted text-sm' }, fmtRelative(doc.updated_at)),
350
362
  );
@@ -363,7 +375,13 @@ function DocRow(doc, kindLabels, m, reload) {
363
375
  }
364
376
 
365
377
  async function openDocDetail(id, kindLabels, m, reload) {
366
- const doc = (await get(`/api/documents/${id}`)).document;
378
+ let doc;
379
+ try {
380
+ doc = (await get(`/api/documents/${id}`)).document;
381
+ } catch (err) {
382
+ toastError(err);
383
+ return;
384
+ }
367
385
 
368
386
  openModal({
369
387
  title: doc.title,
@@ -371,7 +389,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
371
389
  body: el('div', { class: 'stack-3' },
372
390
  el('div', { class: 'flex gap-2 wrap center' },
373
391
  el('span', { class: 'chip' }, kindLabels[doc.kind] || doc.kind),
374
- doc.is_primary ? Badge('final_passed', '대표') : null,
392
+ doc.is_primary ? Badge('accent', '대표') : null,
375
393
  el('span', { class: 'chip' }, sourceLabel(doc.source)),
376
394
  el('span', { class: 'muted text-sm' }, `수정 ${fmtRelative(doc.updated_at)}`),
377
395
  ),
@@ -383,7 +401,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
383
401
  Btn('삭제', { variant: 'danger', icon: 'trash', onClick: async () => {
384
402
  const ok = await confirmDialog({ title: '문서 삭제', message: `"${doc.title}"을(를) 삭제할까요?`, confirmLabel: '삭제', danger: true });
385
403
  if (!ok) return;
386
- try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했습니다.'); close(); await reload(); }
404
+ try { await del(`/api/documents/${doc.id}`); toastOk('문서를 삭제했어요.'); close(); await reload(); }
387
405
  catch (err) { toastError(err); }
388
406
  } }),
389
407
  Btn('수정', { variant: 'primary', icon: 'edit', onClick: () => { close(); openDocEdit(doc, m, reload); } }),
@@ -392,7 +410,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
392
410
  }
393
411
 
394
412
  function openDocCreate(reload) {
395
- meta().then((m) => docForm(null, m, reload));
413
+ meta().then((m) => docForm(null, m, reload)).catch(toastError);
396
414
  }
397
415
 
398
416
  function openDocEdit(doc, m, reload) {
@@ -418,19 +436,17 @@ function docForm(doc, m, reload) {
418
436
  ),
419
437
  footer: (close) => [
420
438
  Btn('취소', { onClick: close }),
421
- Btn('문서 저장', { variant: 'primary', onClick: async () => {
439
+ SubmitBtn('문서 저장', async () => {
422
440
  const t = title.value.trim();
423
- if (!t) { toastError(new Error('제목을 입력해 주세요.')); title.focus(); return; }
424
- if (!content.value.trim()) { toastError(new Error('내용을 입력해 주세요.')); content.focus(); return; }
441
+ if (!t) { title.focus(); throw new Error('제목을 입력해 주세요.'); }
442
+ if (!content.value.trim()) { content.focus(); throw new Error('내용을 입력해 주세요.'); }
425
443
  const body = { kind: kind.value, title: t, content: content.value, is_primary: primary.checked };
426
- try {
427
- if (doc) await put(`/api/documents/${doc.id}`, body);
428
- else await post('/api/documents', { ...body, source: 'manual' });
429
- toastOk(doc ? '문서를 수정했습니다.' : '문서를 추가했습니다.');
430
- close();
431
- await reload();
432
- } catch (err) { toastError(err); }
433
- } }),
444
+ if (doc) await put(`/api/documents/${doc.id}`, body);
445
+ else await post('/api/documents', { ...body, source: 'manual' });
446
+ toastOk(doc ? '문서를 수정했어요.' : '문서를 추가했어요.');
447
+ close();
448
+ await reload();
449
+ }),
434
450
  ],
435
451
  });
436
452
  }
@@ -5,16 +5,9 @@
5
5
  // the same "AI에게 보내면 저장됩니다" instruction across surfaces.
6
6
  import {
7
7
  el, get, icon, navigate, Card, Badge, Btn, EmptyState, ListRow, CheckRow,
8
- Stat, PageHead, fmtDate, fmtRelative, scoreClass, mount,
8
+ Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
9
9
  } from '/lib.js';
10
10
 
11
- // status code → saturated theme colour. MUST track the badge--{status} hues so a
12
- // colour means the same stage in the pipeline bar, the badges, and the board.
13
- const STATUS_COLOR = {
14
- draft: 'var(--slate)', planned: 'var(--blue)', applied: 'var(--violet)',
15
- document_passed: 'var(--teal)', interview: 'var(--amber)',
16
- final_passed: 'var(--green)', on_hold: 'var(--slate)', rejected: 'var(--red)',
17
- };
18
11
  const PIPE_ORDER = ['draft', 'planned', 'applied', 'document_passed', 'interview', 'final_passed', 'on_hold', 'rejected'];
19
12
 
20
13
  // activity type → icon + theme hue (soft tinted chip + coloured stroke)
@@ -84,6 +77,15 @@ function GettingStarted(o) {
84
77
  title: '시작하기',
85
78
  sub: `프로필 완성도 ${o.profile_completeness}%`,
86
79
  body: [
80
+ // The one place (per DESIGN_GUIDE rule 4) that teaches the core mental model:
81
+ // CareerMate stores; your own AI does the thinking. Without this, first-run users
82
+ // read the empty dashboard as a manual CRUD form and never find the real workflow.
83
+ el('div', { class: 'callout mb-3' },
84
+ icon('info'),
85
+ el('div', {},
86
+ el('div', { class: 'callout__title' }, '두뇌는 당신의 AI, 보관은 CareerMate'),
87
+ el('div', { class: 'callout__body' },
88
+ '공고 분석·자기소개서·면접 준비는 평소 쓰던 AI에게 말로 시키면, 결과가 이 대시보드에 쌓여요.'))),
87
89
  el('div', { class: 'progress' }, el('div', { class: 'progress__bar', style: { width: `${o.profile_completeness}%` } })),
88
90
  el('p', { class: 'text-secondary text-sm', style: { margin: '12px 0 8px' } },
89
91
  '완성할수록 AI의 적합도 분석과 자기소개서 품질이 좋아져요.'),
@@ -94,9 +96,8 @@ function GettingStarted(o) {
94
96
 
95
97
  /* ----------------------------------------------- KPI summary tiles (at-a-glance) */
96
98
  function StatTile(label, value, iconName, to) {
97
- const tile = Stat({ label, value, iconName });
99
+ const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
98
100
  tile.classList.add('is-clickable');
99
- tile.addEventListener('click', () => navigate(to));
100
101
  return tile;
101
102
  }
102
103
  function StatRow(counts) {
@@ -162,7 +163,7 @@ function Pipeline(breakdown) {
162
163
 
163
164
  /* ----------------------------------------------- action lane (what to do next) */
164
165
  function statusDot(status) {
165
- return el('span', { class: 'list-row__dot', style: { background: STATUS_COLOR[status] || 'var(--text-tertiary)' } });
166
+ return el('span', { class: 'list-row__dot', style: { background: statusColor(status) } });
166
167
  }
167
168
 
168
169
  function ActionLane(interviewTodo, inProgress) {
@@ -1,7 +1,7 @@
1
1
  // Interview — preparation materials for jobs that reached 서류 합격 or beyond.
2
2
  import {
3
- el, get, put, icon, navigate, Card, Badge, Btn, EmptyState, Field, Input,
4
- Textarea, openModal, closeModal, toastOk, toastError, copyText,
3
+ el, get, put, icon, navigate, Card, Badge, Btn, SubmitBtn, EmptyState, Field, Input,
4
+ Textarea, openModal, closeModal, toastOk, copyText,
5
5
  downloadUrl, mount,
6
6
  } from '/lib.js';
7
7
 
@@ -200,7 +200,7 @@ export async function render(ctx) {
200
200
  body: formBody,
201
201
  footer: (close) => [
202
202
  Btn('취소', { onClick: close }),
203
- Btn('면접 준비 저장', { variant: 'primary', onClick: () => save(jobId) }),
203
+ SubmitBtn('면접 준비 저장', () => save(jobId)),
204
204
  ],
205
205
  });
206
206
 
@@ -216,15 +216,12 @@ export async function render(ctx) {
216
216
  self_introduction: introInput.value,
217
217
  notes: notesInput.value,
218
218
  };
219
- try {
220
- await put(`/api/jobs/${id}/interview`, payload);
221
- closeModal();
222
- toastOk('면접 준비를 저장했어요.');
223
- await ctx.refreshNav();
224
- await render(ctx);
225
- } catch (err) {
226
- toastError(err);
227
- }
219
+ // Throws on failure so the SubmitBtn surfaces it inline.
220
+ await put(`/api/jobs/${id}/interview`, payload);
221
+ closeModal();
222
+ toastOk('면접 준비를 저장했어요.');
223
+ await ctx.refreshNav();
224
+ await render(ctx);
228
225
  }
229
226
  }
230
227
  }