careermate 0.0.8 → 0.0.9
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/AGENTS.md +2 -2
- package/LICENSE +1 -1
- package/README.md +78 -81
- package/bin/careermate.mjs +11 -2
- package/dist/career-os/eop/cover-letter.md +10 -4
- package/dist/career-os/eop/interview-prep.md +1 -0
- package/dist/career-os/knowledge/cover-letter.md +4 -0
- package/dist/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/dist/doctor.mjs +888 -340
- package/dist/mcp.mjs +904 -350
- package/dist/migrate.mjs +208 -16
- package/dist/public/i18n/en/applications.js +19 -0
- package/dist/public/i18n/en/common.js +6 -0
- package/dist/public/i18n/en/jobs.js +17 -0
- package/dist/public/i18n/en/settings.js +5 -2
- package/dist/public/i18n/ko/applications.js +19 -0
- package/dist/public/i18n/ko/common.js +5 -0
- package/dist/public/i18n/ko/jobs.js +17 -0
- package/dist/public/i18n/ko/settings.js +5 -2
- package/dist/public/lib.js +29 -0
- package/dist/public/pages/applications.js +86 -10
- package/dist/public/pages/documents.js +16 -7
- package/dist/public/pages/home.js +12 -0
- package/dist/public/pages/interview.js +11 -4
- package/dist/public/pages/jobs.js +71 -1
- package/dist/public/pages/settings.js +2 -2
- package/dist/public/styles.css +13 -0
- package/dist/web.mjs +544 -199
- package/docs/FAQ.md +13 -3
- package/docs/MCP_TOOLS.md +22 -9
- package/docs/career-os/eop/cover-letter.md +10 -4
- package/docs/career-os/eop/interview-prep.md +1 -0
- package/docs/career-os/knowledge/cover-letter.md +4 -0
- package/docs/career-os/knowledge/verifiers/human-voice.md +7 -2
- package/package.json +3 -1
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/jobsearch.ts +6 -2
- package/packages/core/src/offers.ts +41 -0
- package/packages/core/src/services.ts +5 -1
- package/packages/core/src/summary.ts +245 -19
- package/packages/db/src/connection.ts +70 -2
- package/packages/db/src/index.ts +1 -0
- package/packages/db/src/repositories.ts +140 -8
- package/packages/db/src/schema.ts +140 -8
- package/packages/mcp-tools/src/result.ts +9 -0
- package/packages/mcp-tools/src/tools.ts +143 -20
- package/packages/parsers/src/documents.ts +7 -1
- package/packages/prompts/src/humanize.ts +4 -0
- package/packages/shared/src/schemas.ts +134 -0
- package/scripts/init.ts +2 -0
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
// Applications — every application grouped by status in a calm VERTICAL list
|
|
2
2
|
// (no horizontal scroll). Change a status inline to move an item between groups.
|
|
3
3
|
import {
|
|
4
|
-
el, get, put, navigate, EmptyState, Btn, ListRow, Badge,
|
|
5
|
-
Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
|
|
4
|
+
el, get, put, navigate, EmptyState, Btn, ListRow, Badge, Card,
|
|
5
|
+
Select, scoreClass, statusColor, mount, toast, toastOk, toastError, todayLocalISO, daysUntil,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
import { t } from '/i18n.js';
|
|
8
8
|
|
|
9
|
-
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid)
|
|
9
|
+
// deadline(YYYY-MM-DD) → "Due in 7d" / "Due today" / "Overdue" (null if absent/invalid).
|
|
10
|
+
// daysUntil shares the server's local-midnight rule (core daysUntilDate).
|
|
10
11
|
function ddayLabel(deadline) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (isNaN(d.getTime())) return null;
|
|
14
|
-
const today = new Date();
|
|
15
|
-
today.setHours(0, 0, 0, 0);
|
|
16
|
-
const days = Math.round((d.getTime() - today.getTime()) / 86400000);
|
|
12
|
+
const days = daysUntil(deadline);
|
|
13
|
+
if (days == null) return null;
|
|
17
14
|
if (days > 0) return t('applications.dday.future', { n: days });
|
|
18
15
|
if (days === 0) return t('applications.dday.today');
|
|
19
16
|
return t('applications.dday.past');
|
|
@@ -21,9 +18,10 @@ function ddayLabel(deadline) {
|
|
|
21
18
|
|
|
22
19
|
export async function render(ctx) {
|
|
23
20
|
ctx.setActions([]);
|
|
24
|
-
const data = await get('/api/applications');
|
|
21
|
+
const [data, offersRes] = await Promise.all([get('/api/applications'), get('/api/offers')]);
|
|
25
22
|
const applications = data.applications || [];
|
|
26
23
|
const boardOrder = data.board_order || [];
|
|
24
|
+
const offers = (offersRes && offersRes.offers) || [];
|
|
27
25
|
|
|
28
26
|
if (!applications.length) {
|
|
29
27
|
mount(ctx.view, el('div', { class: 'stack-4' }, EmptyState({
|
|
@@ -43,6 +41,7 @@ export async function render(ctx) {
|
|
|
43
41
|
for (const app of applications) (byStatus[app.status] || (byStatus[app.status] = [])).push(app);
|
|
44
42
|
|
|
45
43
|
const wrap = el('div', { class: 'stack-4' });
|
|
44
|
+
if (offers.length) wrap.append(OffersSection(offers));
|
|
46
45
|
for (const code of boardOrder) {
|
|
47
46
|
const apps = byStatus[code];
|
|
48
47
|
if (apps && apps.length) wrap.append(StatusGroup(code, apps));
|
|
@@ -63,6 +62,10 @@ export async function render(ctx) {
|
|
|
63
62
|
const subBits = [job.position, job.location].filter(Boolean);
|
|
64
63
|
const dday = ddayLabel(job.deadline);
|
|
65
64
|
if (dday) subBits.push(dday);
|
|
65
|
+
// 탈락 회고: 불합격이고 단계가 기록돼 있으면 어느 단계에서 떨어졌는지 sub에 가볍게 병기.
|
|
66
|
+
if (app.status === 'rejected' && app.rejected_stage) {
|
|
67
|
+
subBits.push(`${t('applications.rejectedStage.label')} ${t('applications.rejectedStage.' + app.rejected_stage)}`);
|
|
68
|
+
}
|
|
66
69
|
const sel = Select(
|
|
67
70
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
68
71
|
{
|
|
@@ -112,3 +115,76 @@ export async function render(ctx) {
|
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
}
|
|
118
|
+
|
|
119
|
+
// Offers — sorted by the AI's score (server pre-sorts). The score + verdict were
|
|
120
|
+
// written by the connected AI at save time, so this renders without any LLM. The
|
|
121
|
+
// precise head-to-head comparison happens in the AI chat via compare_offers.
|
|
122
|
+
//
|
|
123
|
+
// Lifecycle-aware: only OPEN offers deserve the top of the pipeline (they carry
|
|
124
|
+
// accept deadlines — the most time-sensitive decision in the whole search).
|
|
125
|
+
// Accepted/declined offers are kept as career records (terms history feeds the
|
|
126
|
+
// next negotiation) but demoted to a collapsed "past offers" line.
|
|
127
|
+
function OffersSection(offers) {
|
|
128
|
+
const status = (o) => o.offer_status || 'open';
|
|
129
|
+
const active = offers.filter((o) => status(o) === 'open');
|
|
130
|
+
const past = offers.filter((o) => status(o) !== 'open');
|
|
131
|
+
const children = [];
|
|
132
|
+
if (active.length) {
|
|
133
|
+
children.push(
|
|
134
|
+
el('div', { class: 'app-group__head' },
|
|
135
|
+
el('span', { class: 'app-group__title' }, t('applications.offers.title')),
|
|
136
|
+
el('span', { class: 'app-group__count tnum' }, t('applications.group.count', { count: active.length }))),
|
|
137
|
+
...active.map(OfferCard),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
if (past.length) children.push(PastOffers(past));
|
|
141
|
+
return el('div', { class: 'stack-3' }, ...children);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function OfferCard(o) {
|
|
145
|
+
const meta = [];
|
|
146
|
+
if (o.total_comp_annual_est != null)
|
|
147
|
+
meta.push(`${t('applications.offers.est')} ${o.total_comp_annual_est.toLocaleString()}${t('applications.offers.unit')}`);
|
|
148
|
+
if (o.work_arrangement) meta.push(o.work_arrangement);
|
|
149
|
+
if (o.accept_deadline) meta.push(t('applications.offers.acceptBy', { date: o.accept_deadline }));
|
|
150
|
+
const score = o.ai_score != null
|
|
151
|
+
? el('span', { class: `strong tnum ${scoreClass(o.ai_score)}` }, String(o.ai_score))
|
|
152
|
+
: el('span', { class: 'muted' }, t('applications.offers.noScore'));
|
|
153
|
+
// Deadline passed on a still-open offer: flag it, never auto-hide — deadlines
|
|
154
|
+
// get extended in negotiation, so the call is the user's (or the AI's), not ours.
|
|
155
|
+
const expired = !!o.accept_deadline && o.accept_deadline < todayLocalISO();
|
|
156
|
+
const actions = expired
|
|
157
|
+
? el('span', { class: 'flex gap-2', style: { alignItems: 'center' } },
|
|
158
|
+
Badge('on_hold', t('applications.offers.expired')), score)
|
|
159
|
+
: score;
|
|
160
|
+
return Card({
|
|
161
|
+
title: o.company,
|
|
162
|
+
sub: meta.join(' · '),
|
|
163
|
+
actions,
|
|
164
|
+
body: o.verdict ? el('p', { class: 'muted', style: { marginTop: '6px' } }, o.verdict) : null,
|
|
165
|
+
clickable: true,
|
|
166
|
+
onClick: () => navigate(`/jobs/${o.job_id}`),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Accepted/declined offers, one compact row each inside a collapsed disclosure.
|
|
171
|
+
function PastOffers(past) {
|
|
172
|
+
const rows = past.map((o) =>
|
|
173
|
+
el('div', { class: 'flex gap-2', style: { alignItems: 'center', padding: '8px 0' } },
|
|
174
|
+
Badge(o.offer_status === 'accepted' ? 'final_passed' : 'rejected',
|
|
175
|
+
t(`applications.offers.status.${o.offer_status}`)),
|
|
176
|
+
el('span', {
|
|
177
|
+
class: 'strong',
|
|
178
|
+
style: { cursor: 'pointer' },
|
|
179
|
+
activate: () => navigate(`/jobs/${o.job_id}`),
|
|
180
|
+
}, o.company),
|
|
181
|
+
o.total_comp_annual_est != null
|
|
182
|
+
? el('span', { class: 'muted text-sm' },
|
|
183
|
+
`${t('applications.offers.est')} ${o.total_comp_annual_est.toLocaleString()}${t('applications.offers.unit')}`)
|
|
184
|
+
: null,
|
|
185
|
+
));
|
|
186
|
+
return el('details', {},
|
|
187
|
+
el('summary', { class: 'muted text-sm', style: { cursor: 'pointer' } },
|
|
188
|
+
t('applications.offers.past', { count: past.length })),
|
|
189
|
+
...rows);
|
|
190
|
+
}
|
|
@@ -21,19 +21,28 @@ export async function render(ctx) {
|
|
|
21
21
|
const root = el('div', { class: 'stack-4' });
|
|
22
22
|
|
|
23
23
|
const panel = el('div', {});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
24
|
+
// Tabs are keyboard-operable (activate = role/tabindex/Enter·Space), with tab
|
|
25
|
+
// semantics for AT — role은 attrs가 먼저 tab으로 지정하므로 activate의 button 기본값을 덮지 않는다.
|
|
26
|
+
const makeTab = (key, label) =>
|
|
27
|
+
el('div', {
|
|
28
|
+
class: `tab${tab === key ? ' is-active' : ''}`,
|
|
29
|
+
attrs: { role: 'tab', 'aria-selected': String(tab === key) },
|
|
30
|
+
activate: () => selectTab(key),
|
|
31
|
+
}, label);
|
|
32
|
+
const tabCover = makeTab('cover', t('documents.tab.cover'));
|
|
33
|
+
const tabCareer = makeTab('career', t('documents.tab.career'));
|
|
34
|
+
const tabDocs = makeTab('docs', t('documents.tab.docs'));
|
|
35
|
+
root.append(el('div', { class: 'tabs', attrs: { role: 'tablist' } }, tabCover, tabCareer, tabDocs), panel);
|
|
28
36
|
|
|
29
37
|
let query = '';
|
|
30
38
|
let searchInput = null;
|
|
31
39
|
function selectTab(next) {
|
|
32
40
|
tab = next;
|
|
33
41
|
query = '';
|
|
34
|
-
tabCover
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
for (const [node, key] of [[tabCover, 'cover'], [tabCareer, 'career'], [tabDocs, 'docs']]) {
|
|
43
|
+
node.classList.toggle('is-active', tab === key);
|
|
44
|
+
node.setAttribute('aria-selected', String(tab === key));
|
|
45
|
+
}
|
|
37
46
|
setActions();
|
|
38
47
|
renderPanel();
|
|
39
48
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
el, get, icon, navigate, Card, Badge, Btn, ListRow, CheckRow,
|
|
8
8
|
Stat, PageHead, fmtDate, fmtRelative, scoreClass, STATUS_COLOR, statusColor, mount,
|
|
9
|
+
scheduleDdayBadge,
|
|
9
10
|
} from '/lib.js';
|
|
10
11
|
import { t } from '/i18n.js';
|
|
11
12
|
|
|
@@ -215,6 +216,17 @@ function ActionLane(s) {
|
|
|
215
216
|
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
216
217
|
}));
|
|
217
218
|
}
|
|
219
|
+
// Hiring-schedule stages (면접·인적성·발표) sit right after interview prep and before
|
|
220
|
+
// deadlines: they're the most time-bound "be there on this date" items.
|
|
221
|
+
for (const it of s.upcoming_schedule || []) {
|
|
222
|
+
push(it.job.id, ListRow({
|
|
223
|
+
leading: statusDot('interview'),
|
|
224
|
+
title: it.job.company,
|
|
225
|
+
sub: it.time ? `${it.label} · ${it.time}` : it.label,
|
|
226
|
+
trailing: scheduleDdayBadge(it.d_day),
|
|
227
|
+
onClick: () => navigate(`/jobs/${it.job.id}`),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
218
230
|
for (const it of s.deadlines || []) {
|
|
219
231
|
push(it.job.id, ListRow({
|
|
220
232
|
leading: statusDot(it.status),
|
|
@@ -146,12 +146,19 @@ export async function render(ctx) {
|
|
|
146
146
|
mount(panel, list.length
|
|
147
147
|
? el('div', { class: 'stack-3' }, ...list.map(QuestionBlock))
|
|
148
148
|
: el('p', { class: 'muted', style: { margin: 0 } }, t('interview.modal.questionsEmptyTab')));
|
|
149
|
-
for (const c of CATEGORIES)
|
|
149
|
+
for (const c of CATEGORIES) {
|
|
150
|
+
tabEls[c].classList.toggle('is-active', c === active);
|
|
151
|
+
tabEls[c].setAttribute('aria-selected', String(c === active));
|
|
152
|
+
}
|
|
150
153
|
}
|
|
151
|
-
|
|
154
|
+
// 키보드 조작 가능 탭(activate = role/tabindex/Enter·Space) + 탭 시맨틱.
|
|
155
|
+
const tabs = el('div', { class: 'tabs', attrs: { role: 'tablist' } }, ...CATEGORIES.map((c) => {
|
|
152
156
|
const n = groups[c].length;
|
|
153
|
-
const tabEl = el('div', {
|
|
154
|
-
|
|
157
|
+
const tabEl = el('div', {
|
|
158
|
+
class: 'tab',
|
|
159
|
+
attrs: { role: 'tab', 'aria-selected': 'false' },
|
|
160
|
+
activate: () => { active = c; draw(); },
|
|
161
|
+
}, n ? `${t('interview.tab.' + c)} (${n})` : t('interview.tab.' + c));
|
|
155
162
|
tabEls[c] = tabEl;
|
|
156
163
|
return tabEl;
|
|
157
164
|
}));
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
el, get, post, put, del, icon, navigate, Card, Badge, Btn, SubmitBtn,
|
|
7
7
|
EmptyState, Chips, Field, Input, Textarea, Select,
|
|
8
8
|
openModal, closeModal, confirmDialog, toastOk, toastError,
|
|
9
|
-
fmtDate, scoreClass, mount, meta, linesToArray, csvToArray,
|
|
9
|
+
fmtDate, scoreClass, mount, meta, linesToArray, csvToArray, daysUntil, scheduleDdayBadge,
|
|
10
10
|
} from '/lib.js';
|
|
11
11
|
import { t } from '/i18n.js';
|
|
12
12
|
|
|
@@ -340,6 +340,7 @@ async function renderDetail(ctx, jobId) {
|
|
|
340
340
|
CoverLettersCard(job.cover_letters),
|
|
341
341
|
InterviewCard(job)),
|
|
342
342
|
PostingCard(job),
|
|
343
|
+
CompanyResearchCard(job),
|
|
343
344
|
FitCard(job.fit),
|
|
344
345
|
TimelineCard(job.timeline),
|
|
345
346
|
RelatedCard(job.related),
|
|
@@ -372,6 +373,41 @@ function PostingCard(job) {
|
|
|
372
373
|
...job.requirements.map((r) => el('li', {}, r)))));
|
|
373
374
|
}
|
|
374
375
|
|
|
376
|
+
if (job.questions && job.questions.length) {
|
|
377
|
+
// 한국 문항형 자소서: 회사 지정 문항·글자수 한도. AI가 문항별 답변을 쓰고 한도를
|
|
378
|
+
// 점검할 원본이자, 사용자가 대시보드에서 요구 문항을 그대로 볼 수 있게 한다.
|
|
379
|
+
body.push(el('div', { class: 'mt-3' },
|
|
380
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.questions.title')),
|
|
381
|
+
el('ol', { class: 'stack-2', style: { margin: 0, paddingLeft: '18px' } },
|
|
382
|
+
...job.questions.map((q) => {
|
|
383
|
+
const label = questionLimitLabel(q);
|
|
384
|
+
return el('li', {},
|
|
385
|
+
el('span', {}, q.question),
|
|
386
|
+
label ? el('span', { class: 'muted text-sm', style: { marginLeft: '8px' } }, label) : null);
|
|
387
|
+
}))));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (job.schedule && job.schedule.length) {
|
|
391
|
+
// 전형 일정: 면접·인적성·발표 등 단계별 날짜. 날짜순으로 D-day 라벨과 함께 보여주고,
|
|
392
|
+
// 이미 지난 일정은 흐리게 처리한다(v9 문항 블록과 같은 위치·스타일 패턴).
|
|
393
|
+
const sorted = job.schedule.slice().sort((a, b) => {
|
|
394
|
+
if (a.date !== b.date) return a.date < b.date ? -1 : 1;
|
|
395
|
+
return (a.time || '99:99').localeCompare(b.time || '99:99');
|
|
396
|
+
});
|
|
397
|
+
body.push(el('div', { class: 'mt-3' },
|
|
398
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.schedule.title')),
|
|
399
|
+
el('ul', { class: 'stack-2', style: { margin: 0, padding: 0, listStyle: 'none' } },
|
|
400
|
+
...sorted.map((s) => {
|
|
401
|
+
const dDay = daysUntil(s.date);
|
|
402
|
+
const when = s.time ? `${fmtDate(s.date)} ${s.time}` : fmtDate(s.date);
|
|
403
|
+
return el('li', { class: `flex gap-2 center${dDay != null && dDay < 0 ? ' muted' : ''}` },
|
|
404
|
+
scheduleDdayBadge(dDay),
|
|
405
|
+
el('span', { class: 'strong' }, s.label),
|
|
406
|
+
el('span', { class: 'text-sm' }, when),
|
|
407
|
+
s.note ? el('span', { class: 'muted text-sm' }, s.note) : null);
|
|
408
|
+
}))));
|
|
409
|
+
}
|
|
410
|
+
|
|
375
411
|
if (job.description) {
|
|
376
412
|
// 원문은 가장 길어 칼럼을 늘리는 주범 — 기본 접힘(아코디언)으로 둔다.
|
|
377
413
|
body.push(el('details', { class: 'disclosure mt-3' },
|
|
@@ -384,6 +420,40 @@ function PostingCard(job) {
|
|
|
384
420
|
return Card({ title: t('jobs.posting.title'), body });
|
|
385
421
|
}
|
|
386
422
|
|
|
423
|
+
// 문항 글자수 한도 라벨(예: "최대 700자(공백포함)"). 한도가 없으면 null.
|
|
424
|
+
// count_mode: with_space=공백포함 · no_space=공백제외 · byte=UTF-8 바이트(공백 구분 없음).
|
|
425
|
+
function questionLimitLabel(q) {
|
|
426
|
+
const hasMin = typeof q.min_chars === 'number';
|
|
427
|
+
const hasMax = typeof q.max_chars === 'number';
|
|
428
|
+
if (!hasMin && !hasMax) return null;
|
|
429
|
+
const unit = q.count_mode === 'byte' ? t('jobs.questions.unitByte') : t('jobs.questions.unitChar');
|
|
430
|
+
const suffix = q.count_mode === 'no_space' ? t('jobs.questions.modeNoSpace')
|
|
431
|
+
: q.count_mode === 'with_space' ? t('jobs.questions.modeWithSpace')
|
|
432
|
+
: '';
|
|
433
|
+
const parts = [];
|
|
434
|
+
if (hasMin) parts.push(t('jobs.questions.min', { n: q.min_chars, unit }));
|
|
435
|
+
if (hasMax) parts.push(t('jobs.questions.max', { n: q.max_chars, unit }));
|
|
436
|
+
return `${parts.join(' · ')}${suffix}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Company research the AI saved (overview·인재상·핵심가치·평판) — rendered so the
|
|
440
|
+
// user can actually SEE it in the dashboard, not just have the AI use it.
|
|
441
|
+
function CompanyResearchCard(job) {
|
|
442
|
+
const hasValues = job.core_values && job.core_values.length;
|
|
443
|
+
if (!job.company_overview && !job.talent_profile && !hasValues && !job.reputation) return null;
|
|
444
|
+
const field = (labelKey, value) => el('div', { class: 'mt-3' },
|
|
445
|
+
el('div', { class: 'muted text-sm mb-2' }, t(labelKey)),
|
|
446
|
+
el('p', { style: { margin: 0, whiteSpace: 'pre-wrap' } }, value));
|
|
447
|
+
const body = [];
|
|
448
|
+
if (job.company_overview) body.push(field('jobs.company.overview', job.company_overview));
|
|
449
|
+
if (job.talent_profile) body.push(field('jobs.company.talent', job.talent_profile));
|
|
450
|
+
if (hasValues) body.push(el('div', { class: 'mt-3' },
|
|
451
|
+
el('div', { class: 'muted text-sm mb-2' }, t('jobs.company.values')),
|
|
452
|
+
Chips(job.core_values, { accent: true })));
|
|
453
|
+
if (job.reputation) body.push(field('jobs.company.reputation', job.reputation));
|
|
454
|
+
return Card({ title: t('jobs.company.title'), body });
|
|
455
|
+
}
|
|
456
|
+
|
|
387
457
|
function FitCard(fit) {
|
|
388
458
|
if (!fit) {
|
|
389
459
|
return Card({
|
|
@@ -168,7 +168,6 @@ function Theme() {
|
|
|
168
168
|
/* ------------------------------------------------------------- 언어 / Language */
|
|
169
169
|
function LanguageSwitcher() {
|
|
170
170
|
// 테마 컴포넌트와 동일 패턴: localStorage 영속 + paint() + 변경 시 재페인트.
|
|
171
|
-
// 카탈로그가 채워지는 Phase 2부터 페이지 텍스트가 실제로 전환된다.
|
|
172
171
|
const options = [
|
|
173
172
|
{ value: 'ko', label: '한국어' },
|
|
174
173
|
{ value: 'en', label: 'English' },
|
|
@@ -188,8 +187,9 @@ function LanguageSwitcher() {
|
|
|
188
187
|
onLangChange(paint);
|
|
189
188
|
|
|
190
189
|
return Card({
|
|
190
|
+
// 제목은 의도적으로 양언어 병기 — 현재 언어를 못 읽는 사용자도 이 카드를 찾아야 한다.
|
|
191
191
|
title: '언어 / Language',
|
|
192
|
-
sub: '
|
|
192
|
+
sub: t('settings.lang.sub'),
|
|
193
193
|
body: row,
|
|
194
194
|
});
|
|
195
195
|
}
|
package/dist/public/styles.css
CHANGED
|
@@ -577,3 +577,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
577
577
|
.card__head .right{ margin-left: 0; width: 100%; justify-content: flex-start; }
|
|
578
578
|
.page-head{ flex-wrap: wrap; }
|
|
579
579
|
}
|
|
580
|
+
|
|
581
|
+
/* ---------- reduced motion ----------
|
|
582
|
+
전정기관 민감 사용자를 위해 OS 설정을 존중한다: 모달·토스트·오프캔버스의
|
|
583
|
+
등장 애니메이션과 hover 트랜지션을 사실상 즉시(0.01ms)로 줄인다.
|
|
584
|
+
(스켈레톤 shimmer 포함 — 반복 애니메이션이 가장 부담이 크다.) */
|
|
585
|
+
@media (prefers-reduced-motion: reduce) {
|
|
586
|
+
*, *::before, *::after {
|
|
587
|
+
animation-duration: 0.01ms !important;
|
|
588
|
+
animation-iteration-count: 1 !important;
|
|
589
|
+
transition-duration: 0.01ms !important;
|
|
590
|
+
scroll-behavior: auto !important;
|
|
591
|
+
}
|
|
592
|
+
}
|