careermate 0.0.10 → 0.0.13

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.
package/dist/migrate.mjs CHANGED
@@ -536,7 +536,7 @@ function ensureTable2() {
536
536
  getDb().exec(`
537
537
  CREATE TABLE IF NOT EXISTS interview_retrospectives (
538
538
  id TEXT PRIMARY KEY,
539
- job_id TEXT NOT NULL,
539
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
540
540
  interview_round TEXT,
541
541
  occurred_at TEXT,
542
542
  questions TEXT NOT NULL DEFAULT '[]',
@@ -549,15 +549,32 @@ function ensureTable2() {
549
549
  );
550
550
  CREATE INDEX IF NOT EXISTS idx_interview_retrospectives_job
551
551
  ON interview_retrospectives(job_id, occurred_at DESC, updated_at DESC);
552
- CREATE TRIGGER IF NOT EXISTS trg_interview_retrospectives_delete_job
553
- AFTER DELETE ON jobs
554
- BEGIN
555
- DELETE FROM interview_retrospectives WHERE job_id = OLD.id;
556
- END;
552
+ DROP TRIGGER IF EXISTS trg_interview_retrospectives_delete_job;
557
553
  `);
558
554
  }
559
555
  ensureTable2();
560
556
 
557
+ // packages/db/src/job-search.ts
558
+ function ensureTables() {
559
+ getDb().exec(`
560
+ CREATE TABLE IF NOT EXISTS job_search_seen (
561
+ source TEXT NOT NULL,
562
+ external_id TEXT NOT NULL,
563
+ first_seen_at TEXT NOT NULL,
564
+ last_seen_at TEXT NOT NULL,
565
+ PRIMARY KEY (source, external_id)
566
+ );
567
+ CREATE TABLE IF NOT EXISTS job_search_preferences (
568
+ id INTEGER PRIMARY KEY CHECK (id = 1),
569
+ keyword TEXT NOT NULL DEFAULT '',
570
+ remember_categories TEXT NOT NULL DEFAULT '[]',
571
+ saramin_categories TEXT NOT NULL DEFAULT '[]',
572
+ updated_at TEXT NOT NULL
573
+ );
574
+ `);
575
+ }
576
+ ensureTables();
577
+
561
578
  // packages/shared/src/enums.ts
