careermate 0.0.3 → 0.0.5

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
@@ -4436,7 +4436,20 @@ var optLine = external_exports.string().max(MAX_LINE).optional();
4436
4436
  var optNote = external_exports.string().max(MAX_NOTE).optional();
4437
4437
  var optBody = external_exports.string().max(MAX_BODY).optional();
4438
4438
  var reqBody = external_exports.string().max(MAX_BODY).refine((s) => s.trim().length > 0, { message: EMPTY_MSG });
4439
- var strList = external_exports.array(external_exports.string().max(MAX_ITEM)).max(MAX_ITEMS);
4439
+ function toStrList(v) {
4440
+ if (typeof v !== "string") return v;
4441
+ const s = v.trim();
4442
+ if (!s) return [];
4443
+ if (s.startsWith("[")) {
4444
+ try {
4445
+ const parsed = JSON.parse(s);
4446
+ if (Array.isArray(parsed)) return parsed;
4447
+ } catch {
4448
+ }
4449
+ }
4450
+ return s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
4451
+ }
4452
+ var strList = external_exports.preprocess(toStrList, external_exports.array(external_exports.string().max(MAX_ITEM)).max(MAX_ITEMS));
4440
4453
  var baseRecord = {
4441
4454
  id,
4442
4455
  created_at: isoDateTime,
@@ -4498,6 +4511,9 @@ var ExperienceRecordSchema = external_exports.object({
4498
4511
  tech: external_exports.array(external_exports.string()),
4499
4512
  order_index: external_exports.number()
4500
4513
  });
4514
+ var ExperienceBatchInputSchema = external_exports.object({
4515
+ experiences: external_exports.array(ExperienceInputSchema).min(1).max(MAX_ITEMS).describe("\uCD94\uAC00\uD560 \uACBD\uB825 \uBAA9\uB85D(\uD55C \uAC74\uC774\uC5B4\uB3C4 \uBC30\uC5F4\uB85C). \uAC19\uC740 \uD68C\uC0AC\xB7\uC9C1\uBB34\xB7\uC785\uC0AC\uC77C\uC740 \uAC31\uC2E0\uB418\uACE0 \uC911\uBCF5 \uC0DD\uC131\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.")
4516
+ });
4501
4517
  var ProjectInputSchema = external_exports.object({
4502
4518
  name: reqLine,
4503
4519
  role: optLine,
@@ -4521,6 +4537,9 @@ var ProjectRecordSchema = external_exports.object({
4521
4537
  end_date: external_exports.string().nullable(),
4522
4538
  order_index: external_exports.number()
4523
4539
  });
4540
+ var ProjectBatchInputSchema = external_exports.object({
4541
+ projects: external_exports.array(ProjectInputSchema).min(1).max(MAX_ITEMS).describe("\uCD94\uAC00\uD560 \uD504\uB85C\uC81D\uD2B8 \uBAA9\uB85D(\uD55C \uAC1C\uC5EC\uB3C4 \uBC30\uC5F4\uB85C). \uAC19\uC740 \uC774\uB984\uC758 \uD504\uB85C\uC81D\uD2B8\uB294 \uAC31\uC2E0\uB418\uACE0 \uC911\uBCF5 \uC0DD\uC131\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.")
4542
+ });
4524
4543
  var SkillInputSchema = external_exports.object({
4525
4544
  name: reqLine,
4526
4545
  category: external_exports.string().max(MAX_LINE).optional().describe("\uC5B8\uC5B4/\uD504\uB808\uC784\uC6CC\uD06C/\uD234/\uC18C\uD504\uD2B8\uC2A4\uD0AC \uB4F1"),
@@ -4536,6 +4555,9 @@ var SkillRecordSchema = external_exports.object({
4536
4555
  years: external_exports.number().nullable(),
4537
4556
  order_index: external_exports.number()
4538
4557
  });
4558
+ var SkillBatchInputSchema = external_exports.object({
4559
+ skills: external_exports.array(SkillInputSchema).min(1).max(MAX_ITEMS).describe("\uCD94\uAC00\uD560 \uAE30\uC220\uC2A4\uD0DD \uBAA9\uB85D(\uD55C \uAC1C\uC5EC\uB3C4 \uBC30\uC5F4\uB85C). \uAC19\uC740 \uC774\uB984\uC758 \uAE30\uC220\uC740 \uAC31\uC2E0\uB418\uACE0 \uC911\uBCF5 \uC0DD\uC131\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.")
4560
+ });
4539
4561
  var DocumentInputSchema = external_exports.object({
4540
4562
  kind: external_exports.enum(DOCUMENT_KINDS),
4541
4563
  title: reqLine,
@@ -83,6 +83,7 @@ const ICONS = {
83
83
  trash: '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6"/>',
84
84
  copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
85
85
  download: '<path d="M12 3v12"/><path d="m7 11 5 4 5-4"/><path d="M5 21h14"/>',
86
+ upload: '<path d="M12 21V9"/><path d="m7 13 5-4 5 4"/><path d="M5 3h14"/>',
86
87
  external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/>',
87
88
  check: '<path d="m20 6-11 11-5-5"/>',
88
89
  sparkle: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>',
@@ -135,7 +136,11 @@ export async function api(method, path, body) {
135
136
  const text = await res.text();
136
137
  let data = {};
137
138
  if (text) { try { data = JSON.parse(text); } catch { /* non-JSON error page */ } }
138
- if (!res.ok) throw new Error(data.error || friendlyStatus(res.status));
139
+ if (!res.ok) {
140
+ const e = new Error(data.error || friendlyStatus(res.status));
141
+ e.status = res.status; // lets callers self-heal (e.g. drop a stale row on 404)
142
+ throw e;
143
+ }
139
144
  return data;
140
145
  }
141
146
  // Friendly fallback when the server didn't supply a message — never show a raw status code.
@@ -100,6 +100,13 @@ export async function render(ctx) {
100
100
  await render(ctx); // refetch + re-render so the item moves to its new group
101
101
  } catch (err) {
102
102
  toastError(err);
103
+ if (err && err.status === 404) {
104
+ // The job/application no longer exists (e.g. a stale tab after a reset).
105
+ // Refresh so the phantom row disappears instead of erroring on every retry.
106
+ await ctx.refreshNav();
107
+ await render(ctx);
108
+ return;
109
+ }
103
110
  selectEl.value = app.status; // revert on failure
104
111
  selectEl.disabled = false;
105
112
  }
@@ -262,7 +262,13 @@ async function renderDetail(ctx, jobId) {
262
262
  if (res && res.hint) toastOk(res.hint);
263
263
  await ctx.refreshNav();
264
264
  await remount();
265
- } catch (err) { toastError(err); e.target.value = job.status; }
265
+ } catch (err) {
266
+ toastError(err);
267
+ // Stale tab: the job was removed since this page loaded. Re-render so the
268
+ // detail reflects reality (shows "not found") instead of looping the error.
269
+ if (err && err.status === 404) { await ctx.refreshNav(); await remount(); return; }
270
+ e.target.value = job.status;
271
+ }
266
272
  },
267
273
  },
268
274
  );
@@ -309,21 +315,19 @@ async function renderDetail(ctx, jobId) {
309
315
  el('div', { class: 'page-head__actions' }, headActions),
310
316
  );
311
317
 
312
- // --- Layout: two columns (info+fit left, side cards right) ----------------
313
- const left = el('div', { class: 'stack-4' },
318
+ // --- Layout ---------------------------------------------------------------
319
+ // 자기소개서·면접을 맨 위 2열로(둘 다 짧음), 그 아래 공고 정보(원문은 접힘)와
320
+ // 적합도를 전체폭으로 쌓는다. 한쪽 칼럼만 길어 빈 공간이 도드라지던 50/50
321
+ // 그리드를 대체한다. 관련 기록은 있을 때만 맨 아래.
322
+ const wrap = el('div', { class: 'stack-4' },
323
+ head,
324
+ el('div', { class: 'grid grid--2' },
325
+ CoverLettersCard(job.cover_letters),
326
+ InterviewCard(job)),
314
327
  PostingCard(job),
315
328
  FitCard(job.fit),
316
- );
317
- const right = el('div', { class: 'stack-4' },
318
- CoverLettersCard(job.cover_letters),
319
- InterviewCard(job),
320
329
  RelatedCard(job.related),
321
330
  );
322
-
323
- const wrap = el('div', { class: 'stack-4' },
324
- head,
325
- el('div', { class: 'grid grid--2' }, left, right),
326
- );
327
331
  mount(ctx.view, wrap);
328
332
  }
329
333
 
@@ -353,9 +357,12 @@ function PostingCard(job) {
353
357
  }
354
358
 
355
359
  if (job.description) {
356
- body.push(el('div', { class: 'mt-3' },
357
- el('div', { class: 'muted text-sm mb-2' }, '공고 원문'),
358
- el('div', { class: 'doc-preview' }, job.description)));
360
+ // 원문은 가장 길어 칼럼을 늘리는 주범 — 기본 접힘(아코디언)으로 둔다.
361
+ body.push(el('details', { class: 'disclosure mt-3' },
362
+ el('summary', { class: 'disclosure__summary' },
363
+ icon('chevronRight', 'disclosure__chevron'),
364
+ el('span', {}, '공고 원문')),
365
+ el('div', { class: 'doc-preview mt-2' }, job.description)));
359
366
  }
360
367
 
361
368
  return Card({ title: '공고 정보', body });
@@ -10,6 +10,12 @@ const COUNT_LABELS = {
10
10
  documents: '문서', cover_letters: '자기소개서', jobs: '채용공고',
11
11
  applications: '지원', interview_preps: '면접 준비',
12
12
  };
13
+ const BACKUP_COUNT_LABELS = {
14
+ ...COUNT_LABELS,
15
+ cover_letter_versions: '자소서 버전',
16
+ fit_analyses: '적합도 분석',
17
+ activities: '활동',
18
+ };
13
19
 
14
20
  export async function render(ctx) {
15
21
  ctx.setActions([]);
@@ -21,7 +27,7 @@ export async function render(ctx) {
21
27
  Theme(),
22
28
  VerifyMode(info),
23
29
  MyData(info.counts),
24
- Backup(backups, () => render(ctx)),
30
+ Backup(backups, async () => { await ctx.refreshNav(); await render(ctx); }),
25
31
  DangerZone(ctx),
26
32
  );
27
33
 
@@ -64,18 +70,42 @@ function DataLocation(info) {
64
70
 
65
71
  /* ---------------------------------------------------------- 연결 상태 */
66
72
  function Connection(info) {
67
- const dashRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0' } },
68
- el('div', { class: 'flex gap-3', style: { alignItems: 'center' } },
73
+ async function createShortcut() {
74
+ try {
75
+ const { shortcut } = await post('/api/settings/dashboard-shortcut', { open: true });
76
+ toastOk('대시보드 바로가기를 만들었어요.');
77
+ openModal({
78
+ title: '바로가기 생성',
79
+ body: el('div', { class: 'stack-3' },
80
+ el('div', { class: 'callout' },
81
+ icon('info'),
82
+ el('div', {},
83
+ el('div', { class: 'callout__title' }, '폴더를 만들었습니다'),
84
+ el('div', { class: 'callout__body' },
85
+ '대시보드가 실행 중이면 브라우저만 열고, 꺼져 있으면 서버를 시작한 뒤 브라우저를 엽니다.'))),
86
+ el('dl', { class: 'kv' },
87
+ ...kvRow('폴더', shortcut.shortcut_dir, true),
88
+ ...kvRow('바로가기', shortcut.launcher_path, true))),
89
+ footer: (close) => [
90
+ Btn('경로 복사', { icon: 'copy', onClick: () => copyText(shortcut.launcher_path) }),
91
+ Btn('확인', { variant: 'primary', onClick: close }),
92
+ ],
93
+ });
94
+ } catch (e) {
95
+ toastError(e);
96
+ }
97
+ }
98
+
99
+ const dashRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0' } },
100
+ el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'center' } },
69
101
  el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), '실행 중'),
70
102
  el('div', {},
71
103
  el('div', { class: 'strong' }, '대시보드'),
72
104
  el('div', { class: 'muted text-sm tnum' }, info.url || '—'))),
73
- info.url
74
- ? Btn('새 탭에서 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open(info.url, '_blank') })
75
- : null);
105
+ Btn('바로가기 만들기', { icon: 'copy', sm: true, variant: 'ghost', onClick: createShortcut }));
76
106
 
77
- const mcpRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
78
- el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
107
+ const mcpRow = el('div', { class: 'settings-action-row flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
108
+ el('div', { class: 'settings-action-row__main flex gap-3', style: { alignItems: 'flex-start' } },
79
109
  el('div', { class: 'empty__icon', style: { width: '30px', height: '30px', margin: 0, borderRadius: '8px' } }, icon('link')),
80
110
  el('div', {},
81
111
  el('div', { class: 'strong' }, 'MCP 서버'),
@@ -193,8 +223,26 @@ function MyData(counts = {}) {
193
223
  });
194
224
  }
195
225
 
196
- /* ------------------------------------------------- 백업 / 내보내기 */
226
+ /* ------------------------------------------------- 내보내기 / 가져오기 */
197
227
  function Backup(backups = [], rerender) {
228
+ const fileInput = el('input', {
229
+ type: 'file',
230
+ accept: '.json,application/json',
231
+ style: { display: 'none' },
232
+ onChange: async (e) => {
233
+ const file = e.target.files?.[0];
234
+ e.target.value = '';
235
+ if (!file) return;
236
+ try {
237
+ const backup = await readBackupFile(file);
238
+ const { preview } = await post('/api/settings/import-preview', { backup });
239
+ openImportModal({ fileName: file.name, backup, preview, rerender });
240
+ } catch (err) {
241
+ toastError(err);
242
+ }
243
+ },
244
+ });
245
+
198
246
  async function createBackup() {
199
247
  try {
200
248
  const res = await post('/api/settings/backup');
@@ -213,15 +261,113 @@ function Backup(backups = [], rerender) {
213
261
  el('td', {}, el('span', { class: 'truncate', title: b.path || b.filename }, b.filename)),
214
262
  el('td', { class: 'muted text-sm' }, fmtRelative(b.created_at)),
215
263
  el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } }, `${Math.round((b.size || 0) / 1024)} KB`)))))
216
- : el('p', { class: 'muted', style: { margin: 0 } }, '아직 백업이 없습니다. "백업 생성"으로 현재 데이터를 안전하게 보관하세요.');
264
+ : el('p', { class: 'muted', style: { margin: 0 } }, '아직 로컬 백업이 없습니다. "현재 상태 백업"으로 지금 데이터를 보관하세요.');
217
265
 
218
266
  return Card({
219
- title: '백업 / 내보내기',
267
+ title: '내보내기 / 가져오기',
220
268
  actions: [
221
- Btn('백업 생성', { icon: 'copy', sm: true, variant: 'primary', onClick: createBackup }),
222
- Btn('전체 데이터 내보내기(JSON)', { icon: 'download', sm: true, variant: 'ghost', onClick: () => downloadUrl('/api/settings/export-all') }),
269
+ Btn('내보내기(JSON)', { icon: 'download', sm: true, variant: 'primary', onClick: () => downloadUrl('/api/settings/export-all') }),
270
+ Btn('가져오기(JSON)', { icon: 'upload', sm: true, variant: 'ghost', onClick: () => fileInput.click() }),
271
+ Btn('현재 상태 백업', { icon: 'copy', sm: true, variant: 'ghost', onClick: createBackup }),
223
272
  ],
224
- body: list,
273
+ body: el('div', { class: 'stack-3' }, fileInput, list),
274
+ });
275
+ }
276
+
277
+ function readBackupFile(file) {
278
+ return new Promise((resolve, reject) => {
279
+ const reader = new FileReader();
280
+ reader.onerror = () => reject(new Error('백업 파일을 읽지 못했어요.'));
281
+ reader.onload = () => {
282
+ try {
283
+ resolve(JSON.parse(String(reader.result || '')));
284
+ } catch {
285
+ reject(new Error('JSON 백업 파일만 복원할 수 있어요.'));
286
+ }
287
+ };
288
+ reader.readAsText(file);
289
+ });
290
+ }
291
+
292
+ function fmtBackupDate(iso) {
293
+ if (!iso) return '알 수 없음';
294
+ const d = new Date(iso);
295
+ if (Number.isNaN(d.getTime())) return iso;
296
+ return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
297
+ }
298
+
299
+ function previewRows(counts = {}) {
300
+ const entries = Object.entries(counts)
301
+ .filter(([, count]) => Number(count) > 0)
302
+ .sort(([a], [b]) => (BACKUP_COUNT_LABELS[a] || a).localeCompare(BACKUP_COUNT_LABELS[b] || b, 'ko'));
303
+
304
+ if (!entries.length) {
305
+ return el('p', { class: 'muted', style: { margin: 0 } }, '복원할 항목이 없습니다.');
306
+ }
307
+
308
+ return el('table', { class: 'table' },
309
+ el('thead', {}, el('tr', {}, el('th', {}, '데이터'), el('th', { style: { textAlign: 'right' } }, '항목'))),
310
+ el('tbody', {}, ...entries.map(([key, count]) => el('tr', {},
311
+ el('td', {}, BACKUP_COUNT_LABELS[key] || key),
312
+ el('td', { class: 'tnum', style: { textAlign: 'right' } }, String(count))))));
313
+ }
314
+
315
+ function openImportModal({ fileName, backup, preview, rerender }) {
316
+ let confirmBtn;
317
+ const input = Input({
318
+ placeholder: '가져오기',
319
+ onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== '가져오기'; },
320
+ onKeydown: (e) => {
321
+ if (e.key === 'Enter' && e.target.value.trim() === '가져오기' && confirmBtn && !confirmBtn.disabled) confirmBtn.click();
322
+ },
323
+ });
324
+
325
+ openModal({
326
+ title: '데이터 가져오기',
327
+ size: 'lg',
328
+ body: el('div', { class: 'stack-3' },
329
+ el('div', { class: 'callout' },
330
+ icon('info'),
331
+ el('div', {},
332
+ el('div', { class: 'callout__title' }, '현재 데이터는 먼저 백업됩니다'),
333
+ el('div', { class: 'callout__body' },
334
+ '가져오면 이 컴퓨터의 CareerMate 데이터가 선택한 JSON 내용으로 전체 교체됩니다.'))),
335
+ el('dl', { class: 'kv' },
336
+ ...kvRow('파일', fileName, false),
337
+ ...kvRow('백업 시각', fmtBackupDate(preview.exported_at), false),
338
+ ...kvRow('스키마', `${preview.version} / 현재 ${preview.current_version}`, false),
339
+ ...kvRow('전체 항목', String(preview.total_rows || 0), false)),
340
+ previewRows(preview.counts),
341
+ preview.warnings?.length
342
+ ? el('div', { class: 'callout' },
343
+ icon('info'),
344
+ el('div', {},
345
+ el('div', { class: 'callout__title' }, '확인 필요'),
346
+ el('div', { class: 'callout__body' }, preview.warnings.join(' '))))
347
+ : null,
348
+ el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
349
+ '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, '가져오기'), ' 를 입력하세요.'),
350
+ input),
351
+ footer: (close) => {
352
+ confirmBtn = Btn('가져오기', {
353
+ icon: 'upload',
354
+ variant: 'danger',
355
+ disabled: true,
356
+ onClick: async () => {
357
+ confirmBtn.disabled = true;
358
+ try {
359
+ await post('/api/settings/restore', { backup, confirm: 'RESTORE' });
360
+ close();
361
+ toastOk('데이터를 가져왔어요.');
362
+ await rerender();
363
+ } catch (e) {
364
+ toastError(e);
365
+ confirmBtn.disabled = input.value.trim() !== '가져오기';
366
+ }
367
+ },
368
+ });
369
+ return [Btn('취소', { onClick: close }), confirmBtn];
370
+ },
225
371
  });
