careermate 0.0.7 → 0.0.8
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/README.md +30 -10
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/career-os/eop/cover-letter.md +4 -3
- package/dist/career-os/knowledge/cover-letter.md +3 -3
- package/dist/career-os/knowledge/fit-matching.md +2 -0
- package/dist/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/dist/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/dist/doctor.mjs +18995 -801
- package/dist/mcp.mjs +19009 -815
- package/dist/migrate.mjs +83 -6
- package/dist/public/i18n/en/common.js +4 -0
- package/dist/public/i18n/en/documents.js +0 -10
- package/dist/public/i18n/en/home.js +5 -0
- package/dist/public/i18n/en/jobs.js +1 -1
- package/dist/public/i18n/en/profile.js +0 -5
- package/dist/public/i18n/ko/common.js +4 -0
- package/dist/public/i18n/ko/documents.js +0 -10
- package/dist/public/i18n/ko/home.js +5 -0
- package/dist/public/i18n/ko/jobs.js +1 -1
- package/dist/public/i18n/ko/profile.js +0 -5
- package/dist/public/lib.js +72 -0
- package/dist/public/pages/applications.js +10 -6
- package/dist/public/pages/documents.js +4 -7
- package/dist/public/pages/home.js +26 -6
- package/dist/public/pages/profile.js +2 -3
- package/dist/public/styles.css +39 -4
- package/dist/web.mjs +18547 -649
- package/docs/MCP_TOOLS.md +14 -7
- package/docs/career-os/eop/cover-letter.md +4 -3
- package/docs/career-os/knowledge/cover-letter.md +3 -3
- package/docs/career-os/knowledge/fit-matching.md +2 -0
- package/docs/career-os/knowledge/verifiers/ai-detection-risk.md +57 -0
- package/docs/career-os/knowledge/verifiers/truthfulness.md +1 -0
- package/package.json +3 -2
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/jobsearch.ts +186 -0
- package/packages/core/src/services.ts +24 -2
- package/packages/core/src/summary.ts +196 -0
- package/packages/core/src/verify/charcount.ts +87 -0
- package/packages/core/src/verify/index.ts +1 -0
- package/packages/core/src/verify/lint.ts +5 -0
- package/packages/db/src/repositories.ts +42 -8
- package/packages/db/src/schema.ts +29 -0
- package/packages/exporters/src/docx.ts +165 -0
- package/packages/exporters/src/index.ts +142 -9
- package/packages/exporters/src/markdown.ts +16 -11
- package/packages/knowledge/src/index.ts +8 -3
- package/packages/mcp-tools/src/tools.ts +173 -53
- package/packages/shared/src/schemas.ts +64 -0
- package/scripts/init.ts +5 -0
package/dist/migrate.mjs
CHANGED
|
@@ -312,6 +312,32 @@ var MIGRATIONS = [
|
|
|
312
312
|
);
|
|
313
313
|
|
|
314
314
|
CREATE INDEX IF NOT EXISTS idx_timeline_job ON application_timeline_events(job_id, occurred_at ASC);
|
|
315
|
+
`,
|
|
316
|
+
// v4 — structured quantitative credentials on the profile (학력·자격증·어학점수·수상).
|
|
317
|
+
// Stored as JSON arrays in TEXT columns (same pattern as profile.links), so the AI
|
|
318
|
+
// can cite exact values (GPA, TOEIC 920) without re-asking or hallucinating, and
|
|
319
|
+
// they feed the verify corpus. ALTER ADD COLUMN is non-destructive and idempotent
|
|
320
|
+
// across the ordered migration runner.
|
|
321
|
+
`
|
|
322
|
+
ALTER TABLE profile ADD COLUMN education TEXT NOT NULL DEFAULT '[]';
|
|
323
|
+
ALTER TABLE profile ADD COLUMN certifications TEXT NOT NULL DEFAULT '[]';
|
|
324
|
+
ALTER TABLE profile ADD COLUMN language_scores TEXT NOT NULL DEFAULT '[]';
|
|
325
|
+
ALTER TABLE profile ADD COLUMN awards TEXT NOT NULL DEFAULT '[]';
|
|
326
|
+
`,
|
|
327
|
+
// v5 — company research on the job posting (사업개요·인재상·핵심가치). Korea-centric: cover
|
|
328
|
+
// letters and interview prep align to a company's 인재상/핵심가치, so store it where the AI
|
|
329
|
+
// already reads it (the job context) rather than a separate entity. Additive ALTER, idempotent.
|
|
330
|
+
`
|
|
331
|
+
ALTER TABLE jobs ADD COLUMN company_overview TEXT;
|
|
332
|
+
ALTER TABLE jobs ADD COLUMN talent_profile TEXT;
|
|
333
|
+
ALTER TABLE jobs ADD COLUMN core_values TEXT NOT NULL DEFAULT '[]';
|
|
334
|
+
`,
|
|
335
|
+
// v6 — post-interview debrief on interview_preps (받은 질문·잘된 점·개선점·다음 라운드 포커스·
|
|
336
|
+
// 체감 분위기). Closes the loop: prep is pre-interview, debrief is post-interview, but both belong
|
|
337
|
+
// to the same per-job entity, so store as one JSON column rather than a new table. Additive ALTER,
|
|
338
|
+
// idempotent. Feeds next-round prep and consistency (lessons carry into later interviews).
|
|
339
|
+
`
|
|
340
|
+
ALTER TABLE interview_preps ADD COLUMN debrief TEXT NOT NULL DEFAULT '{}';
|
|
315
341
|
`
|
|
316
342
|
];
|
|
317
343
|
function migrate(db) {
|
|
@@ -4482,6 +4508,33 @@ var LinkSchema = external_exports.object({
|
|
|
4482
4508
|
label: reqLine,
|
|
4483
4509
|
url: SafeUrl
|
|
4484
4510
|
});
|
|
4511
|
+
var EducationSchema = external_exports.object({
|
|
4512
|
+
school: reqLine.describe("\uD559\uAD50\uBA85"),
|
|
4513
|
+
degree: external_exports.string().max(MAX_LINE).optional().describe("\uD559\uC704/\uACFC\uC815 (\uACE0\uC878\xB7\uD559\uC0AC\xB7\uC11D\uC0AC\xB7\uBC15\uC0AC \uB4F1)"),
|
|
4514
|
+
major: external_exports.string().max(MAX_LINE).optional().describe("\uC804\uACF5"),
|
|
4515
|
+
gpa: external_exports.string().max(MAX_LINE).optional().describe("\uD559\uC810 (\uC608: 3.8)"),
|
|
4516
|
+
gpa_scale: external_exports.string().max(MAX_LINE).optional().describe("\uD559\uC810 \uB9CC\uC810 (\uC608: 4.5)"),
|
|
4517
|
+
start_date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM"),
|
|
4518
|
+
end_date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM (\uC878\uC5C5/\uC608\uC815)"),
|
|
4519
|
+
status: external_exports.string().max(MAX_LINE).optional().describe("\uC878\uC5C5/\uC7AC\uD559/\uD734\uD559/\uC911\uD1F4/\uC218\uB8CC")
|
|
4520
|
+
});
|
|
4521
|
+
var CertificationSchema = external_exports.object({
|
|
4522
|
+
name: reqLine.describe("\uC790\uACA9\uC99D/\uBA74\uD5C8\uBA85"),
|
|
4523
|
+
issuer: external_exports.string().max(MAX_LINE).optional().describe("\uBC1C\uAE09\uAE30\uAD00"),
|
|
4524
|
+
date: external_exports.string().max(MAX_LINE).optional().describe("\uCDE8\uB4DD\uC77C YYYY-MM"),
|
|
4525
|
+
score: external_exports.string().max(MAX_LINE).optional().describe("\uC810\uC218/\uB4F1\uAE09(\uC788\uC73C\uBA74)")
|
|
4526
|
+
});
|
|
4527
|
+
var LanguageScoreSchema = external_exports.object({
|
|
4528
|
+
test: reqLine.describe("\uC2DC\uD5D8\uBA85 (TOEIC\xB7OPIc\xB7TOEIC Speaking\xB7JLPT\xB7HSK \uB4F1)"),
|
|
4529
|
+
score: reqLine.describe("\uC810\uC218/\uB4F1\uAE09 (\uC608: 920, IH, N1)"),
|
|
4530
|
+
date: external_exports.string().max(MAX_LINE).optional().describe("\uC751\uC2DC/\uCDE8\uB4DD\uC77C YYYY-MM")
|
|
4531
|
+
});
|
|
4532
|
+
var AwardSchema = external_exports.object({
|
|
4533
|
+
title: reqLine.describe("\uC218\uC0C1/\uB300\uC678\uD65C\uB3D9\uBA85"),
|
|
4534
|
+
issuer: external_exports.string().max(MAX_LINE).optional().describe("\uC8FC\uCD5C/\uC218\uC5EC\uAE30\uAD00"),
|
|
4535
|
+
date: external_exports.string().max(MAX_LINE).optional().describe("YYYY-MM"),
|
|
4536
|
+
description: external_exports.string().max(MAX_NOTE).optional().describe("\uAC04\uB2E8 \uC124\uBA85")
|
|
4537
|
+
});
|
|
4485
4538
|
var ProfileInputSchema = external_exports.object({
|
|
4486
4539
|
name: reqLine.optional(),
|
|
4487
4540
|
email: optLine,
|
|
@@ -4493,7 +4546,11 @@ var ProfileInputSchema = external_exports.object({
|
|
|
4493
4546
|
desired_conditions: external_exports.string().max(MAX_NOTE).optional().describe("\uD76C\uB9DD \uADFC\uBB34 \uC870\uAC74 (\uC5F0\uBD09, \uC9C0\uC5ED, \uADFC\uBB34\uD615\uD0DC \uB4F1)"),
|
|
4494
4547
|
preferred_tone: external_exports.string().max(MAX_LINE).optional().describe("\uC790\uAE30\uC18C\uAC1C\uC11C \uC120\uD638 \uBB38\uCCB4 (\uC608: \uB2F4\uBC31\uD558\uACE0 \uAD6C\uCCB4\uC801)"),
|
|
4495
4548
|
emphasis_points: strList.optional().describe("\uAC15\uC870\uD558\uACE0 \uC2F6\uC740 \uD575\uC2EC \uD3EC\uC778\uD2B8"),
|
|
4496
|
-
links: external_exports.array(LinkSchema).max(MAX_ITEMS).optional().describe("\uD3EC\uD2B8\uD3F4\uB9AC\uC624/\uAE43\uD5C8\uBE0C/\uB9C1\uD06C\uB4DC\uC778 \uB4F1")
|
|
4549
|
+
links: external_exports.array(LinkSchema).max(MAX_ITEMS).optional().describe("\uD3EC\uD2B8\uD3F4\uB9AC\uC624/\uAE43\uD5C8\uBE0C/\uB9C1\uD06C\uB4DC\uC778 \uB4F1"),
|
|
4550
|
+
education: external_exports.array(EducationSchema).max(MAX_ITEMS).optional().describe("\uD559\uB825 (\uD559\uAD50\xB7\uD559\uC704\xB7\uC804\uACF5\xB7\uD559\uC810)"),
|
|
4551
|
+
certifications: external_exports.array(CertificationSchema).max(MAX_ITEMS).optional().describe("\uC790\uACA9\uC99D/\uBA74\uD5C8"),
|
|
4552
|
+
language_scores: external_exports.array(LanguageScoreSchema).max(MAX_ITEMS).optional().describe("\uC5B4\uD559 \uC810\uC218 (\uD1A0\uC775\xB7\uC624\uD53D \uB4F1)"),
|
|
4553
|
+
awards: external_exports.array(AwardSchema).max(MAX_ITEMS).optional().describe("\uC218\uC0C1/\uB300\uC678\uD65C\uB3D9")
|
|
4497
4554
|
});
|
|
4498
4555
|
var ProfileRecordSchema = external_exports.object({
|
|
4499
4556
|
...baseRecord,
|
|
@@ -4507,7 +4564,11 @@ var ProfileRecordSchema = external_exports.object({
|
|
|
4507
4564
|
desired_conditions: external_exports.string().nullable(),
|
|
4508
4565
|
preferred_tone: external_exports.string().nullable(),
|
|
4509
4566
|
emphasis_points: external_exports.array(external_exports.string()),
|
|
4510
|
-
links: external_exports.array(LinkSchema)
|
|
4567
|
+
links: external_exports.array(LinkSchema),
|
|
4568
|
+
education: external_exports.array(EducationSchema),
|
|
4569
|
+
certifications: external_exports.array(CertificationSchema),
|
|
4570
|
+
language_scores: external_exports.array(LanguageScoreSchema),
|
|
4571
|
+
awards: external_exports.array(AwardSchema)
|
|
4511
4572
|
});
|
|
4512
4573
|
var ExperienceInputSchema = external_exports.object({
|
|
4513
4574
|
company: reqLine,
|
|
@@ -4646,7 +4707,10 @@ var JobInputSchema = external_exports.object({
|
|
|
4646
4707
|
requirements: strList.optional().describe("\uC790\uACA9\uC694\uAC74/\uC6B0\uB300\uC0AC\uD56D \uD575\uC2EC"),
|
|
4647
4708
|
keywords: strList.optional().describe("\uD575\uC2EC \uD0A4\uC6CC\uB4DC"),
|
|
4648
4709
|
deadline: external_exports.string().max(MAX_LINE).optional().describe("\uB9C8\uAC10\uC77C YYYY-MM-DD"),
|
|
4649
|
-
source: external_exports.string().max(MAX_LINE).optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)")
|
|
4710
|
+
source: external_exports.string().max(MAX_LINE).optional().describe("\uCD9C\uCC98 (\uC0AC\uB78C\uC778/\uC6D0\uD2F0\uB4DC/\uC9C1\uC811 \uC785\uB825 \uB4F1)"),
|
|
4711
|
+
company_overview: optBody.describe("\uD68C\uC0AC \uC0AC\uC5C5\uAC1C\uC694\xB7\uBBF8\uC158\xB7\uC8FC\uC694 \uC81C\uD488\xB7\uCD5C\uADFC \uD65C\uB3D9 (AI\uAC00 \uB9AC\uC11C\uCE58\uD574 \uCC44\uC6C0)"),
|
|
4712
|
+
talent_profile: optBody.describe("\uD68C\uC0AC\uAC00 \uAC15\uC870\uD558\uB294 \uC778\uC7AC\uC0C1 (\uD55C\uAD6D \uC790\uC18C\uC11C\xB7\uBA74\uC811\uC5D0\uC11C \uC815\uB82C \uAE30\uC900)"),
|
|
4713
|
+
core_values: strList.optional().describe("\uD68C\uC0AC \uD575\uC2EC\uAC00\uCE58 (\uD0A4\uC6CC\uB4DC \uBC30\uC5F4)")
|
|
4650
4714
|
});
|
|
4651
4715
|
var JobRecordSchema = external_exports.object({
|
|
4652
4716
|
...baseRecord,
|
|
@@ -4659,7 +4723,10 @@ var JobRecordSchema = external_exports.object({
|
|
|
4659
4723
|
requirements: external_exports.array(external_exports.string()),
|
|
4660
4724
|
keywords: external_exports.array(external_exports.string()),
|
|
4661
4725
|
deadline: external_exports.string().nullable(),
|
|
4662
|
-
source: external_exports.string().nullable()
|
|
4726
|
+
source: external_exports.string().nullable(),
|
|
4727
|
+
company_overview: external_exports.string().nullable(),
|
|
4728
|
+
talent_profile: external_exports.string().nullable(),
|
|
4729
|
+
core_values: external_exports.array(external_exports.string())
|
|
4663
4730
|
});
|
|
4664
4731
|
var FitAnalysisInputSchema = external_exports.object({
|
|
4665
4732
|
job_id: reqLine.describe("\uBD84\uC11D \uB300\uC0C1 \uACF5\uACE0 ID"),
|
|
@@ -4728,12 +4795,21 @@ var InterviewQuestionSchema = external_exports.object({
|
|
|
4728
4795
|
followups: strList.optional().describe("\uC608\uC0C1 \uAF2C\uB9AC \uC9C8\uBB38"),
|
|
4729
4796
|
answer_outline: optBody.describe("\uB2F5\uBCC0 \uAC00\uC774\uB4DC/\uD575\uC2EC \uD3EC\uC778\uD2B8")
|
|
4730
4797
|
});
|
|
4798
|
+
var InterviewDebriefSchema = external_exports.object({
|
|
4799
|
+
asked_questions: strList.optional().describe("\uC2E4\uC81C\uB85C \uBC1B\uC740 \uC9C8\uBB38\uB4E4"),
|
|
4800
|
+
went_well: optBody.describe("\uC798\uB41C \uC810/\uAC15\uD588\uB358 \uB2F5\uBCC0"),
|
|
4801
|
+
to_improve: optBody.describe("\uC544\uC26C\uC6B4 \uC810/\uB2E4\uC74C\uC5D0 \uAC1C\uC120\uD560 \uC810"),
|
|
4802
|
+
next_round_focus: optBody.describe("\uB2E4\uC74C \uB77C\uC6B4\uB4DC/\uBA74\uC811\uC5D0 \uB300\uBE44\uD574 \uC900\uBE44\uD560 \uD3EC\uC778\uD2B8"),
|
|
4803
|
+
sentiment: external_exports.enum(["positive", "neutral", "negative"]).optional().describe("\uCCB4\uAC10 \uBD84\uC704\uAE30"),
|
|
4804
|
+
interviewed_at: optLine.describe("\uBA74\uC811 \uBCF8 \uB0A0\uC9DC(YYYY-MM-DD)")
|
|
4805
|
+
});
|
|
4731
4806
|
var InterviewPrepInputSchema = external_exports.object({
|
|
4732
4807
|
job_id: reqLine,
|
|
4733
4808
|
questions: external_exports.array(InterviewQuestionSchema).max(MAX_ITEMS).optional(),
|
|
4734
4809
|
star_guides: external_exports.array(StarGuideSchema).max(MAX_ITEMS).optional(),
|
|
4735
4810
|
self_introduction: optBody.describe("1\uBD84 \uC790\uAE30\uC18C\uAC1C \uCD08\uC548"),
|
|
4736
|
-
notes: optNote.describe("\uBA74\uC811 \uD6C4\uAE30/\uBA54\uBAA8")
|
|
4811
|
+
notes: optNote.describe("\uBA74\uC811 \uD6C4\uAE30/\uBA54\uBAA8"),
|
|
4812
|
+
debrief: InterviewDebriefSchema.optional().describe("\uBA74\uC811 \uD6C4 \uB514\uBE0C\uB9AC\uD504(\uBC1B\uC740 \uC9C8\uBB38\xB7\uC798\uB41C \uC810\xB7\uAC1C\uC120\uC810\xB7\uB2E4\uC74C \uB77C\uC6B4\uB4DC \uD3EC\uCEE4\uC2A4)")
|
|
4737
4813
|
});
|
|
4738
4814
|
var InterviewPrepRecordSchema = external_exports.object({
|
|
4739
4815
|
...baseRecord,
|
|
@@ -4741,7 +4817,8 @@ var InterviewPrepRecordSchema = external_exports.object({
|
|
|
4741
4817
|
questions: external_exports.array(InterviewQuestionSchema),
|
|
4742
4818
|
star_guides: external_exports.array(StarGuideSchema),
|
|
4743
4819
|
self_introduction: external_exports.string().nullable(),
|
|
4744
|
-
notes: external_exports.string().nullable()
|
|
4820
|
+
notes: external_exports.string().nullable(),
|
|
4821
|
+
debrief: InterviewDebriefSchema.nullable()
|
|
4745
4822
|
});
|
|
4746
4823
|
var ActivityRecordSchema = external_exports.object({
|
|
4747
4824
|
id,
|
|
@@ -9,6 +9,10 @@ export default {
|
|
|
9
9
|
'common.delete': 'Delete',
|
|
10
10
|
'common.open': 'Open',
|
|
11
11
|
'common.justNow': 'just now',
|
|
12
|
+
'common.export': 'Export',
|
|
13
|
+
'common.fmtWord': 'Word',
|
|
14
|
+
'common.fmtHtml': 'HTML',
|
|
15
|
+
'common.fmtMarkdown': 'Markdown',
|
|
12
16
|
|
|
13
17
|
'toast.copied': 'Copied to clipboard.',
|
|
14
18
|
'toast.copyFailed': "Couldn't copy. Please select and copy it manually.",
|
|
@@ -28,10 +28,6 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': 'Primary',
|
|
29
29
|
'documents.cover.open': 'Open',
|
|
30
30
|
'documents.cover.copy': 'Copy',
|
|
31
|
-
'documents.cover.exportMd': 'MD',
|
|
32
|
-
'documents.cover.exportHtml': 'HTML',
|
|
33
|
-
'documents.cover.exportMdTitle': 'Export as Markdown',
|
|
34
|
-
'documents.cover.exportHtmlTitle': 'Export as HTML',
|
|
35
31
|
'documents.cover.setPrimary': 'Set as primary',
|
|
36
32
|
'documents.cover.delete': 'Delete',
|
|
37
33
|
'documents.cover.noContent': 'No content yet.',
|
|
@@ -60,8 +56,6 @@ export default {
|
|
|
60
56
|
'documents.coverCreate.toast': 'Cover letter added.',
|
|
61
57
|
|
|
62
58
|
// cover detail body
|
|
63
|
-
'documents.coverDetail.exportMd': 'Export as Markdown',
|
|
64
|
-
'documents.coverDetail.exportHtml': 'Export as HTML',
|
|
65
59
|
'documents.coverDetail.currentVersion': 'Current version',
|
|
66
60
|
'documents.coverDetail.copy': 'Copy',
|
|
67
61
|
'documents.coverDetail.noContent': 'No content.',
|
|
@@ -101,10 +95,6 @@ export default {
|
|
|
101
95
|
'documents.docDetail.updated': 'Updated {{when}}',
|
|
102
96
|
'documents.docDetail.noContent': 'No content.',
|
|
103
97
|
'documents.docDetail.copy': 'Copy',
|
|
104
|
-
'documents.docDetail.export': 'MD',
|
|
105
|
-
'documents.docDetail.exportTitle': 'Export as Markdown',
|
|
106
|
-
'documents.docDetail.exportHtml': 'HTML',
|
|
107
|
-
'documents.docDetail.exportHtmlTitle': 'Export as HTML',
|
|
108
98
|
'documents.docDetail.delete': 'Delete',
|
|
109
99
|
'documents.docDetail.deleteTitle': 'Delete document',
|
|
110
100
|
'documents.docDetail.deleteConfirm': 'Delete "{{title}}"?',
|
|
@@ -37,6 +37,11 @@ export default {
|
|
|
37
37
|
'home.stat.activeApplications': 'Active applications',
|
|
38
38
|
'home.stat.coverLetters': 'Cover letters',
|
|
39
39
|
'home.stat.interviewPending': 'Interview prep',
|
|
40
|
+
// KPI tile context sub-lines (derived from real counts; omitted when zero)
|
|
41
|
+
'home.stat.jobsSub': '{{count}} to apply',
|
|
42
|
+
'home.stat.subInterview': 'Interview {{count}}',
|
|
43
|
+
'home.stat.subDocPassed': 'Screening {{count}}',
|
|
44
|
+
'home.stat.subApplied': 'Applied {{count}}',
|
|
40
45
|
|
|
41
46
|
// pipeline
|
|
42
47
|
'home.pipeline.title': 'Application pipeline',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export default {
|
|
3
3
|
// ---- list mode ----
|
|
4
4
|
'jobs.list.empty.title': 'No saved postings yet',
|
|
5
|
-
'jobs.list.empty.body': 'Add a posting you are interested in to track its fit and your progress.',
|
|
5
|
+
'jobs.list.empty.body': 'Add a posting you are interested in to track its fit and your progress. Or just tell your connected AI “find backend jobs” and it will fetch postings and save them here.',
|
|
6
6
|
'jobs.list.addJob': 'Add posting',
|
|
7
7
|
'jobs.list.searchPlaceholder': 'Search company or role',
|
|
8
8
|
'jobs.list.searchAria': 'Search postings',
|
|
@@ -11,11 +11,6 @@ export default {
|
|
|
11
11
|
'profile.completeness.title': 'Profile completeness',
|
|
12
12
|
'profile.completeness.percent': '{{pct}}%',
|
|
13
13
|
|
|
14
|
-
// export profile as a resume
|
|
15
|
-
'profile.export.md': 'MD',
|
|
16
|
-
'profile.export.html': 'HTML',
|
|
17
|
-
'profile.export.mdTitle': 'Export resume as Markdown',
|
|
18
|
-
'profile.export.htmlTitle': 'Export resume as HTML',
|
|
19
14
|
|
|
20
15
|
// basic info card
|
|
21
16
|
'profile.basic.title': 'Basic info',
|
|
@@ -9,6 +9,10 @@ export default {
|
|
|
9
9
|
'common.delete': '삭제',
|
|
10
10
|
'common.open': '열기',
|
|
11
11
|
'common.justNow': '방금 전',
|
|
12
|
+
'common.export': '내보내기',
|
|
13
|
+
'common.fmtWord': 'Word',
|
|
14
|
+
'common.fmtHtml': 'HTML',
|
|
15
|
+
'common.fmtMarkdown': 'Markdown',
|
|
12
16
|
|
|
13
17
|
'toast.copied': '클립보드에 복사했습니다.',
|
|
14
18
|
'toast.copyFailed': '복사에 실패했습니다. 직접 선택해 복사해 주세요.',
|
|
@@ -28,10 +28,6 @@ export default {
|
|
|
28
28
|
'documents.cover.primary': '대표',
|
|
29
29
|
'documents.cover.open': '열기',
|
|
30
30
|
'documents.cover.copy': '복사',
|
|
31
|
-
'documents.cover.exportMd': 'MD',
|
|
32
|
-
'documents.cover.exportHtml': 'HTML',
|
|
33
|
-
'documents.cover.exportMdTitle': 'Markdown 내보내기',
|
|
34
|
-
'documents.cover.exportHtmlTitle': 'HTML 내보내기',
|
|
35
31
|
'documents.cover.setPrimary': '대표 지정',
|
|
36
32
|
'documents.cover.delete': '삭제',
|
|
37
33
|
'documents.cover.noContent': '아직 내용이 없습니다.',
|
|
@@ -60,8 +56,6 @@ export default {
|
|
|
60
56
|
'documents.coverCreate.toast': '자기소개서를 추가했어요.',
|
|
61
57
|
|
|
62
58
|
// cover detail body
|
|
63
|
-
'documents.coverDetail.exportMd': 'MD 내보내기',
|
|
64
|
-
'documents.coverDetail.exportHtml': 'HTML 내보내기',
|
|
65
59
|
'documents.coverDetail.currentVersion': '현재 버전',
|
|
66
60
|
'documents.coverDetail.copy': '복사',
|
|
67
61
|
'documents.coverDetail.noContent': '내용이 없습니다.',
|
|
@@ -101,10 +95,6 @@ export default {
|
|
|
101
95
|
'documents.docDetail.updated': '수정 {{when}}',
|
|
102
96
|
'documents.docDetail.noContent': '내용이 없습니다.',
|
|
103
97
|
'documents.docDetail.copy': '복사',
|
|
104
|
-
'documents.docDetail.export': 'MD',
|
|
105
|
-
'documents.docDetail.exportTitle': 'Markdown 내보내기',
|
|
106
|
-
'documents.docDetail.exportHtml': 'HTML',
|
|
107
|
-
'documents.docDetail.exportHtmlTitle': 'HTML 내보내기',
|
|
108
98
|
'documents.docDetail.delete': '삭제',
|
|
109
99
|
'documents.docDetail.deleteTitle': '문서 삭제',
|
|
110
100
|
'documents.docDetail.deleteConfirm': '"{{title}}"을(를) 삭제할까요?',
|
|
@@ -28,6 +28,11 @@ export default {
|
|
|
28
28
|
'home.stat.activeApplications': '진행 중 지원',
|
|
29
29
|
'home.stat.coverLetters': '자기소개서',
|
|
30
30
|
'home.stat.interviewPending': '면접 준비',
|
|
31
|
+
// KPI tile context sub-lines (derived from real counts; omitted when zero)
|
|
32
|
+
'home.stat.jobsSub': '지원 전 {{count}}건',
|
|
33
|
+
'home.stat.subInterview': '면접 {{count}}',
|
|
34
|
+
'home.stat.subDocPassed': '서류 {{count}}',
|
|
35
|
+
'home.stat.subApplied': '지원 {{count}}',
|
|
31
36
|
|
|
32
37
|
// pipeline
|
|
33
38
|
'home.pipeline.title': '지원 파이프라인',
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export default {
|
|
3
3
|
// ---- list mode ----
|
|
4
4
|
'jobs.list.empty.title': '저장된 공고가 없어요',
|
|
5
|
-
'jobs.list.empty.body': '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요.',
|
|
5
|
+
'jobs.list.empty.body': '관심 있는 채용공고를 추가해 적합도와 진행 상태를 관리해 보세요. 연결된 AI에게 “백엔드 공고 찾아줘”처럼 말하면 공고를 찾아 여기에 저장해 줍니다.',
|
|
6
6
|
'jobs.list.addJob': '공고 추가',
|
|
7
7
|
'jobs.list.searchPlaceholder': '회사·직무 검색',
|
|
8
8
|
'jobs.list.searchAria': '공고 검색',
|
|
@@ -11,11 +11,6 @@ export default {
|
|
|
11
11
|
'profile.completeness.title': '프로필 완성도',
|
|
12
12
|
'profile.completeness.percent': '{{pct}}%',
|
|
13
13
|
|
|
14
|
-
// export profile as a resume
|
|
15
|
-
'profile.export.md': 'MD',
|
|
16
|
-
'profile.export.html': 'HTML',
|
|
17
|
-
'profile.export.mdTitle': '이력서 Markdown 내보내기',
|
|
18
|
-
'profile.export.htmlTitle': '이력서 HTML 내보내기',
|
|
19
14
|
|
|
20
15
|
// basic info card
|
|
21
16
|
'profile.basic.title': '기본 정보',
|
package/dist/public/lib.js
CHANGED
|
@@ -215,6 +215,78 @@ export function Btn(label, { icon: ic, variant = '', sm, onClick, type, disabled
|
|
|
215
215
|
export function IconBtn(name, { onClick, title, variant = 'ghost' } = {}) {
|
|
216
216
|
return el('button', { class: `btn btn--${variant} icon-btn`, onClick, title, attrs: { 'aria-label': title || name } }, icon(name));
|
|
217
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* A dropdown: one trigger button that opens a small popover of actions, so
|
|
220
|
+
* several secondary actions (e.g. export as docx/html/md) collapse into a single
|
|
221
|
+
* control instead of a row of competing buttons. The popover is position:fixed
|
|
222
|
+
* and anchored to the trigger's rect, so it never clips inside a scrolling modal
|
|
223
|
+
* body and flips above the trigger when there's no room below. Closes on outside
|
|
224
|
+
* click, Escape, scroll, resize, or item activation.
|
|
225
|
+
*
|
|
226
|
+
* `items`: array of `{ label, hint?, icon?, onClick, danger? }` (falsy entries skipped).
|
|
227
|
+
*/
|
|
228
|
+
export function Menu(label, items, { icon: ic = 'download', variant = 'ghost', sm, align = 'end' } = {}) {
|
|
229
|
+
const list = el('div', { class: 'menu__list', attrs: { role: 'menu' }, hidden: true });
|
|
230
|
+
for (const it of items) {
|
|
231
|
+
if (!it) continue;
|
|
232
|
+
list.append(el('button', {
|
|
233
|
+
class: `menu__item${it.danger ? ' menu__item--danger' : ''}`,
|
|
234
|
+
type: 'button', attrs: { role: 'menuitem' },
|
|
235
|
+
onClick: () => { close(); it.onClick?.(); },
|
|
236
|
+
}, it.icon && icon(it.icon), el('span', { class: 'menu__item-label' }, it.label), it.hint && el('span', { class: 'menu__item-hint' }, it.hint)));
|
|
237
|
+
}
|
|
238
|
+
const trigger = el('button', {
|
|
239
|
+
class: ['btn', variant && `btn--${variant}`, sm && 'btn--sm', 'menu__trigger'].filter(Boolean).join(' '),
|
|
240
|
+
type: 'button', attrs: { 'aria-haspopup': 'menu', 'aria-expanded': 'false' },
|
|
241
|
+
onClick: () => (list.hidden ? open() : close()),
|
|
242
|
+
}, ic && icon(ic), label && el('span', {}, label), icon('chevronRight', 'menu__caret'));
|
|
243
|
+
const wrap = el('div', { class: 'menu' }, trigger, list);
|
|
244
|
+
|
|
245
|
+
function place() {
|
|
246
|
+
const r = trigger.getBoundingClientRect();
|
|
247
|
+
const lw = list.offsetWidth, lh = list.offsetHeight;
|
|
248
|
+
let top = r.bottom + 6;
|
|
249
|
+
if (top + lh > window.innerHeight - 8 && r.top - 6 - lh > 8) top = r.top - 6 - lh; // flip up
|
|
250
|
+
let left = align === 'end' ? r.right - lw : r.left;
|
|
251
|
+
left = Math.max(8, Math.min(left, window.innerWidth - lw - 8));
|
|
252
|
+
list.style.top = `${Math.round(top)}px`;
|
|
253
|
+
list.style.left = `${Math.round(left)}px`;
|
|
254
|
+
}
|
|
255
|
+
function open() {
|
|
256
|
+
if (!list.hidden) return;
|
|
257
|
+
list.hidden = false; place(); // place() reads layout while visible, same tick → no flash
|
|
258
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
259
|
+
document.addEventListener('click', onDoc, true);
|
|
260
|
+
document.addEventListener('keydown', onKey, true);
|
|
261
|
+
window.addEventListener('scroll', close, true);
|
|
262
|
+
window.addEventListener('resize', close);
|
|
263
|
+
}
|
|
264
|
+
function close() {
|
|
265
|
+
if (list.hidden) return;
|
|
266
|
+
list.hidden = true;
|
|
267
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
268
|
+
document.removeEventListener('click', onDoc, true);
|
|
269
|
+
document.removeEventListener('keydown', onKey, true);
|
|
270
|
+
window.removeEventListener('scroll', close, true);
|
|
271
|
+
window.removeEventListener('resize', close);
|
|
272
|
+
}
|
|
273
|
+
function onDoc(e) { if (!wrap.contains(e.target)) close(); }
|
|
274
|
+
function onKey(e) { if (e.key === 'Escape') { close(); trigger.focus(); } }
|
|
275
|
+
return wrap;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The standard "내보내기" dropdown for a document export endpoint. `baseUrl` is a
|
|
279
|
+
* GET export route (e.g. `/api/export/profile`); each item appends `?format=…`
|
|
280
|
+
* and triggers a download. One control replaces the old docx/html/md button row.
|
|
281
|
+
*/
|
|
282
|
+
export function ExportMenu(baseUrl, { sm = true } = {}) {
|
|
283
|
+
const sep = baseUrl.includes('?') ? '&' : '?';
|
|
284
|
+
return Menu(t('common.export'), [
|
|
285
|
+
{ label: t('common.fmtWord'), hint: '.docx', onClick: () => downloadUrl(`${baseUrl}${sep}format=docx`) },
|
|
286
|
+
{ label: t('common.fmtHtml'), hint: '.html', onClick: () => downloadUrl(`${baseUrl}${sep}format=html`) },
|
|
287
|
+
{ label: t('common.fmtMarkdown'), hint: '.md', onClick: () => downloadUrl(`${baseUrl}${sep}format=md`) },
|
|
288
|
+
], { sm });
|
|
289
|
+
}
|
|
218
290
|
/**
|
|
219
291
|
* A footer button for async saves. While `action()` runs the button is disabled
|
|
220
292
|
* (so a slow local round-trip can't be double-clicked into duplicate records),
|
|
@@ -1,7 +1,7 @@
|
|
|
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,
|
|
4
|
+
el, get, put, navigate, EmptyState, Btn, ListRow, Badge,
|
|
5
5
|
Select, scoreClass, statusColor, mount, toast, toastOk, toastError,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
import { t } from '/i18n.js';
|
|
@@ -66,20 +66,24 @@ export async function render(ctx) {
|
|
|
66
66
|
const sel = Select(
|
|
67
67
|
statusOptions.map((o) => ({ ...o, selected: o.value === app.status })),
|
|
68
68
|
{
|
|
69
|
-
class: 'select select--sm
|
|
69
|
+
class: 'select select--sm',
|
|
70
70
|
title: t('applications.statusSelect.title'),
|
|
71
71
|
onClick: (e) => e.stopPropagation(),
|
|
72
72
|
onChange: (e) => { e.stopPropagation(); changeStatus(app, e.target.value, e.target); },
|
|
73
73
|
},
|
|
74
74
|
);
|
|
75
|
+
// Keep the right edge informative on every row: a right-aligned score column
|
|
76
|
+
// (a muted dash when not yet scored, so scores line up and "no score" reads
|
|
77
|
+
// clearly), then the status — a calm badge that swaps to the change-select on hover.
|
|
78
|
+
const score = app.fit_score != null
|
|
79
|
+
? el('span', { class: `app-score strong tnum ${scoreClass(app.fit_score)}` }, t('applications.row.score', { score: app.fit_score }))
|
|
80
|
+
: el('span', { class: 'app-score muted tnum' }, '—');
|
|
75
81
|
return ListRow({
|
|
76
82
|
title: job.company || '—',
|
|
77
83
|
sub: subBits.join(' · '),
|
|
78
84
|
trailing: [
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
: null,
|
|
82
|
-
sel,
|
|
85
|
+
score,
|
|
86
|
+
el('span', { class: 'app-status' }, Badge(app.status, t('status.' + app.status)), sel),
|
|
83
87
|
],
|
|
84
88
|
onClick: () => navigate(`/jobs/${app.job_id}`),
|
|
85
89
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
el, get, post, put, del, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState,
|
|
5
5
|
Field, Input, Textarea, Select, openModal, confirmDialog,
|
|
6
|
-
toastOk, toastError, copyText,
|
|
6
|
+
toastOk, toastError, copyText, fmtRelative, mount, meta, ExportMenu,
|
|
7
7
|
} from '/lib.js';
|
|
8
8
|
import { t } from '/i18n.js';
|
|
9
9
|
|
|
@@ -138,8 +138,7 @@ function CoverRow(cl, reload) {
|
|
|
138
138
|
const actions = el('div', { class: 'flex gap-2 wrap' },
|
|
139
139
|
Btn(t('documents.cover.open'), { sm: true, icon: 'external', onClick: () => openCoverDetail(cl.id, reload) }),
|
|
140
140
|
Btn(t('documents.cover.copy'), { sm: true, variant: 'ghost', icon: 'copy', onClick: () => copyText(cl.current_content || ''), disabled: !cl.current_content }),
|
|
141
|
-
|
|
142
|
-
Btn(t('documents.cover.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', title: t('documents.cover.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
|
|
141
|
+
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
143
142
|
cl.is_primary ? null : Btn(t('documents.cover.setPrimary'), { sm: true, variant: 'ghost', icon: 'check', onClick: () => setPrimaryCover(cl.id, reload) }),
|
|
144
143
|
IconBtn('trash', { variant: 'danger', title: t('documents.cover.delete'), onClick: () => removeCover(cl, reload) }),
|
|
145
144
|
);
|
|
@@ -255,8 +254,7 @@ function CoverDetailBody(cl, { refresh }) {
|
|
|
255
254
|
cl.is_primary ? Badge('accent', t('documents.cover.primary')) : null,
|
|
256
255
|
),
|
|
257
256
|
el('div', { class: 'flex gap-2 wrap' },
|
|
258
|
-
|
|
259
|
-
Btn(t('documents.coverDetail.exportHtml'), { sm: true, variant: 'ghost', icon: 'download', onClick: () => downloadUrl(`/api/export/cover-letter/${cl.id}?format=html`) }),
|
|
257
|
+
ExportMenu(`/api/export/cover-letter/${cl.id}`),
|
|
260
258
|
),
|
|
261
259
|
));
|
|
262
260
|
|
|
@@ -446,8 +444,7 @@ async function openDocDetail(id, kindLabels, m, reload) {
|
|
|
446
444
|
),
|
|
447
445
|
footer: (close) => [
|
|
448
446
|
Btn(t('documents.docDetail.copy'), { variant: 'ghost', icon: 'copy', onClick: () => copyText(doc.content || '') }),
|
|
449
|
-
|
|
450
|
-
Btn(t('documents.docDetail.exportHtml'), { variant: 'ghost', icon: 'download', title: t('documents.docDetail.exportHtmlTitle'), onClick: () => downloadUrl(`/api/export/document/${doc.id}?format=html`) }),
|
|
447
|
+
ExportMenu(`/api/export/document/${doc.id}`, { sm: false }),
|
|
451
448
|
Btn(t('documents.docDetail.delete'), { variant: 'danger', icon: 'trash', onClick: async () => {
|
|
452
449
|
const ok = await confirmDialog({ title: t('documents.docDetail.deleteTitle'), message: t('documents.docDetail.deleteConfirm', { title: doc.title }), confirmLabel: t('documents.docDetail.deleteConfirmLabel'), danger: true });
|
|
453
450
|
if (!ok) return;
|
|
@@ -45,7 +45,7 @@ export async function render(ctx) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
if (!s.onboarding.completed) wrap.append(GettingStarted(s.onboarding));
|
|
48
|
-
wrap.append(StatRow(s
|
|
48
|
+
wrap.append(StatRow(s));
|
|
49
49
|
wrap.append(Pipeline(s.status_breakdown));
|
|
50
50
|
wrap.append(el('div', { class: 'grid grid--2' },
|
|
51
51
|
el('div', { class: 'stack-4' }, ActionLane(s), RecentJobs(s.recent_jobs)),
|
|
@@ -96,15 +96,35 @@ function GettingStarted(o) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/* ----------------------------------------------- KPI summary tiles (at-a-glance) */
|
|
99
|
-
function StatTile(label, value, iconName, to) {
|
|
100
|
-
const tile = Stat({ label, value, iconName, onClick: () => navigate(to) });
|
|
99
|
+
function StatTile(label, value, iconName, to, hint) {
|
|
100
|
+
const tile = Stat({ label, value, hint, iconName, onClick: () => navigate(to) });
|
|
101
101
|
tile.classList.add('is-clickable');
|
|
102
102
|
return tile;
|
|
103
103
|
}
|
|
104
|
-
function StatRow(
|
|
104
|
+
function StatRow(s) {
|
|
105
|
+
const counts = s.counts;
|
|
106
|
+
const by = Object.fromEntries((s.status_breakdown || []).map((b) => [b.status, b.count]));
|
|
107
|
+
const c = (st) => by[st] || 0;
|
|
108
|
+
|
|
109
|
+
// Contextual sub-lines use only real counts (never invented). Tiles with no
|
|
110
|
+
// meaningful secondary signal (cover letters, interview prep) stay clean — the
|
|
111
|
+
// .stat min-height keeps the row even either way.
|
|
112
|
+
|
|
113
|
+
// 저장 공고 → how many postings still await action (not yet applied).
|
|
114
|
+
const notApplied = c('draft') + c('planned');
|
|
115
|
+
const jobsHint = notApplied > 0 ? t('home.stat.jobsSub', { count: notApplied }) : null;
|
|
116
|
+
|
|
117
|
+
// 진행 중 지원 → surface the most advanced active stages (interview → screening
|
|
118
|
+
// → applied), up to two, so the number carries a "where are they" breakdown.
|
|
119
|
+
const activeHint = [
|
|
120
|
+
c('interview') ? t('home.stat.subInterview', { count: c('interview') }) : null,
|
|
121
|
+
c('document_passed') ? t('home.stat.subDocPassed', { count: c('document_passed') }) : null,
|
|
122
|
+
c('applied') ? t('home.stat.subApplied', { count: c('applied') }) : null,
|
|
123
|
+
].filter(Boolean).slice(0, 2).join(' · ') || null;
|
|
124
|
+
|
|
105
125
|
return el('div', { class: 'grid grid--4' },
|
|
106
|
-
StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs'),
|
|
107
|
-
StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications'),
|
|
126
|
+
StatTile(t('home.stat.jobs'), counts.jobs, 'briefcase', '/jobs', jobsHint),
|
|
127
|
+
StatTile(t('home.stat.activeApplications'), counts.active_applications, 'layers', '/applications', activeHint),
|
|
108
128
|
StatTile(t('home.stat.coverLetters'), counts.cover_letters, 'file', '/documents'),
|
|
109
129
|
StatTile(t('home.stat.interviewPending'), counts.interview_pending, 'mic', '/interview'),
|
|
110
130
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
el, get, post, put, del, icon, Card, Badge, Btn, IconBtn, SubmitBtn, EmptyState, Chips,
|
|
4
4
|
Field, Input, Textarea, openModal, closeModal, confirmDialog,
|
|
5
|
-
toastOk, toastError, fmtDate, mount, linesToArray, csvToArray,
|
|
5
|
+
toastOk, toastError, fmtDate, mount, linesToArray, csvToArray, ExportMenu,
|
|
6
6
|
} from '/lib.js';
|
|
7
7
|
import { t } from '/i18n.js';
|
|
8
8
|
|
|
@@ -67,8 +67,7 @@ function CompletenessCard(o) {
|
|
|
67
67
|
title: t('profile.completeness.title'),
|
|
68
68
|
sub: t('profile.completeness.percent', { pct }),
|
|
69
69
|
actions: [
|
|
70
|
-
|
|
71
|
-
Btn(t('profile.export.html'), { sm: true, variant: 'ghost', icon: 'download', title: t('profile.export.htmlTitle'), onClick: () => downloadUrl('/api/export/profile?format=html') }),
|
|
70
|
+
ExportMenu('/api/export/profile'),
|
|
72
71
|
],
|
|
73
72
|
body: [
|
|
74
73
|
el('div', { class: 'progress', style: { marginBottom: steps.length ? '12px' : '0' } },
|
package/dist/public/styles.css
CHANGED
|
@@ -201,14 +201,16 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
201
201
|
.card.is-clickable { cursor: pointer; transition: border-color .12s, box-shadow .12s, transform .12s; }
|
|
202
202
|
.card.is-clickable:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
|
|
203
203
|
|
|
204
|
-
/* stat tiles
|
|
205
|
-
|
|
204
|
+
/* stat tiles — slim, dense KPI row (home only). A contextual hint line, when
|
|
205
|
+
present, sits at the bottom so tiles align evenly whether or not they carry one. */
|
|
206
|
+
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
|
207
|
+
padding: 12px 15px; min-height: 84px; display: flex; flex-direction: column; }
|
|
206
208
|
.stat.is-clickable { cursor: pointer; transition: border-color .12s, box-shadow .12s; }
|
|
207
209
|
.stat.is-clickable:hover { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
|
|
208
210
|
.stat__label { font-size: 13px; color: var(--text-secondary); display:flex; align-items:center; gap:7px; }
|
|
209
211
|
.stat__label svg { width: 15px; height: 15px; color: var(--text-tertiary); }
|
|
210
|
-
.stat__value { font-size:
|
|
211
|
-
.stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top:
|
|
212
|
+
.stat__value { font-size: 23px; font-weight: 700; letter-spacing: -.02em; margin-top: 3px; line-height: 1.15; }
|
|
213
|
+
.stat__hint { font-size: 12.5px; color: var(--text-tertiary); margin-top: auto; padding-top: 6px; }
|
|
212
214
|
/* borderless count strip (settings 내 데이터) — flat counts, never a card-in-card */
|
|
213
215
|
.countstrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px 24px; }
|
|
214
216
|
.countstrip__item { display: flex; flex-direction: column; gap: 2px; }
|
|
@@ -379,6 +381,24 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
379
381
|
.app-group__title { font-weight: 650; font-size: 15px; letter-spacing: -.01em; }
|
|
380
382
|
.app-group__count { margin-left: auto; color: var(--text-tertiary); font-size: 13px; }
|
|
381
383
|
|
|
384
|
+
/* applications row trailing — keep the right edge informative (never empty):
|
|
385
|
+
right-aligned score column, an optional D-day chip, then the status. The status
|
|
386
|
+
shows a calm badge at rest and swaps to the inline change-select on hover/focus
|
|
387
|
+
(display swap, not opacity, so the hidden control reserves no width). */
|
|
388
|
+
.app-score { min-width: 42px; text-align: right; font-size: 13.5px; }
|
|
389
|
+
.app-dday { color: var(--text-tertiary); }
|
|
390
|
+
.app-dday--urgent { color: var(--amber); }
|
|
391
|
+
.app-status { display: inline-flex; align-items: center; }
|
|
392
|
+
.app-status .select { display: none; }
|
|
393
|
+
.list-row:hover .app-status .badge,
|
|
394
|
+
.list-row:focus-within .app-status .badge { display: none; }
|
|
395
|
+
.list-row:hover .app-status .select,
|
|
396
|
+
.list-row:focus-within .app-status .select { display: inline-flex; }
|
|
397
|
+
@media (hover: none) {
|
|
398
|
+
.app-status .badge { display: none; }
|
|
399
|
+
.app-status .select { display: inline-flex; }
|
|
400
|
+
}
|
|
401
|
+
|
|
382
402
|
/* row-level action (e.g. status select) — hidden until the row is hovered or
|
|
383
403
|
focused, so grouped lists stay calm. Always shown where hover isn't available. */
|
|
384
404
|
.list-row .row-action { opacity: 0; pointer-events: none; transition: opacity .12s; }
|
|
@@ -467,6 +487,21 @@ h1,h2,h3,h4 { margin: 0; font-weight: 650; letter-spacing: -0.01em; }
|
|
|
467
487
|
@keyframes pop { from { transform: translate(-50%,-46%); opacity: 0; } to { transform: translate(-50%,-50%); opacity: 1; } }
|
|
468
488
|
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
|
469
489
|
|
|
490
|
+
/* Dropdown menu (Menu/ExportMenu) — fixed popover anchored to its trigger so it
|
|
491
|
+
never clips inside a scrolling modal body. Sits above the modal layer (60). */
|
|
492
|
+
.menu { position: relative; display: inline-flex; }
|
|
493
|
+
.menu__trigger .menu__caret { width: 14px; height: 14px; margin-left: -2px; opacity: .55; transform: rotate(90deg); }
|
|
494
|
+
.menu__list { position: fixed; z-index: 70; min-width: 176px; padding: 5px; display: flex; flex-direction: column; gap: 2px;
|
|
495
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-lg); animation: fade .12s; }
|
|
496
|
+
.menu__list[hidden] { display: none; }
|
|
497
|
+
.menu__item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border: 0; border-radius: var(--radius-sm);
|
|
498
|
+
background: transparent; color: var(--text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
|
|
499
|
+
.menu__item:hover, .menu__item:focus-visible { background: var(--surface-hover); outline: none; }
|
|
500
|
+
.menu__item .icon { width: 15px; height: 15px; color: var(--text-tertiary); flex: none; }
|
|
501
|
+
.menu__item-label { flex: 1; }
|
|
502
|
+
.menu__item-hint { color: var(--text-tertiary); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
|
503
|
+
.menu__item--danger { color: var(--red); }
|
|
504
|
+
|
|
470
505
|
/* ----------------------------------------------------------------- toast */
|
|
471
506
|
.toasts { position: fixed; bottom: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
|
|
472
507
|
.toast { background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: var(--radius);
|