careermate 0.1.0

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 (124) hide show
  1. package/README.md +256 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/apps/mcp/src/index.ts +66 -0
  4. package/apps/web/DESIGN_GUIDE.md +105 -0
  5. package/apps/web/UI_CONTRACT.md +44 -0
  6. package/apps/web/public/app.js +118 -0
  7. package/apps/web/public/fonts/PretendardVariable.woff2 +0 -0
  8. package/apps/web/public/index.html +41 -0
  9. package/apps/web/public/lib.js +282 -0
  10. package/apps/web/public/pages/applications.js +98 -0
  11. package/apps/web/public/pages/documents.js +446 -0
  12. package/apps/web/public/pages/home.js +263 -0
  13. package/apps/web/public/pages/interview.js +230 -0
  14. package/apps/web/public/pages/jobs.js +494 -0
  15. package/apps/web/public/pages/profile.js +576 -0
  16. package/apps/web/public/pages/settings.js +233 -0
  17. package/apps/web/public/styles.css +426 -0
  18. package/apps/web/src/exports.ts +68 -0
  19. package/apps/web/src/http.ts +180 -0
  20. package/apps/web/src/index.ts +49 -0
  21. package/apps/web/src/info.ts +50 -0
  22. package/apps/web/src/routes.ts +350 -0
  23. package/apps/web/src/security.ts +102 -0
  24. package/apps/web/src/server.ts +141 -0
  25. package/apps/web/src/settings.ts +88 -0
  26. package/bin/careermate.mjs +74 -0
  27. package/dist/careermate.mcpb +0 -0
  28. package/dist/install-page/index.html +474 -0
  29. package/dist/install-page/style.css +391 -0
  30. package/dist/install-page/vercel.json +20 -0
  31. package/dist/mcp-smoke.err +3 -0
  32. package/dist/mcp.mjs +23704 -0
  33. package/dist/mcpb-stage/README.md +219 -0
  34. package/dist/mcpb-stage/dist/install-page/index.html +434 -0
  35. package/dist/mcpb-stage/dist/install-page/style.css +407 -0
  36. package/dist/mcpb-stage/dist/install-page/vercel.json +20 -0
  37. package/dist/mcpb-stage/dist/mcp.mjs +23704 -0
  38. package/dist/mcpb-stage/dist/public/app.js +118 -0
  39. package/dist/mcpb-stage/dist/public/fonts/PretendardVariable.woff2 +0 -0
  40. package/dist/mcpb-stage/dist/public/index.html +41 -0
  41. package/dist/mcpb-stage/dist/public/lib.js +282 -0
  42. package/dist/mcpb-stage/dist/public/pages/applications.js +98 -0
  43. package/dist/mcpb-stage/dist/public/pages/documents.js +446 -0
  44. package/dist/mcpb-stage/dist/public/pages/home.js +263 -0
  45. package/dist/mcpb-stage/dist/public/pages/interview.js +230 -0
  46. package/dist/mcpb-stage/dist/public/pages/jobs.js +494 -0
  47. package/dist/mcpb-stage/dist/public/pages/profile.js +576 -0
  48. package/dist/mcpb-stage/dist/public/pages/settings.js +233 -0
  49. package/dist/mcpb-stage/dist/public/styles.css +420 -0
  50. package/dist/mcpb-stage/dist/web.mjs +7240 -0
  51. package/dist/mcpb-stage/manifest.json +40 -0
  52. package/dist/public/app.js +118 -0
  53. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  54. package/dist/public/index.html +41 -0
  55. package/dist/public/lib.js +282 -0
  56. package/dist/public/pages/applications.js +98 -0
  57. package/dist/public/pages/documents.js +446 -0
  58. package/dist/public/pages/home.js +263 -0
  59. package/dist/public/pages/interview.js +230 -0
  60. package/dist/public/pages/jobs.js +494 -0
  61. package/dist/public/pages/profile.js +576 -0
  62. package/dist/public/pages/settings.js +233 -0
  63. package/dist/public/styles.css +426 -0
  64. package/dist/web.mjs +7240 -0
  65. package/docs/ARCHITECTURE.md +208 -0
  66. package/docs/CHANGES_V1.md +103 -0
  67. package/docs/DATA_MODEL.md +460 -0
  68. package/docs/DECISIONS.md +277 -0
  69. package/docs/DEMO.md +242 -0
  70. package/docs/INSTALL.md +148 -0
  71. package/docs/INSTALL_AND_USAGE.md +99 -0
  72. package/docs/MCP_TOOLS.md +233 -0
  73. package/docs/ROADMAP.md +134 -0
  74. package/docs/START_WORKFLOW.md +125 -0
  75. package/docs/SUPPORTED_AI_APPS.md +60 -0
  76. package/docs/TODO.md +57 -0
  77. package/docs/UX_NOTES.md +247 -0
  78. package/docs/WORKFLOWS.md +200 -0
  79. package/install-page/index.html +474 -0
  80. package/install-page/style.css +391 -0
  81. package/install-page/vercel.json +20 -0
  82. package/package.json +68 -0
  83. package/packages/core/src/context.ts +74 -0
  84. package/packages/core/src/index.ts +8 -0
  85. package/packages/core/src/onboarding.ts +81 -0
  86. package/packages/core/src/services.ts +146 -0
  87. package/packages/core/src/summary.ts +104 -0
  88. package/packages/db/src/connection.ts +46 -0
  89. package/packages/db/src/index.ts +22 -0
  90. package/packages/db/src/paths.ts +41 -0
  91. package/packages/db/src/repositories.ts +828 -0
  92. package/packages/db/src/runtime.ts +58 -0
  93. package/packages/db/src/schema.ts +189 -0
  94. package/packages/exporters/src/html.ts +113 -0
  95. package/packages/exporters/src/index.ts +364 -0
  96. package/packages/exporters/src/markdown.ts +178 -0
  97. package/packages/mcp-tools/src/bridge.ts +83 -0
  98. package/packages/mcp-tools/src/index.ts +8 -0
  99. package/packages/mcp-tools/src/result.ts +49 -0
  100. package/packages/mcp-tools/src/tools.ts +455 -0
  101. package/packages/parsers/src/html.ts +86 -0
  102. package/packages/parsers/src/index.ts +228 -0
  103. package/packages/parsers/src/keywords.ts +151 -0
  104. package/packages/prompts/src/humanize.ts +59 -0
  105. package/packages/prompts/src/index.ts +82 -0
  106. package/packages/prompts/src/install.ts +43 -0
  107. package/packages/prompts/src/onboarding.ts +35 -0
  108. package/packages/prompts/src/system.ts +53 -0
  109. package/packages/shared/src/enums.ts +103 -0
  110. package/packages/shared/src/index.ts +18 -0
  111. package/packages/shared/src/schemas.ts +398 -0
  112. package/packages/workflows/src/definitions.ts +107 -0
  113. package/packages/workflows/src/index.ts +39 -0
  114. package/scripts/build-dist.mjs +62 -0
  115. package/scripts/build-mcpb.mjs +70 -0
  116. package/scripts/doctor.ts +81 -0
  117. package/scripts/init.ts +342 -0
  118. package/scripts/mcp-probe.ts +55 -0
  119. package/scripts/migrate.ts +6 -0
  120. package/scripts/run.mjs +33 -0
  121. package/scripts/seed.ts +129 -0
  122. package/scripts/test.ts +117 -0
  123. package/scripts/ui-smoke.ts +73 -0
  124. package/tsconfig.json +29 -0