226
372
  }
227
373
 
@@ -190,10 +190,10 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
190
190
 
191
191
  /* ----------------------------------------------------------------- card */
192
192
  .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
193
- .card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
193
+ .card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; min-width: 0; }
194
194
  .card__head h3 { font-size: 15px; }
195
195
  .card__head .sub { color: var(--text-tertiary); font-size: 13px; }
196
- .card__head .right { margin-left: auto; display: flex; gap: 6px; }
196
+ .card__head .right { margin-left: auto; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; min-width: 0; max-width: 100%; }
197
197
  .card__body { padding: 18px; }
198
198
  .card__body--tight { padding: 0; }
199
199
  /* a light bordered block for grouping inside a card/modal — never nest a full .card */
@@ -403,11 +403,24 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
403
403
  /* ----------------------------------------------------------------- doc preview */
404
404
  .doc-preview { white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.75; color: var(--text);
405
405
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px 26px; max-height: 60vh; overflow-y: auto; }
406
+
407
+ /* disclosure — native <details> for collapsing long content (예: 공고 원문) */
408
+ .disclosure__summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
409
+ list-style: none; color: var(--text-secondary); font-size: 13px; font-weight: 600; user-select: none; }
410
+ .disclosure__summary::-webkit-details-marker { display: none; }
411
+ .disclosure__summary:hover { color: var(--text); }
412
+ .disclosure__summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
413
+ .disclosure__chevron { width: 16px; height: 16px; flex: none; transition: transform .15s ease; }
414
+ .disclosure[open] .disclosure__chevron { transform: rotate(90deg); }
406
415
  .kv { display: grid; grid-template-columns: 132px 1fr; gap: 8px 16px; }