562
579
  var APPLICATION_STATUSES = [
563
580
  "draft",
@@ -24,10 +24,6 @@ const actionsEl = document.getElementById('topbar-actions');
24
24
  const routeStatusEl = document.getElementById('route-status');
25
25
  const moduleCache = new Map();
26
26
 
27
- function removePrivateRepositoryLinks(root = document) {
28
- root.querySelectorAll?.('a[href*="github.com/osntak/careermate"]').forEach((link) => link.remove());
29
- }
30
-
31
27
  function parseHash() {
32
28
  const raw = location.hash.replace(/^#\/?/, '');
33
29
  const segments = raw.split('/').filter(Boolean);
@@ -95,7 +91,6 @@ async function route() {
95
91
  if (token !== renderToken) return;
96
92
  clear(view);
97
93
  await mod.render(ctx);
98
- removePrivateRepositoryLinks(view);
99
94
  if (routeStatusEl) routeStatusEl.textContent = titleEl.textContent || t('nav.' + def.id);
100
95
  } catch (err) {
101
96
  if (token !== renderToken) return;
@@ -142,7 +137,6 @@ function localizeShell() {
142
137
  faq.href = en ? 'https://careermate.life/en/faq' : 'https://careermate.life/faq';
143
138
  faq.title = en ? 'FAQ' : '자주 묻는 질문';
144
139
  }
145
- removePrivateRepositoryLinks();
146
140
  }
147
141
 
148
142
  async function boot() {
@@ -164,4 +158,4 @@ async function boot() {
164
158
  await route();
165
159
  }
166
160
 
167
- boot();
161
+ boot();
@@ -2,7 +2,6 @@
2
2
  export default {
3
3
  // shared
4
4
  'settings.copy': 'Copy',
5
- 'settings.confirm': 'OK',
6
5
  'settings.cancel': 'Cancel',
7
6
  'settings.copyPath': 'Copy path',
8
7
 
@@ -17,8 +16,6 @@ export default {
17
16
 
18
17
  // connection
19
18
  'settings.connection.title': 'Connection',
20
- 'settings.connection.shortcutDemoToast': "Here's a preview of how the shortcut works.",
21
- 'settings.connection.shortcutToast': 'Created a dashboard shortcut.',
22
19
  'settings.connection.shortcutDemoTitle': 'Shortcut preview',
23
20
  'settings.connection.shortcutTitle': 'Shortcut created',
24
21
  'settings.connection.shortcutDemoCalloutTitle': "The demo doesn't create files",
@@ -123,8 +120,6 @@ export default {
123
120
 
124
121
  // help / report a problem
125
122
  'settings.support.title': 'Help & report a problem',
126
- 'settings.support.sub': 'Report bugs on GitHub Issues, ask how-to questions in Discussions, or email us for anything else.',
127
- 'settings.support.report': 'Report a problem',
128
- 'settings.support.ask': 'Ask a question',
123
+ 'settings.support.sub': 'Report bugs, questions, and feature requests by email. Do not include private career data in support messages.',
129
124
  'settings.support.email': 'Email us',
130
125
  };
@@ -2,7 +2,6 @@
2
2
  export default {
3
3
  // 공통
4
4
  'settings.copy': '복사',
5
- 'settings.confirm': '확인',
6
5
  'settings.cancel': '취소',
7
6
  'settings.copyPath': '경로 복사',
8
7
 
@@ -17,8 +16,6 @@ export default {
17
16
 
18
17
  // 연결 상태
19
18
  'settings.connection.title': '연결 상태',
20
- 'settings.connection.shortcutDemoToast': '데모 바로가기 동작을 미리 보여드려요.',
21
- 'settings.connection.shortcutToast': '대시보드 바로가기를 만들었어요.',
22
19
  'settings.connection.shortcutDemoTitle': '바로가기 미리보기',
23
20
  'settings.connection.shortcutTitle': '바로가기 생성',
24
21
  'settings.connection.shortcutDemoCalloutTitle': '데모에서는 파일을 만들지 않습니다',
@@ -123,8 +120,6 @@ export default {
123
120
 
124
121
  // help / report a problem
125
122
  'settings.support.title': '도움말·문제 신고',
126
- 'settings.support.sub': '버그는 GitHub 이슈로, 사용법 질문은 Discussions로, 그 밖의 문의는 이메일로 보내 주세요.',
127
- 'settings.support.report': '문제 신고',
128
- 'settings.support.ask': '질문하기',
123
+ 'settings.support.sub': '\ubc84\uadf8, \uc0ac\uc6a9\ubc95 \uc9c8\ubb38, \uae30\ub2a5 \uc694\uccad\uc740 \uc774\uba54\uc77c\ub85c \ubcf4\ub0b4 \uc8fc\uc138\uc694. \ubb38\uc758\uc5d0\ub294 \uac1c\uc778 \ucee4\ub9ac\uc5b4 \ub370\uc774\ud130\ub97c \ub123\uc9c0 \ub9c8\uc138\uc694.',
129
124
  'settings.support.email': '이메일 문의',
130
125
  };
@@ -28,7 +28,6 @@
28
28
  <div class="sidebar__foot">
29
29
  <div><span class="dot"></span><span id="foot-status">로컬에서 실행 중</span></div>
30
30
  <div id="foot-path" class="truncate" style="margin-top:4px"></div>
31
- <a class="sidebar__foot-link" href="https://github.com/osntak/careermate" target="_blank" rel="noopener" title="GitHub에서 소스 보기"><svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg><span>GitHub</span></a>
32
31
  <a class="sidebar__foot-link" id="foot-faq" href="https://careermate.life/faq" target="_blank" rel="noopener" title="자주 묻는 질문"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg><span>FAQ</span></a>
33
32
  </div>
34
33
  </aside>
@@ -411,6 +411,9 @@ export function Spinner() { return el('div', { class: 'view' }, el('div', { clas
411
411
 
412
412
  /* ------------------------------------------------------------------ toast */
413
413
  let toastRoot;
414
+ function clearToasts() {
415
+ if (toastRoot) clear(toastRoot);
416
+ }
414
417
  export function toast(message, { title, type = 'default', timeout = 3800 } = {}) {
415
418
  if (!toastRoot) { toastRoot = el('div', { class: 'toasts' }); document.body.append(toastRoot); }
416
419
  const t = el('div', { class: `toast toast--${type}` },
@@ -436,22 +439,27 @@ function ensureModalRoot() {
436
439
  }
437
440
  export function closeModal() {
438
441
  if (modalRoot) { modalRoot.classList.remove('is-open'); clear(modalRoot); }
442
+ delete document.body.dataset.modalIntent;
439
443
  // Return focus to whatever opened the modal so keyboard users aren't dumped at the page top.
440
444
  if (modalReturnFocus) { try { modalReturnFocus.focus(); } catch { /* element gone */ } modalReturnFocus = null; }
441
445
  }
442
446
  /**
443
447
  * Open a modal. `render(close)` returns the modal body content. Provide title,
444
- * optional footer buttons via `footer(close)`. Returns nothing; call closeModal().
448
+ * optional footer buttons via `footer(close)`. `intent` only changes
449
+ * interruption weight; pages should not hand-roll dialog styles.
445
450
  */
446
- export function openModal({ title, body, footer, size }) {
451
+ export function openModal({ title, body, footer, size, intent = 'dialog' }) {
447
452
  const root = ensureModalRoot();
448
453
  const close = closeModal;
454
+ if (intent === 'contextual' || intent === 'alert') clearToasts();
455
+ root.dataset.intent = intent;
456
+ document.body.dataset.modalIntent = intent;
449
457
  modalReturnFocus = document.activeElement; // restored by closeModal
450
458
  const closeBtn = IconBtn('plus', { title: t('common.close'), onClick: close }); // rotated to look like ×
451
459
  closeBtn.style.transform = 'rotate(45deg)';
452
460
  const modal = el('div', {
453
- class: `modal${size === 'lg' ? ' modal--lg' : ''}`,
454
- attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
461
+ class: `modal${size === 'lg' ? ' modal--lg' : ''}${intent === 'alert' ? ' modal--alert' : ''}`,
462
+ attrs: { role: intent === 'alert' ? 'alertdialog' : 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'modal-title' },
455
463
  },
456
464
  el('div', { class: 'modal__head' }, el('h3', { id: 'modal-title' }, title), el('div', { class: 'topbar__spacer' }), closeBtn),
457
465
  );
@@ -482,6 +490,7 @@ export function confirmDialog({ title = t('common.confirm'), message, confirmLab
482
490
  return new Promise((resolve) => {
483
491
  openModal({
484
492
  title,
493
+ intent: danger ? 'alert' : 'dialog',
485
494
  body: el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } }, message),
486
495
  footer: (close) => [
487
496
  Btn(t('common.cancel'), { onClick: () => { close(); resolve(false); } }),
@@ -21,6 +21,7 @@ export async function render(ctx) {
21
21
  const root = el('div', { class: 'stack-4' });
22
22
 
23
23
  const panel = el('div', {});
24
+ let activeHasItems = true;
24
25
  // Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
25
26
  // semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
26
27
  const makeTab = (key, label) =>
@@ -43,7 +44,7 @@ export async function render(ctx) {
43
44
  node.classList.toggle('is-active', tab === key);
44
45
  node.setAttribute('aria-selected', String(tab === key));
45
46
  }
46
- setActions();
47
+ ctx.setActions([]);
47
48
  renderPanel();
48
49
  }
49
50
 
@@ -74,9 +75,14 @@ export async function render(ctx) {
74
75
  async function renderPanel() {
75
76
  mount(panel, el('div', { class: 'muted text-sm' }, t('documents.loading')));
76
77
  try {
77
- if (tab === 'cover') mount(panel, await CoverLettersTab(reload));
78
- else if (tab === 'career') mount(panel, await CareerDescriptionsTab(reload));
79
- else mount(panel, await DocumentsTab(reload));
78
+ const content = tab === 'cover'
79
+ ? await CoverLettersTab(reload)
80
+ : tab === 'career'
81
+ ? await CareerDescriptionsTab(reload)
82
+ : await DocumentsTab(reload);
83
+ activeHasItems = content.dataset.hasItems === 'true';
84
+ mount(panel, content);
85
+ setActions();
80
86
  applyFilter();
81
87
  await openRequestedDocument();
82
88
  } catch (err) {
@@ -102,6 +108,10 @@ export async function render(ctx) {
102
108
 
103
109
  // topbar: search (filters the active tab) + the tab's create action
104
110
  function setActions() {
111
+ if (!activeHasItems) {
112
+ ctx.setActions([]);
113
+ return;
114
+ }
105
115
  const placeholder = tab === 'cover' ? t('documents.search.cover') : tab === 'career' ? t('documents.search.career') : t('documents.search.docs');
106
116
  const search = Input({ type: 'search', placeholder, value: query, attrs: { 'aria-label': t('documents.search.aria') } });
107
117
  search.classList.add('input--inline');
@@ -115,7 +125,7 @@ export async function render(ctx) {
115
125
  }
116
126
 
117
127
  mount(ctx.view, root);
118
- setActions();
128
+ ctx.setActions([]);
119
129
  await renderPanel();
120
130
  }
121
131
 
@@ -124,6 +134,7 @@ export async function render(ctx) {
124
134
  async function CoverLettersTab(reload) {
125
135
  const { cover_letters: list } = await get('/api/cover-letters');
126
136
  const wrap = el('div', { class: 'stack-3' });
137
+ wrap.dataset.hasItems = list.length ? 'true' : 'false';
127
138
 
128
139
  if (!list.length) {
129
140
  return mountInto(wrap, EmptyState({
@@ -376,6 +387,7 @@ async function CareerDescriptionsTab(reload) {
376
387
  const [{ documents: list }, m] = await Promise.all([get('/api/documents?kind=career_description'), meta()]);
377
388
  const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
378
389
  const wrap = el('div', { class: 'stack-3' });
390
+ wrap.dataset.hasItems = list.length ? 'true' : 'false';
379
391
 
380
392
  if (!list.length) {
381
393
  return mountInto(wrap, EmptyState({
@@ -397,6 +409,7 @@ async function DocumentsTab(reload) {
397
409
  const list = (documents || []).filter((doc) => doc.kind !== 'career_description');
398
410
  const kindLabels = Object.fromEntries((m.document_kinds || []).map((k) => [k.value, t('kind.' + k.value)]));
399
411
  const wrap = el('div', { class: 'stack-3' });
412
+ wrap.dataset.hasItems = list.length ? 'true' : 'false';
400
413
 
401
414
  if (!list.length) {
402
415
  return mountInto(wrap, EmptyState({
@@ -262,13 +262,15 @@ function ActionLane(s) {
262
262
  onClick: it.job ? () => navigate(`/jobs/${it.job.id}`) : null,
263
263
  }));
264
264
  }
265
- return Card({
265
+ const card = Card({
266
266
  title: t('home.actionLane.title'),
267
267
  actions: rows.length ? Btn(t('home.actionLane.viewAll'), { sm: true, variant: 'ghost', onClick: () => navigate('/applications') }) : null,
268
268
  body: rows.length
269
269
  ? el('div', {}, ...rows)
270
270
  : el('p', { class: 'muted', style: { margin: 0 } }, t('home.actionLane.empty')),
271
271
  });
272
+ card.classList.add('home-action-card');
273
+ return card;
272
274
  }
273
275
 
274
276
  /* ----------------------------------------------- recent jobs */
@@ -30,12 +30,16 @@ export async function render(ctx) {
30
30
  }
31
31
 
32
32
  /* ----------------------------------------------------- 데이터 저장 위치 */
33
- function kvRow(label, value, copyable) {
33
+ function kvRow(label, value, copyable, { wrapValue = false } = {}) {
34
34
  return [
35
35
  el('dt', {}, label),
36
36
  el('dd', {},
37
37
  el('div', { class: 'flex between gap-3' },
38
- el('span', { class: 'truncate tnum', style: { fontFamily: 'var(--mono, ui-monospace, monospace)' }, title: value }, value || '—'),
38
+ el('span', {
39
+ class: `${wrapValue ? 'kv__value-wrap' : 'truncate'} tnum`,
40
+ style: { fontFamily: 'var(--mono, ui-monospace, monospace)' },
41
+ title: value,
42
+ }, value || '—'),
39
43
  copyable && value
40
44
  ? IconBtn('copy', { title: t('settings.copy'), onClick: () => copyText(value) })
41
45
  : null)),
@@ -70,9 +74,9 @@ function Connection(info) {
70
74
  async function createShortcut() {
71
75
  try {
72
76
  const { shortcut } = await post('/api/settings/dashboard-shortcut', { open: true });
73
- toastOk(isDemo ? t('settings.connection.shortcutDemoToast') : t('settings.connection.shortcutToast'));
74
77
  openModal({
75
78
  title: isDemo ? t('settings.connection.shortcutDemoTitle') : t('settings.connection.shortcutTitle'),
79
+ intent: 'contextual',
76
80
  body: el('div', { class: 'stack-3' },
77
81
  el('div', { class: 'callout' },
78
82
  icon('info'),
@@ -83,11 +87,11 @@ function Connection(info) {
83
87
  ? t('settings.connection.shortcutDemoCalloutBody')
84
88
  : t('settings.connection.shortcutCalloutBody')))),
85
89
  el('dl', { class: 'kv' },
86
- ...kvRow(t('settings.connection.shortcutFolder'), shortcut.shortcut_dir, true),
87
- ...kvRow(t('settings.connection.shortcutLauncher'), shortcut.launcher_path, true))),
90
+ ...kvRow(t('settings.connection.shortcutFolder'), shortcut.shortcut_dir, true, { wrapValue: true }),
91
+ ...kvRow(t('settings.connection.shortcutLauncher'), shortcut.launcher_path, true, { wrapValue: true }))),
88
92
  footer: (close) => [
89
- Btn(t('settings.copyPath'), { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
90
- Btn(t('settings.confirm'), { variant: 'primary', onClick: close }),
93
+ Btn(t('settings.copyPath'), { icon: 'copy', variant: 'primary', onClick: () => copyText(shortcut.launcher_path) }),
94
+ Btn(t('common.close'), { variant: 'ghost', onClick: close }),
91
95
  ],
92
96
  });
93
97
  } catch (e) {
@@ -360,6 +364,7 @@ function openImportModal({ fileName, backup, preview, rerender }) {
360
364
  openModal({
361
365
  title: t('settings.import.title'),
362
366
  size: 'lg',
367
+ intent: 'alert',
363
368
  body: el('div', { class: 'stack-3' },
364
369
  el('div', { class: 'callout' },
365
370
  icon('info'),
@@ -409,15 +414,10 @@ function openImportModal({ fileName, backup, preview, rerender }) {
409
414
  /* -------------------------------------------------- 위험 구역 (Danger zone) */
410
415
  /* ----------------------------------------------------- 도움말 · 문제 신고 */
411
416
  function Support() {
412
- const ext = (href, iconName, label) =>
413
- el('a', { class: 'btn btn--ghost btn--sm', href, attrs: { target: '_blank', rel: 'noopener noreferrer' } },
414
- icon(iconName), el('span', {}, label));
415
417
  return Card({
416
418
  title: t('settings.support.title'),
417
419
  sub: t('settings.support.sub'),
418
420
  body: el('div', { class: 'flex gap-2 wrap' },
419
- ext('https://github.com/osntak/careermate/issues/new?template=bug_report.yml', 'external', t('settings.support.report')),
420
- ext('https://github.com/osntak/careermate/discussions', 'external', t('settings.support.ask')),
421
421
  el('a', { class: 'btn btn--ghost btn--sm', href: 'mailto:osntak@gmail.com' }, icon('mail'), el('span', {}, t('settings.support.email'))),
422
422
  ),
423
423
  });
@@ -437,6 +437,7 @@ function DangerZone(ctx) {
437
437
 
438
438
  openModal({
439
439
  title: t('settings.reset.title'),
440
+ intent: 'alert',
440
441
  body: el('div', { class: 'stack-3' },
441
442
  el('div', { class: 'callout' },
442
443
  icon('info'),
@@ -406,6 +406,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
406
406
  .list-row:focus-within .row-action { opacity: 1; pointer-events: auto; }
407
407
  @media (hover: none) { .list-row .row-action { opacity: 1; pointer-events: auto; } }
408
408
 
409
+ /* Home's "next action" lane is scanned repeatedly on mobile, so keep it tighter
410
+ than generic cards while preserving the shared ListRow structure. */
411
+ .home-action-card .list-row__trail .badge { max-width: 116px; overflow: hidden; text-overflow: ellipsis; }
412
+
409
413
  /* ----------------------------------------------------------------- pipeline bar + funnel
410
414
  Visualizes the application status_breakdown. Segment color == badge--{status} hue,
411
415
  so a colour means the same stage everywhere (board, badges, here). */
@@ -467,6 +471,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
467
471
  /* min-width:0 overrides a grid item's default min-width:auto so the 1fr column
468
472
  can shrink below its content — otherwise a long path/value overflows the grid. */
469
473
  .kv dd { margin: 0; min-width: 0; }
474
+ .kv__value-wrap { min-width: 0; white-space: normal; word-break: keep-all; overflow-wrap: anywhere; line-height: 1.5; }
470
475
  .settings-action-row { align-items: flex-start; min-width: 0; }
471
476
  .settings-action-row__main { flex: 1 1 auto; min-width: 0; }
472
477
  .settings-action-row__main > div:last-child { min-width: 0; }
@@ -476,10 +481,13 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
476
481
  .modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
477
482
  .modal-root.is-open { display: block; }
478
483
  .modal__scrim { position: absolute; inset: 0; background: rgba(16,16,20,.42); backdrop-filter: blur(2px); animation: fade .15s; }
484
+ .modal-root[data-intent="contextual"] .modal__scrim { background: rgba(16,16,20,.22); backdrop-filter: blur(1px); }
485
+ .modal-root[data-intent="alert"] .modal__scrim { background: rgba(16,16,20,.52); }
479
486
  .modal { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: min(640px, calc(100vw - 32px));
480
487
  max-height: calc(100vh - 48px); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
481
488
  box-shadow: var(--shadow-lg); display: flex; flex-direction: column; animation: pop .16s ease; }
482
489
  .modal--lg { width: min(880px, calc(100vw - 32px)); }
490
+ .modal--alert { width: min(520px, calc(100vw - 32px)); }
483
491
  .modal__head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
484
492
  .modal__head h3 { font-size: 15px; }
485
493
  .modal__body { padding: 20px; overflow-y: auto; }
@@ -488,6 +496,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
488
496
  .modal__error[hidden] { display: none; }
489
497
  .modal__foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
490
498
  @keyframes pop { from { transform: translate(-50%,-46%); opacity: 0; } to { transform: translate(-50%,-50%); opacity: 1; } }
499
+ @keyframes sheetup { from { transform: translateY(18px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
491
500
  @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
492
501
 
493
502
  /* Dropdown menu (Menu/ExportMenu) — fixed popover anchored to its trigger so it
@@ -507,6 +516,7 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
507
516
 
508
517
  /* ----------------------------------------------------------------- toast */
509
518
  .toasts { position: fixed; bottom: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
519
+ body[data-modal-intent] .toasts { top: 20px; bottom: auto; }
510
520
  .toast { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius);
511
521
  box-shadow: var(--shadow-md); padding: 12px 16px; min-width: 260px; max-width: 380px; animation: slidein .2s ease; font-size: 14px; }
512
522
  .toast--success { border-left-color: var(--green); }
@@ -578,7 +588,27 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
578
588
  .settings-action-row > .btn{ width: 100%; justify-content: center; }
579
589
  .card__head{ align-items: flex-start; flex-wrap: wrap; }
580
590
  .card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
591
+ .home-action-card .card__head{ align-items: center; flex-wrap: nowrap; padding: 12px 14px; }
592
+ .home-action-card .card__head .right{ margin-left: auto; width: auto; justify-content: flex-end; }
593
+ .home-action-card .card__body{ padding: 8px 14px; }
594
+ .home-action-card .list-row{ gap: 10px; padding: 8px 0; }
595
+ .home-action-card .list-row__trail{ gap: 6px; }
581
596
  .page-head{ flex-wrap: wrap; }
597
+ .modal-root[data-intent="contextual"] .modal{
598
+ top: auto; left: 0; right: 0; bottom: 0; transform: none; width: 100%;
599
+ max-height: min(86vh, calc(100vh - 24px)); border-radius: var(--radius-lg) var(--radius-lg) 0 0;
600
+ border-bottom: 0; animation: sheetup .16s ease;
601
+ }
602
+ .modal-root[data-intent="contextual"] .modal__foot{ justify-content: stretch; }
603
+ .modal-root[data-intent="contextual"] .modal__foot .btn{ flex: 1 1 auto; justify-content: center; }
604
+ .modal__foot{
605
+ justify-content: stretch; flex-wrap: wrap;
606
+ padding-bottom: max(14px, env(safe-area-inset-bottom));
607
+ }
608
+ .modal__foot .btn{ flex: 1 1 0; min-width: 0; min-height: 44px; justify-content: center; }
609
+ .toasts{ left: 16px; right: 16px; bottom: max(16px, env(safe-area-inset-bottom)); }
610
+ body[data-modal-intent] .toasts{ top: calc(var(--topbar-h) + 16px); bottom: auto; }
611
+ .toast{ width: 100%; min-width: 0; max-width: none; }
582
612
  }
583
613
 
584
614
  /* ---------- reduced motion ----------