@@ -0,0 +1,233 @@
1
+ // Settings — data location, connection, theme, backup/export, and danger zone.
2
+ // Everything stays local; this page surfaces where data lives and how to manage it.
3
+ import {
4
+ el, get, post, icon, navigate, Card, Stat, Btn, IconBtn, Input,
5
+ openModal, copyText, downloadUrl, fmtRelative, toastOk, toastError, mount,
6
+ } from '/lib.js';
7
+
8
+ const COUNT_LABELS = {
9
+ profile: '프로필', experiences: '경력', projects: '프로젝트', skills: '스킬',
10
+ documents: '문서', cover_letters: '자기소개서', jobs: '채용공고',
11
+ applications: '지원', interview_preps: '면접 준비',
12
+ };
13
+
14
+ export async function render(ctx) {
15
+ ctx.setActions([]);
16
+ const { info, backups } = await get('/api/settings');
17
+
18
+ const wrap = el('div', { class: 'stack-4' },
19
+ DataLocation(info),
20
+ Connection(info),
21
+ Theme(),
22
+ MyData(info.counts),
23
+ Backup(backups, () => render(ctx)),
24
+ DangerZone(ctx),
25
+ );
26
+
27
+ mount(ctx.view, wrap);
28
+ }
29
+
30
+ /* ----------------------------------------------------- 데이터 저장 위치 */
31
+ function kvRow(label, value, copyable) {
32
+ return [
33
+ el('dt', {}, label),
34
+ el('dd', {},
35
+ el('div', { class: 'flex between gap-3' },
36
+ el('span', { class: 'truncate tnum', style: { fontFamily: 'var(--mono, ui-monospace, monospace)' }, title: value }, value || '—'),
37
+ copyable && value
38
+ ? IconBtn('copy', { title: '복사', onClick: () => copyText(value) })
39
+ : null)),
40
+ ];
41
+ }
42
+
43
+ function DataLocation(info) {
44
+ const kv = el('dl', { class: 'kv' },
45
+ ...kvRow('데이터 폴더', info.data_dir, true),
46
+ ...kvRow('데이터베이스', info.db_path, true),
47
+ ...kvRow('앱 버전', info.version, false),
48
+ ...kvRow('Node 버전', info.node_version, false),
49
+ );
50
+
51
+ const privacy = el('div', { class: 'callout callout--privacy' },
52
+ icon('lock'),
53
+ el('div', {},
54
+ el('div', { class: 'callout__title' }, '내 컴퓨터에만 저장됩니다'),
55
+ el('div', { class: 'callout__body' },
56
+ '모든 데이터는 이 컴퓨터에만 저장됩니다. 외부로 전송되지 않습니다. AI에는 사용자가 직접 입력하거나 AI가 MCP로 조회한 정보만 전달됩니다.')));
57
+
58
+ return Card({
59
+ title: '데이터 저장 위치',
60
+ body: el('div', { class: 'stack-3' }, kv, privacy),
61
+ });
62
+ }
63
+
64
+ /* ---------------------------------------------------------- 연결 상태 */
65
+ function Connection(info) {
66
+ const dashRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0' } },
67
+ el('div', { class: 'flex gap-3', style: { alignItems: 'center' } },
68
+ el('span', { class: 'badge badge--final_passed' }, el('span', { class: 'dot' }), '실행 중'),
69
+ el('div', {},
70
+ el('div', { class: 'strong' }, '대시보드'),
71
+ el('div', { class: 'muted text-sm tnum' }, info.url || '—'))),
72
+ info.url
73
+ ? Btn('새 탭에서 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open(info.url, '_blank') })
74
+ : null);
75
+
76
+ const mcpRow = el('div', { class: 'flex between gap-3', style: { padding: '4px 0', alignItems: 'flex-start' } },
77
+ el('div', { class: 'flex gap-3', style: { alignItems: 'flex-start' } },
78
+ el('div', { class: 'empty__icon', style: { width: '30px', height: '30px', margin: 0, borderRadius: '8px' } }, icon('link')),
79
+ el('div', {},
80
+ el('div', { class: 'strong' }, 'MCP 서버'),
81
+ el('div', { class: 'muted text-sm', style: { maxWidth: '440px', lineHeight: '1.6' } },
82
+ 'MCP 서버는 사용하시는 AI 클라이언트(Claude·ChatGPT·Cursor)가 직접 실행하며, 이 대시보드와 같은 데이터베이스를 공유합니다.'))),
83
+ Btn('설치·연결 안내 열기', { icon: 'external', sm: true, variant: 'ghost', onClick: () => window.open('/install', '_blank') }));
84
+
85
+ return Card({
86
+ title: '연결 상태',
87
+ body: el('div', { class: 'stack-2' }, dashRow, el('div', { class: 'divider' }), mcpRow),
88
+ });
89
+ }
90
+
91
+ /* ------------------------------------------------------------- 테마 */
92
+ function currentTheme() {
93
+ const saved = localStorage.getItem('cf-theme');
94
+ return saved === 'light' || saved === 'dark' ? saved : 'system';
95
+ }
96
+
97
+ function Theme() {
98
+ const options = [
99
+ { value: 'light', label: '라이트' },
100
+ { value: 'dark', label: '다크' },
101
+ { value: 'system', label: '시스템' },
102
+ ];
103
+
104
+ const row = el('div', { class: 'flex gap-2 wrap' });
105
+
106
+ function applyTheme(value) {
107
+ if (value === 'system') {
108
+ delete document.documentElement.dataset.theme;
109
+ localStorage.removeItem('cf-theme');
110
+ } else {
111
+ document.documentElement.dataset.theme = value;
112
+ localStorage.setItem('cf-theme', value);
113
+ }
114
+ paint();
115
+ }
116
+
117
+ function paint() {
118
+ const active = currentTheme();
119
+ mount(row, ...options.map((o) => {
120
+ const b = Btn(o.label, { variant: 'ghost', onClick: () => applyTheme(o.value) });
121
+ b.setAttribute('aria-pressed', String(o.value === active));
122
+ if (o.value === active) b.classList.add('is-selected');
123
+ return b;
124
+ }));
125
+ }
126
+ paint();
127
+
128
+ return Card({
129
+ title: '테마',
130
+ sub: '시스템 설정을 따르거나 직접 고를 수 있어요',
131
+ body: row,
132
+ });
133
+ }
134
+
135
+ /* ---------------------------------------------------------- 내 데이터 */
136
+ function MyData(counts = {}) {
137
+ const entries = Object.entries(COUNT_LABELS)
138
+ .map(([key, label]) => Stat({ label, value: counts[key] ?? 0 }));
139
+ return Card({
140
+ title: '내 데이터',
141
+ sub: '이 컴퓨터에 저장된 항목 수',
142
+ body: el('div', { class: 'grid grid--3' }, ...entries),
143
+ });
144
+ }
145
+
146
+ /* ------------------------------------------------- 백업 / 내보내기 */
147
+ function Backup(backups = [], rerender) {
148
+ async function createBackup() {
149
+ try {
150
+ const res = await post('/api/settings/backup');
151
+ toastOk('백업을 생성했습니다.');
152
+ await rerender();
153
+ } catch (e) {
154
+ toastError(e);
155
+ }
156
+ }
157
+
158
+ const list = backups.length
159
+ ? el('table', { class: 'table' },
160
+ el('thead', {}, el('tr', {},
161
+ el('th', {}, '파일'), el('th', {}, '생성'), el('th', { style: { textAlign: 'right' } }, '크기'))),
162
+ el('tbody', {}, ...backups.map((b) => el('tr', {},
163
+ el('td', {}, el('span', { class: 'truncate', title: b.path || b.filename }, b.filename)),
164
+ el('td', { class: 'muted text-sm' }, fmtRelative(b.created_at)),
165
+ el('td', { class: 'muted text-sm tnum', style: { textAlign: 'right' } }, `${Math.round((b.size || 0) / 1024)} KB`)))))
166
+ : el('p', { class: 'muted', style: { margin: 0 } }, '아직 백업이 없습니다. "백업 생성"으로 현재 데이터를 안전하게 보관하세요.');
167
+
168
+ return Card({
169
+ title: '백업 / 내보내기',
170
+ actions: [
171
+ Btn('백업 생성', { icon: 'copy', sm: true, variant: 'primary', onClick: createBackup }),
172
+ Btn('전체 데이터 내보내기(JSON)', { icon: 'download', sm: true, variant: 'ghost', onClick: () => downloadUrl('/api/settings/export-all') }),
173
+ ],
174
+ body: list,
175
+ });
176
+ }
177
+
178
+ /* -------------------------------------------------- 위험 구역 (Danger zone) */
179
+ function DangerZone(ctx) {
180
+ function openResetModal() {
181
+ let confirmBtn;
182
+ const input = Input({
183
+ placeholder: 'DELETE',
184
+ onInput: (e) => { if (confirmBtn) confirmBtn.disabled = e.target.value.trim() !== 'DELETE'; },
185
+ onKeydown: (e) => { if (e.key === 'Enter' && e.target.value.trim() === 'DELETE' && confirmBtn && !confirmBtn.disabled) confirmBtn.click(); },
186
+ });
187
+
188
+ openModal({
189
+ title: '모든 데이터 초기화',
190
+ body: el('div', { class: 'stack-3' },
191
+ el('div', { class: 'callout' },
192
+ icon('info'),
193
+ el('div', {},
194
+ el('div', { class: 'callout__title' }, '되돌릴 수 없습니다'),
195
+ el('div', { class: 'callout__body' },
196
+ '프로필·공고·지원·문서·면접 준비를 포함한 모든 데이터가 영구적으로 삭제됩니다. 안전을 위해 초기화 직전에 자동으로 백업이 한 번 생성됩니다.'))),
197
+ el('p', { class: 'text-secondary', style: { margin: 0, lineHeight: '1.6' } },
198
+ '계속하려면 아래 입력란에 ', el('span', { class: 'strong' }, 'DELETE'), ' 를 입력하세요.'),
199
+ input),
200
+ footer: (close) => {
201
+ confirmBtn = Btn('모든 데이터 초기화', {
202
+ variant: 'danger',
203
+ disabled: true,
204
+ onClick: async () => {
205
+ confirmBtn.disabled = true;
206
+ try {
207
+ await post('/api/settings/reset', { confirm: 'DELETE' });
208
+ close();
209
+ toastOk('모든 데이터가 초기화되었습니다.');
210
+ navigate('/');
211
+ await ctx.refreshNav();
212
+ } catch (e) {
213
+ toastError(e);
214
+ confirmBtn.disabled = input.value.trim() !== 'DELETE';
215
+ }
216
+ },
217
+ });
218
+ return [Btn('취소', { onClick: close }), confirmBtn];
219
+ },
220
+ });
221
+ }
222
+
223
+ return Card({
224
+ title: '위험 구역',
225
+ sub: '이 작업은 되돌릴 수 없습니다',
226
+ body: el('div', { class: 'flex between gap-3 wrap' },
227
+ el('div', { style: { maxWidth: '440px' } },
228
+ el('div', { class: 'strong' }, '모든 데이터 초기화'),
229
+ el('div', { class: 'muted text-sm', style: { lineHeight: '1.6' } },
230
+ '이 컴퓨터에 저장된 모든 CareerMate 데이터를 삭제합니다. 초기화 전에 자동으로 백업이 생성됩니다.')),
231
+ Btn('모든 데이터 초기화', { icon: 'trash', variant: 'danger', onClick: openResetModal })),
232
+ });
233
+ }
@@ -0,0 +1,426 @@
1
+ /* =============================================================================
2
+ CareerMate design system — a calm, dense, real-productivity-tool aesthetic.
3
+ No framework, no CDN (works fully offline; nothing leaves the machine).
4
+ Inspired by the restraint of Linear / Notion / Raycast — not AI-flashy.
5
+ ========================================================================== */
6
+
7
+ /* Self-hosted brand font (offline; served from /fonts by the local server).
8
+ A real loaded face — not a system fallback — so the type is intentional and
9
+ identical on every machine. One variable file covers all weights. */
10
+ @font-face {
11
+ font-family: 'Pretendard Variable';
12
+ font-weight: 45 920;
13
+ font-style: normal;
14
+ /* block (not swap): show no fallback-then-swap flash; the font is local so the
15
+ brief invisibility is imperceptible, and it's preloaded in index.html. */
16
+ font-display: block;
17
+ src: local('Pretendard Variable'),
18
+ url('/fonts/PretendardVariable.woff2') format('woff2');
19
+ }
20
+
21
+ :root {
22
+ /* Neutral palette (light) */
23
+ --bg: #ffffff;
24
+ --bg-subtle: #f7f7f8;
25
+ --bg-sunken: #f1f1f3;
26
+ --surface: #ffffff;
27
+ --surface-hover: #f7f7f8;
28
+ --border: #e6e6e9;
29
+ --border-strong: #d4d4d8;
30
+ --text: #1c1c1f;
31
+ --text-secondary: #5c5c66;
32
+ --text-tertiary: #8a8a94;
33
+ --text-on-accent: #ffffff;
34
+
35
+ /* Accent — calm indigo */
36
+ --accent: #4f46e5;
37
+ --accent-hover: #4338ca;
38
+ --accent-soft: #eef2ff;
39
+ --accent-border: #c7d2fe;
40
+
41
+ /* Semantic */
42
+ --green: #15803d; --green-soft: #ecfdf3; --green-border: #bbf7d0;
43
+ --teal: #0f766e; --teal-soft: #effcf9; --teal-border: #99f6e4;
44
+ --blue: #1d4ed8; --blue-soft: #eff4ff; --blue-border: #bfdbfe;
45
+ --violet: #6d28d9; --violet-soft: #f5f0ff; --violet-border: #ddd6fe;
46
+ --amber: #b45309; --amber-soft: #fffbeb; --amber-border: #fde68a;
47
+ --red: #b91c1c; --red-soft: #fef2f2; --red-border: #fecaca;
48
+ --slate: #475569; --slate-soft: #f5f6f8; --slate-border: #dbe0e6;
49
+
50
+ /* Elevation */
51
+ --shadow-sm: 0 1px 2px rgba(16,16,20,.05);
52
+ --shadow-md: 0 4px 16px rgba(16,16,20,.08);
53
+ --shadow-lg: 0 12px 40px rgba(16,16,20,.16);
54
+
55
+ /* Geometry */
56
+ --radius-sm: 6px;
57
+ --radius: 9px;
58
+ --radius-lg: 14px;
59
+ --sidebar-w: 248px;
60
+ --topbar-h: 56px;
61
+ --maxw: 1080px;
62
+
63
+ --font: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont,
64
+ 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
65
+ --mono: 'SFMono-Regular', 'JetBrains Mono', Consolas, 'D2Coding', monospace;
66
+ }
67
+
68
+ @media (prefers-color-scheme: dark) {
69
+ :root:not([data-theme='light']) {
70
+ --bg: #161618;
71
+ --bg-subtle: #1d1d20;
72
+ --bg-sunken: #0f0f11;
73
+ --surface: #1d1d20;
74
+ --surface-hover: #26262a;
75
+ --border: #2c2c31;
76
+ --border-strong: #3a3a41;
77
+ --text: #ededf0;
78
+ --text-secondary: #a5a5b0;
79
+ --text-tertiary: #74747e;
80
+ --accent: #818cf8;
81
+ --accent-hover: #a5b4fc;
82
+ --accent-soft: #1e1b3a;
83
+ --accent-border: #3730a3;
84
+ --green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
85
+ --amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b;
86
+ --shadow-lg: 0 12px 40px rgba(0,0,0,.5);
87
+ }
88
+ }
89
+ :root[data-theme='dark'] {
90
+ --bg:#161618; --bg-subtle:#1d1d20; --bg-sunken:#0f0f11; --surface:#1d1d20; --surface-hover:#26262a;
91
+ --border:#2c2c31; --border-strong:#3a3a41; --text:#ededf0; --text-secondary:#a5a5b0; --text-tertiary:#74747e;
92
+ --accent:#818cf8; --accent-hover:#a5b4fc; --accent-soft:#1e1b3a; --accent-border:#3730a3;
93
+ --green-soft:#0e2a1a; --teal-soft:#0c2826; --blue-soft:#11203f; --violet-soft:#1e1638;
94
+ --amber-soft:#2a2008; --red-soft:#2c1414; --slate-soft:#1f242b; --shadow-lg:0 12px 40px rgba(0,0,0,.5);
95
+ }
96
+
97
+ * { box-sizing: border-box; }
98
+ /* Icons must never stretch inside flex containers. */
99
+ svg { flex: none; }
100
+ html, body { height: 100%; }
101
+ body {
102
+ margin: 0; font-family: var(--font); color: var(--text); background: var(--bg-subtle);
103
+ font-size: 15px; line-height: 1.55; -webkit-font-smoothing: antialiased;
104
+ text-rendering: optimizeLegibility;
105
+ }
106
+ a { color: var(--accent); text-decoration: none; }
107
+ a:hover { text-decoration: underline; }
108
+ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
109
+ ::selection { background: var(--accent-soft); }
110
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
111
+
112
+ /* ----------------------------------------------------------------- layout */
113
+ .app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
114
+
115
+ .sidebar {
116
+ background: var(--bg); border-right: 1px solid var(--border);
117
+ display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh;
118
+ }
119
+ .sidebar__brand { display: flex; align-items: center; gap: 10px; padding: 18px 18px 14px; }
120
+ .sidebar__logo {
121
+ width: 30px; height: 30px; border-radius: 8px; flex: none;
122
+ background: var(--accent);
123
+ display: grid; place-items: center; color: #fff; font-weight: 800; font-size: 15px;
124
+ }
125
+ .sidebar__title { font-weight: 700; font-size: 15px; letter-spacing: -.01em; }
126
+ .sidebar__subtitle { font-size: 12px; color: var(--text-tertiary); margin-top: 1px; }
127
+ .nav { padding: 6px 10px; display: flex; flex-direction: column; gap: 1px; flex: 1; overflow-y: auto; }
128
+ .nav__label { font-size: 11.5px; font-weight: 600; color: var(--text-tertiary); padding: 12px 10px 4px; text-transform: uppercase; letter-spacing: .04em; }
129
+ .nav__item {
130
+ display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: var(--radius-sm);
131
+ color: var(--text-secondary); font-weight: 500; cursor: pointer; user-select: none; position: relative;
132
+ }
133
+ .nav__item:hover { background: var(--surface-hover); color: var(--text); text-decoration: none; }
134
+ .nav__item.is-active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
135
+ .nav__item svg { width: 17px; height: 17px; flex: none; }
136
+ .nav__badge { margin-left: auto; font-size: 11.5px; background: var(--bg-sunken); color: var(--text-secondary);
137
+ padding: 1px 7px; border-radius: 999px; font-weight: 600; }
138
+ .nav__item.is-active .nav__badge { background: var(--accent); color: #fff; }
139
+ .sidebar__foot { padding: 12px 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-tertiary); }
140
+ .sidebar__foot .dot { display:inline-block; width:7px; height:7px; border-radius:999px; background: var(--green); margin-right:6px; }
141
+
142
+ .main { display: flex; flex-direction: column; min-width: 0; }
143
+ .topbar {
144
+ height: var(--topbar-h); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 88%, transparent);
145
+ backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 20;
146
+ display: flex; align-items: center; gap: 12px; padding: 0 24px;
147
+ }
148
+ .topbar__title { font-size: 15px; font-weight: 650; }
149
+ .topbar__crumb { color: var(--text-tertiary); }
150
+ .topbar__spacer { flex: 1; }
151
+ .view { padding: 24px; max-width: var(--maxw); width: 100%; margin: 0 auto; }
152
+ .view--wide { max-width: 1280px; }
153
+
154
+ .page-head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
155
+ .page-head__text h1 { font-size: 22px; }
156
+ .page-head__text p { margin: 4px 0 0; color: var(--text-secondary); }
157
+ .page-head__actions { margin-left: auto; display: flex; gap: 8px; flex: none; }
158
+
159
+ /* ----------------------------------------------------------------- grid */
160
+ .grid { display: grid; gap: 16px; }
161
+ .grid--2 { grid-template-columns: repeat(2, 1fr); }
162
+ .grid--3 { grid-template-columns: repeat(3, 1fr); }
163
+ .grid--4 { grid-template-columns: repeat(4, 1fr); }
164
+ @media (max-width: 900px){ .grid--2,.grid--3,.grid--4{ grid-template-columns: 1fr; } .app{ grid-template-columns: 1fr; } .sidebar{ display:none; } }
165
+
166
+ /* ----------------------------------------------------------------- card */
167
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
168
+ .card__head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
169
+ .card__head h3 { font-size: 15px; }
170
+ .card__head .sub { color: var(--text-tertiary); font-size: 13px; }
171
+ .card__head .right { margin-left: auto; display: flex; gap: 6px; }
172
+ .card__body { padding: 18px; }
173
+ .card__body--tight { padding: 0; }
174
+ /* a light bordered block for grouping inside a card/modal — never nest a full .card */
175
+ .subcard { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; }
176
+ .card.is-clickable { cursor: pointer; transition: border-color .12s, box-shadow .12s, transform .12s; }
177
+ .card.is-clickable:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
178
+
179
+ /* stat tiles */
180
+ .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px 18px; }
181
+ .stat.is-clickable { cursor: pointer; transition: border-color .12s, box-shadow .12s; }
182
+ .stat.is-clickable:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
183
+ .stat__label { font-size: 13px; color: var(--text-secondary); display:flex; align-items:center; gap:7px; }
184
+ .stat__label svg { width: 15px; height: 15px; color: var(--text-tertiary); }
185
+ .stat__value { font-size: 26px; font-weight: 700; letter-spacing: -.02em; margin-top: 6px; }
186
+ .stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top: 2px; }
187
+
188
+ /* ----------------------------------------------------------------- buttons */
189
+ .btn {
190
+ display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 13px;
191
+ border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: var(--surface);
192
+ color: var(--text); font-weight: 550; font-size: 14px; cursor: pointer; font-family: inherit;
193
+ transition: background .12s, border-color .12s, opacity .12s; white-space: nowrap; user-select: none;
194
+ }
195
+ .btn:hover { background: var(--surface-hover); }
196
+ .btn:disabled { opacity: .5; cursor: not-allowed; }
197
+ .btn svg { width: 15px; height: 15px; }
198
+ .btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
199
+ .btn--primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
200
+ .btn--ghost { border-color: transparent; background: transparent; }
201
+ .btn--ghost:hover { background: var(--surface-hover); }
202
+ .btn--danger { color: var(--red); border-color: var(--red-border); background: var(--red-soft); }
203
+ .btn--danger:hover { background: var(--red); color:#fff; border-color: var(--red); }
204
+ .btn--sm { height: 28px; padding: 0 10px; font-size: 13px; }
205
+ .btn--block { width: 100%; justify-content: center; }
206
+ .icon-btn { width: 30px; height: 30px; padding: 0; justify-content: center; }
207
+ /* a ghost button marked as the selected option (segmented toggle), e.g. theme picker */
208
+ .btn.is-selected { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-border); }
209
+
210
+ /* ----------------------------------------------------------------- forms */
211
+ .field { margin-bottom: 14px; }
212
+ .field > label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
213
+ .field .hint { font-size: 12px; color: var(--text-tertiary); margin-top: 5px; }
214
+ .input, .textarea, .select {
215
+ width: 100%; font-family: inherit; font-size: 14px; color: var(--text);
216
+ background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
217
+ padding: 8px 11px; transition: border-color .12s, box-shadow .12s;
218
+ }
219
+ .input:focus, .textarea:focus, .select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
220
+ .textarea { resize: vertical; min-height: 90px; line-height: 1.6; }
221
+ .textarea--lg { min-height: 280px; font-size: 15px; }
222
+ .input::placeholder, .textarea::placeholder { color: var(--text-tertiary); }
223
+ .row { display: flex; gap: 10px; }
224
+ .row > * { flex: 1; }
225
+ /* compact inline input for the topbar/toolbar (search boxes) */
226
+ .input--inline { width: 220px; max-width: 46vw; }
227
+
228
+ /* ----------------------------------------------------------------- badges */
229
+ .badge { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 600;
230
+ padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-sunken); color: var(--text-secondary); white-space: nowrap; }
231
+ .badge .dot { width: 6px; height: 6px; border-radius: 999px; background: currentColor; }
232
+ .badge--draft { color: var(--slate); background: var(--slate-soft); border-color: var(--slate-border); }
233
+ .badge--planned { color: var(--blue); background: var(--blue-soft); border-color: var(--blue-border); }
234
+ .badge--applied { color: var(--violet); background: var(--violet-soft); border-color: var(--violet-border); }
235
+ .badge--document_passed { color: var(--teal); background: var(--teal-soft); border-color: var(--teal-border); }
236
+ .badge--interview { color: var(--amber); background: var(--amber-soft); border-color: var(--amber-border); }
237
+ .badge--final_passed { color: var(--green); background: var(--green-soft); border-color: var(--green-border); }
238
+ .badge--rejected { color: var(--red); background: var(--red-soft); border-color: var(--red-border); }
239
+ .badge--on_hold { color: var(--slate); background: var(--slate-soft); border-color: var(--slate-border); }
240
+ .badge--accent { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-border); }
241
+ .badge--score { font-variant-numeric: tabular-nums; }
242
+
243
+ .chip { display:inline-flex; align-items:center; gap:6px; font-size:12px; padding:3px 9px; border-radius:7px;
244
+ background: var(--bg-sunken); color: var(--text-secondary); border:1px solid var(--border); }
245
+ .chip--accent { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-border); }
246
+ .chips { display:flex; flex-wrap:wrap; gap:6px; }
247
+
248
+ /* ----------------------------------------------------------------- table */
249
+ .table { width: 100%; border-collapse: collapse; }
250
+ .table th { text-align: left; font-size: 12px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase;
251
+ letter-spacing: .03em; padding: 10px 16px; border-bottom: 1px solid var(--border); }
252
+ .table td { padding: 12px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }
253
+ .table tr:last-child td { border-bottom: none; }
254
+ .table tbody tr.is-clickable { cursor: pointer; }
255
+ .table tbody tr.is-clickable:hover { background: var(--surface-hover); }
256
+ .table .muted { color: var(--text-tertiary); }
257
+ .table .strong { font-weight: 600; }
258
+
259
+ /* ----------------------------------------------------------------- progress */
260
+ .progress { height: 7px; background: var(--bg-sunken); border-radius: 999px; overflow: hidden; }
261
+ .progress__bar { height: 100%; background: var(--accent); border-radius: 999px; transition: width .4s ease; }
262
+
263
+ /* ----------------------------------------------------------------- empty state */
264
+ .empty { text-align: center; padding: 44px 24px; color: var(--text-secondary); }
265
+ .empty__icon { width: 46px; height: 46px; border-radius: 12px; background: var(--bg-sunken); display: grid; place-items: center; margin: 0 auto 14px; color: var(--text-tertiary); }
266
+ .empty__icon svg { width: 22px; height: 22px; }
267
+ .empty h3 { font-size: 15px; color: var(--text); margin-bottom: 6px; }
268
+ .empty p { max-width: 420px; margin: 0 auto 16px; font-size: 13.5px; }
269
+
270
+ /* ----------------------------------------------------------------- timeline / versions */
271
+ .timeline { display: flex; flex-direction: column; }
272
+ .tl-item { display: grid; grid-template-columns: 22px 1fr; gap: 12px; padding-bottom: 4px; }
273
+ .tl-item__rail { display: flex; flex-direction: column; align-items: center; }
274
+ .tl-item__dot { width: 11px; height: 11px; border-radius: 999px; background: var(--surface); border: 2px solid var(--border-strong); margin-top: 4px; flex: none; }
275
+ .tl-item.is-current .tl-item__dot { background: var(--accent); border-color: var(--accent); }
276
+ .tl-item__line { width: 2px; flex: 1; background: var(--border); margin: 4px 0; }
277
+ .tl-item:last-child .tl-item__line { display: none; }
278
+ .tl-item__body { padding-bottom: 16px; }
279
+
280
+ /* ----------------------------------------------------------------- kanban */
281
+ .board { display: grid; grid-auto-flow: column; grid-auto-columns: 272px; gap: 14px; overflow-x: auto; padding-bottom: 8px; }
282
+ .board__col { background: var(--bg-subtle); border: 1px solid var(--border); border-radius: var(--radius-lg); display: flex; flex-direction: column; max-height: calc(100vh - 200px); }
283
+ .board__col-head { padding: 11px 14px; display: flex; align-items: center; gap: 8px; position: sticky; top: 0; }
284
+ .board__col-head .count { margin-left: auto; font-size: 12.5px; color: var(--text-tertiary); font-weight: 600; }
285
+ .board__cards { padding: 0 10px 12px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }
286
+ .board-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 11px 12px; cursor: pointer; box-shadow: var(--shadow-sm); }
287
+ .board-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
288
+ .board-card__company { font-weight: 600; font-size: 14px; }
289
+ .board-card__role { font-size: 13px; color: var(--text-secondary); margin-top: 1px; }
290
+ .board-card__meta { display: flex; align-items: center; gap: 8px; margin-top: 9px; }
291
+
292
+ /* ----------------------------------------------------------------- list row (shared)
293
+ One primitive behind the home action lane, recent-job list, and any dense list.
294
+ Leading slot → title + muted sub → right-aligned trailing (badges/score). */
295
+ .list-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
296
+ .list-row:last-child { border-bottom: none; }
297
+ .list-row.is-clickable { cursor: pointer; transition: background .12s; }
298
+ .list-row.is-clickable:hover { background: var(--surface-hover); }
299
+ .list-row__lead { flex: none; display: grid; place-items: center; }
300
+ .list-row__lead--chip { width: 30px; height: 30px; border-radius: 8px; background: var(--bg-sunken); color: var(--text-tertiary); }
301
+ .list-row__lead--chip svg { width: 16px; height: 16px; }
302
+ .list-row__dot { width: 9px; height: 9px; border-radius: 999px; flex: none; }
303
+ .list-row__main { flex: 1; min-width: 0; }
304
+ .list-row__title { font-weight: 600; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
305
+ .list-row__sub { font-size: 13px; color: var(--text-tertiary); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
306
+ .list-row__trail { flex: none; display: flex; align-items: center; gap: 8px; margin-left: auto; white-space: nowrap; }
307
+
308
+ /* applications: status-group section header (reads clearly as a section title) */
309
+ .app-group__head { display: flex; align-items: center; gap: 9px; padding-bottom: 8px; margin-bottom: 2px; border-bottom: 1px solid var(--border); }
310
+ .app-group__dot { width: 9px; height: 9px; border-radius: 999px; flex: none; }
311
+ .app-group__title { font-weight: 650; font-size: 15px; letter-spacing: -.01em; }
312
+ .app-group__count { margin-left: auto; color: var(--text-tertiary); font-size: 13px; }
313
+
314
+ /* row-level action (e.g. status select) — hidden until the row is hovered or
315
+ focused, so grouped lists stay calm. Always shown where hover isn't available. */
316
+ .list-row .row-action { opacity: 0; pointer-events: none; transition: opacity .12s; }
317
+ .list-row:hover .row-action,
318
+ .list-row:focus-within .row-action { opacity: 1; pointer-events: auto; }
319
+ @media (hover: none) { .list-row .row-action { opacity: 1; pointer-events: auto; } }
320
+
321
+ /* ----------------------------------------------------------------- pipeline bar + funnel
322
+ Visualizes the application status_breakdown. Segment color == badge--{status} hue,
323
+ so a colour means the same stage everywhere (board, badges, here). */
324
+ .pipebar { display: flex; height: 14px; border-radius: 999px; overflow: hidden; background: var(--bg-sunken); gap: 2px; }
325
+ .pipebar__seg { min-width: 8px; cursor: pointer; transition: flex-grow .4s ease, opacity .12s; }
326
+ .pipebar__seg:hover { opacity: .8; }
327
+ .pipefunnel { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-top: 18px; }
328
+ .pipefunnel__tile { cursor: pointer; }
329
+ .pipefunnel__label { font-size: 13px; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
330
+ .pipefunnel__dot { width: 8px; height: 8px; border-radius: 999px; flex: none; }
331
+ .pipefunnel__value { font-size: 24px; font-weight: 700; letter-spacing: -.02em; margin-top: 5px; line-height: 1.1; }
332
+ .pipefunnel__conv { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; }
333
+ @media (max-width: 560px){ .pipefunnel { grid-template-columns: repeat(2, 1fr); } }
334
+
335
+ /* ----------------------------------------------------------------- check row (onboarding)
336
+ The ONE place setup guidance lives. Outcome rows auto-detect completion. */
337
+ .check-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; }
338
+ .check-row__dot { width: 16px; height: 16px; border: 2px solid var(--border-strong); border-radius: 999px; flex: none; box-sizing: border-box; }
339
+ .check-row__icon { width: 16px; height: 16px; flex: none; color: var(--green); }
340
+ .check-row__label { font-size: 14px; }
341
+ .check-row.is-done .check-row__label { color: var(--text-tertiary); text-decoration: line-through; }
342
+ .check-row__label.is-link { color: var(--accent); cursor: pointer; }
343
+ .check-row__label.is-link:hover { text-decoration: underline; }
344
+
345
+ /* ----------------------------------------------------------------- activity feed (day-bucketed) */
346
+ .feed-group { font-size: 11.5px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .04em; padding: 14px 0 6px; }
347
+ .feed-group:first-child { padding-top: 0; }
348
+ .feed-item { display: flex; gap: 12px; align-items: flex-start; padding: 6px 0; }
349
+ .feed-item__icon { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; flex: none; background: var(--bg-sunken); color: var(--text-tertiary); }
350
+ .feed-item__icon svg { width: 16px; height: 16px; }
351
+ .feed-item__body { flex: 1; min-width: 0; }
352
+ .feed-item__text { font-size: 13.5px; line-height: 1.5; }
353
+ .feed-item__time { display: block; font-size: 12.5px; color: var(--text-tertiary); margin-top: 1px; }
354
+
355
+ /* ----------------------------------------------------------------- first-run hero
356
+ Day one: a single getting-started card centered on a quiet dot canvas, instead
357
+ of a wall of empty cards. */
358
+ .firstrun-hero { display: grid; place-items: start center; padding: 28px 0;
359
+ background-image: radial-gradient(var(--border) 1px, transparent 1px); background-size: 24px 24px; border-radius: var(--radius-lg); }
360
+ .firstrun-hero > .card { width: 100%; max-width: 540px; }
361
+
362
+ /* ----------------------------------------------------------------- doc preview */
363
+ .doc-preview { white-space: pre-wrap; word-break: break-word; font-size: 15px; line-height: 1.75; color: var(--text);
364
+ background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px 26px; max-height: 60vh; overflow-y: auto; }
365
+ .kv { display: grid; grid-template-columns: 132px 1fr; gap: 8px 16px; }
366
+ .kv dt { color: var(--text-tertiary); font-size: 13px; }
367
+ .kv dd { margin: 0; }
368
+
369
+ /* ----------------------------------------------------------------- modal */
370
+ .modal-root { position: fixed; inset: 0; z-index: 60; display: none; }
371
+ .modal-root.is-open { display: block; }
372
+ .modal__scrim { position: absolute; inset: 0; background: rgba(16,16,20,.42); backdrop-filter: blur(2px); animation: fade .15s; }
373
+ .modal { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: min(640px, calc(100vw - 32px));
374
+ max-height: calc(100vh - 48px); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
375
+ box-shadow: var(--shadow-lg); display: flex; flex-direction: column; animation: pop .16s ease; }
376
+ .modal--lg { width: min(880px, calc(100vw - 32px)); }
377
+ .modal__head { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
378
+ .modal__head h3 { font-size: 15px; }
379
+ .modal__body { padding: 20px; overflow-y: auto; }
380
+ .modal__foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
381
+ @keyframes pop { from { transform: translate(-50%,-46%); opacity: 0; } to { transform: translate(-50%,-50%); opacity: 1; } }
382
+ @keyframes fade { from { opacity: 0; } to { opacity: 1; } }
383
+
384
+ /* ----------------------------------------------------------------- toast */
385
+ .toasts { position: fixed; bottom: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
386
+ .toast { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius);
387
+ box-shadow: var(--shadow-md); padding: 12px 16px; min-width: 260px; max-width: 380px; animation: slidein .2s ease; font-size: 14px; }
388
+ .toast--success { border-left-color: var(--green); }
389
+ .toast--error { border-left-color: var(--red); }
390
+ .toast__title { font-weight: 600; margin-bottom: 2px; }
391
+ .toast__body { color: var(--text-secondary); font-size: 13px; }
392
+ @keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
393
+
394
+ /* ----------------------------------------------------------------- tabs */
395
+ .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
396
+ .tab { padding: 9px 14px; font-size: 14px; font-weight: 550; color: var(--text-secondary); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
397
+ .tab:hover { color: var(--text); }
398
+ .tab.is-active { color: var(--accent); border-bottom-color: var(--accent); }
399
+
400
+ /* ----------------------------------------------------------------- utils */
401
+ .muted { color: var(--text-tertiary); }
402
+ .text-sm { font-size: 13px; }
403
+ .text-secondary { color: var(--text-secondary); }
404
+ .stack { display: flex; flex-direction: column; }
405
+ .stack-2 > * + * { margin-top: 8px; } .stack-3 > * + * { margin-top: 12px; } .stack-4 > * + * { margin-top: 16px; }
406
+ .flex { display: flex; align-items: center; } .flex-col{ display:flex; flex-direction:column; }
407
+ .gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
408
+ .between { justify-content: space-between; } .center { align-items: center; } .wrap { flex-wrap: wrap; }
409
+ .ml-auto { margin-left: auto; } .mt-2{margin-top:8px;} .mt-3{margin-top:12px;} .mt-4{margin-top:16px;} .mb-2{margin-bottom:8px;} .mb-3{margin-bottom:12px;} .mb-4{margin-bottom:16px;}
410
+ .tnum { font-variant-numeric: tabular-nums; }
411
+ .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
412
+ .divider { height: 1px; background: var(--border); margin: 16px 0; }
413
+ .skeleton { background: linear-gradient(90deg, var(--bg-sunken) 25%, var(--surface-hover) 37%, var(--bg-sunken) 63%); background-size: 400% 100%; animation: shimmer 1.4s infinite; border-radius: 6px; }
414
+ @keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }
415
+ .hide { display: none !important; }
416
+
417
+ /* score color helper */
418
+ .score-strong { color: var(--green); } .score-mid { color: var(--amber); } .score-weak { color: var(--red); }
419
+
420
+ /* banner / callout */
421
+ .callout { display: flex; gap: 12px; padding: 14px 16px; border-radius: var(--radius); border: 1px solid var(--accent-border); background: var(--accent-soft); }
422
+ .callout svg { flex: none; width: 18px; height: 18px; color: var(--accent); margin-top: 1px; }
423
+ .callout--privacy { border-color: var(--border); background: var(--bg-subtle); }
424
+ .callout--privacy svg { color: var(--text-tertiary); }
425
+ .callout__title { font-weight: 600; margin-bottom: 2px; }
426
+ .callout__body { color: var(--text-secondary); font-size: 13px; }