407
416
  .kv dt { color: var(--text-tertiary); font-size: 13px; }
408
417
  /* min-width:0 overrides a grid item's default min-width:auto so the 1fr column
409
418
  can shrink below its content — otherwise a long path/value overflows the grid. */
410
419
  .kv dd { margin: 0; min-width: 0; }
420
+ .settings-action-row { align-items: flex-start; min-width: 0; }
421
+ .settings-action-row__main { flex: 1 1 auto; min-width: 0; }
422
+ .settings-action-row__main > div:last-child { min-width: 0; }
423
+ .settings-action-row > .btn { flex: 0 0 auto; }
411
424
 
412
425
  /* ----------------------------------------------------------------- modal */
413
426
  .modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
@@ -496,5 +509,9 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
496
509
  .kv{ grid-template-columns: 1fr; gap: 0; }
497
510
  .kv dt{ margin-top: 10px; }
498
511
  .kv dd{ margin: 0 0 2px; }
512
+ .settings-action-row{ align-items: stretch; flex-direction: column; }
513
+ .settings-action-row > .btn{ width: 100%; justify-content: center; }
514
+ .card__head{ align-items: flex-start; flex-wrap: wrap; }
515
+ .card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
499
516
  .page-head{ flex-wrap: wrap; }
500
517
  }