@ansiversa/components 0.0.119 → 0.0.121

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/index.ts CHANGED
@@ -34,14 +34,17 @@ export { default as AvTable } from './src/AvTable.astro';
34
34
  export { default as AvTableToolbar } from './src/AvTableToolbar.astro';
35
35
  export { default as AvTablePagination } from './src/AvTablePagination.astro';
36
36
  export { default as QuizSummary } from './src/Summary/QuizSummary.astro';
37
+ export { default as FlashNoteSummary } from './src/Summary/FlashNoteSummary.astro';
38
+ export { default as ResumeBuilderSummary } from './src/Summary/ResumeBuilderSummary.astro';
39
+ export { default as PortfolioCreatorSummary } from './src/Summary/PortfolioCreatorSummary.astro';
37
40
  export { default as ResumeBuilderShell } from './src/resume-templates/ResumeBuilderShell.astro';
38
41
  export { default as ResumeTemplateClassic } from './src/resume-templates/ResumeTemplateClassic.astro';
39
42
  export { default as ResumeTemplateModernTwoTone } from './src/resume-templates/ResumeTemplateModernTwoTone.astro';
40
43
  export { default as ResumeTemplateMinimal } from './src/resume-templates/ResumeTemplateMinimal.astro';
41
44
  export { default as ResumeTemplateExecutiveTimeline } from './src/resume-templates/ResumeTemplateExecutiveTimeline.astro';
42
- export type { ResumeData, ResumeTemplateType } from './resume-templates/typescript-schema';
43
- export { formatDateRange } from './resume-templates/typescript-schema';
44
- export { resumeData } from './resume-templates/resumeData';
45
+ export type { ResumeData, ResumeTemplateType } from './src/resume-templates/typescript-schema';
46
+ export { formatDateRange } from './src/resume-templates/typescript-schema';
47
+ export { resumeData } from './src/resume-templates/resumeData';
45
48
 
46
49
  export * from "./src/alpine";
47
50
  export * from "./src/Summary/types";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansiversa/components",
3
- "version": "0.0.119",
3
+ "version": "0.0.121",
4
4
  "description": "Shared UI components and layouts for the Ansiversa ecosystem",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,7 +10,6 @@
10
10
  },
11
11
  "files": [
12
12
  "src",
13
- "resume-templates",
14
13
  "index.ts"
15
14
  ],
16
15
  "keywords": [
@@ -0,0 +1,69 @@
1
+ ---
2
+ import { AvButton, AvCard } from "@ansiversa/components";
3
+ import type { FlashnoteDashboardSummaryV1 } from "./types";
4
+ import { buildAppUrl } from "../utils/appUrls";
5
+
6
+ interface Props {
7
+ summary: FlashnoteDashboardSummaryV1;
8
+ }
9
+
10
+ const { summary } = Astro.props as Props;
11
+
12
+ const formatDateTime = (value: string | null, fallback = "Not yet") => {
13
+ if (!value) return fallback;
14
+ const parsed = new Date(value);
15
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed.toLocaleString();
16
+ };
17
+
18
+ const flashnoteBaseUrl = buildAppUrl(summary.appId);
19
+ const decksUrl = buildAppUrl(summary.appId, "/decks");
20
+ ---
21
+
22
+ <section class="av-auth-stack-lg">
23
+ <div class="av-form-row">
24
+ <div class="av-auth-stack-xxs">
25
+ <div class="av-kicker">
26
+ <span>FlashNote</span>
27
+ </div>
28
+ <h2 class="av-card-heading">FlashNote summary</h2>
29
+ <p class="av-text-soft">Last study: {formatDateTime(summary.lastStudyAt)}</p>
30
+ </div>
31
+ <div class="av-row-wrap">
32
+ <AvButton href={flashnoteBaseUrl} size="sm">Open FlashNote</AvButton>
33
+ <AvButton href={decksUrl} size="sm" variant="ghost">View decks</AvButton>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="av-grid-auto av-grid-auto--260">
38
+ <AvCard variant="soft" className="av-card--fullheight">
39
+ <div class="av-auth-stack-xxs">
40
+ <p class="av-card-heading">Decks</p>
41
+ <h3 class="av-app-card-title">{summary.decksCount}</h3>
42
+ <p class="av-text-soft">Total decks</p>
43
+ </div>
44
+ </AvCard>
45
+ <AvCard variant="soft" className="av-card--fullheight">
46
+ <div class="av-auth-stack-xxs">
47
+ <p class="av-card-heading">Cards</p>
48
+ <h3 class="av-app-card-title">{summary.cardsCount}</h3>
49
+ <p class="av-text-soft">Across all decks</p>
50
+ </div>
51
+ </AvCard>
52
+ <AvCard variant="soft" className="av-card--fullheight">
53
+ <div class="av-auth-stack-xxs">
54
+ <p class="av-card-heading">Reviews today</p>
55
+ <h3 class="av-app-card-title">{summary.reviewsToday}</h3>
56
+ <p class="av-text-soft">Scheduled reviews</p>
57
+ </div>
58
+ </AvCard>
59
+ <AvCard variant="soft" className="av-card--fullheight">
60
+ <div class="av-auth-stack-xxs">
61
+ <p class="av-card-heading">Last import</p>
62
+ <h3 class="av-app-card-title">
63
+ {formatDateTime(summary.lastImportedFromQuizAt, "No quiz imports")}
64
+ </h3>
65
+ <p class="av-text-soft">Quiz cards</p>
66
+ </div>
67
+ </AvCard>
68
+ </div>
69
+ </section>
@@ -0,0 +1,71 @@
1
+ ---
2
+ import { AvButton, AvCard } from "@ansiversa/components";
3
+ import type { PortfolioDashboardSummaryV1 } from "./types";
4
+ import { buildAppUrl } from "../utils/appUrls";
5
+
6
+ interface Props {
7
+ summary: PortfolioDashboardSummaryV1;
8
+ }
9
+
10
+ const { summary } = Astro.props as Props;
11
+
12
+ const formatDateTime = (value: string | null, fallback = "Not yet") => {
13
+ if (!value) return fallback;
14
+ const parsed = new Date(value);
15
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed.toLocaleString();
16
+ };
17
+
18
+ const portfolioBaseUrl = buildAppUrl(summary.appId);
19
+ const portfoliosUrl = buildAppUrl(summary.appId, "/app/portfolios");
20
+ const completionLabel =
21
+ typeof summary.completionHint === "number" ? `${summary.completionHint}%` : "—";
22
+ ---
23
+
24
+ <section class="av-auth-stack-lg">
25
+ <div class="av-form-row">
26
+ <div class="av-auth-stack-xxs">
27
+ <div class="av-kicker">
28
+ <span>Portfolio Creator</span>
29
+ </div>
30
+ <h2 class="av-card-heading">Portfolio summary</h2>
31
+ <p class="av-text-soft">Last updated: {formatDateTime(summary.lastUpdatedAt)}</p>
32
+ </div>
33
+ <div class="av-row-wrap">
34
+ <AvButton href={portfolioBaseUrl} size="sm">Open Portfolio Creator</AvButton>
35
+ <AvButton href={portfoliosUrl} size="sm" variant="ghost">View portfolios</AvButton>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="av-grid-auto av-grid-auto--260">
40
+ <AvCard variant="soft" className="av-card--fullheight">
41
+ <div class="av-auth-stack-xxs">
42
+ <p class="av-card-heading">Portfolios</p>
43
+ <h3 class="av-app-card-title">{summary.totalPortfolios}</h3>
44
+ <p class="av-text-soft">Total portfolios</p>
45
+ </div>
46
+ </AvCard>
47
+ <AvCard variant="soft" className="av-card--fullheight">
48
+ <div class="av-auth-stack-xxs">
49
+ <p class="av-card-heading">Published</p>
50
+ <h3 class="av-app-card-title">{summary.publishedCount}</h3>
51
+ <p class="av-text-soft">Live portfolios</p>
52
+ </div>
53
+ </AvCard>
54
+ <AvCard variant="soft" className="av-card--fullheight">
55
+ <div class="av-auth-stack-xxs">
56
+ <p class="av-card-heading">Completion</p>
57
+ <h3 class="av-app-card-title">{completionLabel}</h3>
58
+ <p class="av-text-soft">Publish ratio</p>
59
+ </div>
60
+ </AvCard>
61
+ <AvCard variant="soft" className="av-card--fullheight">
62
+ <div class="av-auth-stack-xxs">
63
+ <p class="av-card-heading">Visibility</p>
64
+ <h3 class="av-app-card-title">
65
+ {summary.visibilityBreakdown.public} / {summary.visibilityBreakdown.unlisted} / {summary.visibilityBreakdown.private}
66
+ </h3>
67
+ <p class="av-text-soft">Public / Unlisted / Private</p>
68
+ </div>
69
+ </AvCard>
70
+ </div>
71
+ </section>
@@ -0,0 +1,70 @@
1
+ ---
2
+ import { AvButton, AvCard } from "@ansiversa/components";
3
+ import type { ResumeBuilderDashboardSummaryV1 } from "./types";
4
+ import { buildAppUrl } from "../utils/appUrls";
5
+
6
+ interface Props {
7
+ summary: ResumeBuilderDashboardSummaryV1;
8
+ }
9
+
10
+ const { summary } = Astro.props as Props;
11
+
12
+ const formatDateTime = (value: string | null, fallback = "Not yet") => {
13
+ if (!value) return fallback;
14
+ const parsed = new Date(value);
15
+ return Number.isNaN(parsed.getTime()) ? fallback : parsed.toLocaleString();
16
+ };
17
+
18
+ const resumeBaseUrl = buildAppUrl(summary.appId);
19
+ const resumesUrl = buildAppUrl(summary.appId, "/app/resumes");
20
+ const templatesLabel = summary.templatesUsed.length > 0 ? summary.templatesUsed.join(", ") : "No templates yet";
21
+ const completionLabel =
22
+ typeof summary.completionHint === "number" ? `${summary.completionHint}%` : "—";
23
+ ---
24
+
25
+ <section class="av-auth-stack-lg">
26
+ <div class="av-form-row">
27
+ <div class="av-auth-stack-xxs">
28
+ <div class="av-kicker">
29
+ <span>Resume Builder</span>
30
+ </div>
31
+ <h2 class="av-card-heading">Resume builder summary</h2>
32
+ <p class="av-text-soft">Last updated: {formatDateTime(summary.lastUpdatedAt)}</p>
33
+ </div>
34
+ <div class="av-row-wrap">
35
+ <AvButton href={resumeBaseUrl} size="sm">Open Resume Builder</AvButton>
36
+ <AvButton href={resumesUrl} size="sm" variant="ghost">View resumes</AvButton>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="av-grid-auto av-grid-auto--260">
41
+ <AvCard variant="soft" className="av-card--fullheight">
42
+ <div class="av-auth-stack-xxs">
43
+ <p class="av-card-heading">Resumes</p>
44
+ <h3 class="av-app-card-title">{summary.totalResumes}</h3>
45
+ <p class="av-text-soft">Total resumes</p>
46
+ </div>
47
+ </AvCard>
48
+ <AvCard variant="soft" className="av-card--fullheight">
49
+ <div class="av-auth-stack-xxs">
50
+ <p class="av-card-heading">Completion</p>
51
+ <h3 class="av-app-card-title">{completionLabel}</h3>
52
+ <p class="av-text-soft">Enabled sections</p>
53
+ </div>
54
+ </AvCard>
55
+ <AvCard variant="soft" className="av-card--fullheight">
56
+ <div class="av-auth-stack-xxs">
57
+ <p class="av-card-heading">Default resume</p>
58
+ <h3 class="av-app-card-title">{summary.defaultResumeTitle ?? "Not set"}</h3>
59
+ <p class="av-text-soft">Primary resume</p>
60
+ </div>
61
+ </AvCard>
62
+ <AvCard variant="soft" className="av-card--fullheight">
63
+ <div class="av-auth-stack-xxs">
64
+ <p class="av-card-heading">Templates used</p>
65
+ <h3 class="av-app-card-title">{summary.templatesUsed.length}</h3>
66
+ <p class="av-text-soft">{templatesLabel}</p>
67
+ </div>
68
+ </AvCard>
69
+ </div>
70
+ </section>
@@ -28,3 +28,39 @@ export type QuizDashboardSummaryV1 = {
28
28
  avgScorePct: number;
29
29
  }>;
30
30
  };
31
+
32
+ export type FlashnoteDashboardSummaryV1 = {
33
+ appId: "flashnote";
34
+ version: 1;
35
+ updatedAt: string;
36
+ decksCount: number;
37
+ cardsCount: number;
38
+ reviewsToday: number;
39
+ lastStudyAt: string | null;
40
+ lastImportedFromQuizAt: string | null;
41
+ };
42
+
43
+ export type ResumeBuilderDashboardSummaryV1 = {
44
+ appId: "resume-builder";
45
+ version: 1;
46
+ totalResumes: number;
47
+ defaultResumeTitle: string | null;
48
+ lastUpdatedAt: string;
49
+ templatesUsed: string[];
50
+ sectionsEnabledCount?: number;
51
+ completionHint?: number;
52
+ };
53
+
54
+ export type PortfolioDashboardSummaryV1 = {
55
+ appId: "portfolio-creator";
56
+ version: 1;
57
+ totalPortfolios: number;
58
+ publishedCount: number;
59
+ lastUpdatedAt: string;
60
+ visibilityBreakdown: {
61
+ public: number;
62
+ unlisted: number;
63
+ private: number;
64
+ };
65
+ completionHint?: number;
66
+ };
@@ -1,5 +1,5 @@
1
1
  ---
2
- import type { ResumeData, ResumeTemplateType } from "../../resume-templates/typescript-schema";
2
+ import type { ResumeData, ResumeTemplateType } from "./typescript-schema";
3
3
  import ResumeTemplateClassic from "./ResumeTemplateClassic.astro";
4
4
  import ResumeTemplateExecutiveTimeline from "./ResumeTemplateExecutiveTimeline.astro";
5
5
  import ResumeTemplateMinimal from "./ResumeTemplateMinimal.astro";
@@ -1,6 +1,6 @@
1
1
  ---
2
- import type { ResumeData } from "../../resume-templates/typescript-schema";
3
- import { formatDateRange } from "../../resume-templates/typescript-schema";
2
+ import type { ResumeData } from "./typescript-schema";
3
+ import { formatDateRange } from "./typescript-schema";
4
4
 
5
5
  interface Props {
6
6
  data: ResumeData;
@@ -47,32 +47,10 @@ const declaration = data.declaration ?? {};
47
47
  const hasDeclaration = Boolean(declaration.text || declaration.place || declaration.name);
48
48
  ---
49
49
 
50
- <style>
51
- @media print {
52
- .resume-template a {
53
- text-decoration: none;
54
- color: inherit;
55
- }
56
-
57
- .resume-template .classic-grid {
58
- display: grid;
59
- grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
60
- gap: 2.5rem;
61
- }
62
-
63
- .resume-template .classic-divider {
64
- background: none !important;
65
- border-top: 1px solid #e2e8f0 !important;
66
- -webkit-print-color-adjust: exact;
67
- print-color-adjust: exact;
68
- }
69
- }
70
- </style>
71
-
72
50
  <div class="resume-template bg-slate-100 text-slate-900 print:bg-white">
73
51
  <main class="mx-auto max-w-4xl p-4 sm:p-6">
74
52
  <section class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-10 print:shadow-none print:ring-0">
75
- <header class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
53
+ <header class="av-print-avoid-break flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
76
54
  <div>
77
55
  <h1 class="text-3xl font-bold tracking-tight">{basics.fullName}</h1>
78
56
  <p class="mt-1 text-base font-medium text-slate-700">{basics.headline}</p>
@@ -87,7 +65,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
87
65
  {contactItems.map((item) =>
88
66
  item.href ? (
89
67
  <a
90
- class="text-slate-900 underline underline-offset-2 hover:text-slate-700"
68
+ class="text-slate-900 underline underline-offset-2 hover:text-slate-700 print:no-underline"
91
69
  href={item.href}
92
70
  >
93
71
  {item.label}
@@ -101,10 +79,10 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
101
79
  )}
102
80
  </header>
103
81
 
104
- <div class="classic-divider my-8 h-0 border-t border-slate-200"></div>
82
+ <div class="my-8 h-0 border-t border-slate-200"></div>
105
83
 
106
- <div class="classic-grid grid gap-10 lg:grid-cols-3">
107
- <div class="space-y-10 lg:col-span-2">
84
+ <div class="grid gap-10 lg:grid-cols-3 print:grid-cols-1">
85
+ <div class="space-y-10 lg:col-span-2 print:col-span-1">
108
86
  {experienceItems.length > 0 && (
109
87
  <section>
110
88
  <h2 class="text-sm font-bold tracking-widest text-slate-900">EXPERIENCE</h2>
@@ -113,7 +91,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
113
91
  const range = formatDateRange(item);
114
92
  const meta = item.location ? `${range} · ${item.location}` : range;
115
93
  return (
116
- <article>
94
+ <article class="av-print-avoid-break">
117
95
  <div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
118
96
  <div>
119
97
  <h3 class="text-base font-semibold">{item.role}</h3>
@@ -147,7 +125,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
147
125
  ? formatDateRange({ start: project.start, end: project.end, present: project.present })
148
126
  : undefined;
149
127
  return (
150
- <article>
128
+ <article class="av-print-avoid-break">
151
129
  <div class="flex items-baseline justify-between gap-3">
152
130
  {project.link ? (
153
131
  <a class="text-base font-semibold underline underline-offset-4" href={project.link}>
@@ -191,7 +169,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
191
169
  {educationItems.map((edu) => {
192
170
  const range = edu.start ? formatDateRange({ start: edu.start, end: edu.end }) : undefined;
193
171
  return (
194
- <div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
172
+ <div class="av-print-avoid-break flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between">
195
173
  <div>
196
174
  <h3 class="text-base font-semibold">{edu.degree}</h3>
197
175
  <p class="text-sm text-slate-700">{edu.school}</p>
@@ -211,7 +189,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
211
189
 
212
190
  <aside class="space-y-10">
213
191
  {skills.length > 0 && (
214
- <section>
192
+ <section class="av-print-avoid-break">
215
193
  <h2 class="text-sm font-bold tracking-widest text-slate-900">SKILLS</h2>
216
194
  <div class="mt-4 flex flex-wrap gap-2">
217
195
  {skills.map((skill) => (
@@ -224,13 +202,13 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
224
202
  )}
225
203
 
226
204
  {certifications.length > 0 && (
227
- <section>
205
+ <section class="av-print-avoid-break">
228
206
  <h2 class="text-sm font-bold tracking-widest text-slate-900">CERTIFICATIONS</h2>
229
207
  <ul class="mt-4 space-y-3 text-sm text-slate-700">
230
208
  {certifications.map((cert) => {
231
209
  const meta = [cert.issuer, cert.year].filter(Boolean).join(" · ");
232
210
  return (
233
- <li class="space-y-1">
211
+ <li class="av-print-avoid-break space-y-1">
234
212
  {cert.link ? (
235
213
  <a class="font-semibold text-slate-900 underline underline-offset-2" href={cert.link}>
236
214
  {cert.name}
@@ -247,7 +225,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
247
225
  )}
248
226
 
249
227
  {highlights.length > 0 && (
250
- <section>
228
+ <section class="av-print-avoid-break">
251
229
  <h2 class="text-sm font-bold tracking-widest text-slate-900">HIGHLIGHTS</h2>
252
230
  <ul class="mt-4 list-disc space-y-2 pl-5 text-sm leading-6 text-slate-700">
253
231
  {highlights.map((item) => (
@@ -258,7 +236,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
258
236
  )}
259
237
 
260
238
  {languages.length > 0 && (
261
- <section>
239
+ <section class="av-print-avoid-break">
262
240
  <h2 class="text-sm font-bold tracking-widest text-slate-900">LANGUAGES</h2>
263
241
  <ul class="mt-4 space-y-2 text-sm text-slate-700">
264
242
  {languages.map((language) => (
@@ -278,7 +256,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
278
256
  {awards.map((award) => {
279
257
  const meta = [award.by, award.year].filter(Boolean).join(" · ");
280
258
  return (
281
- <li class="space-y-1">
259
+ <li class="av-print-avoid-break space-y-1">
282
260
  <div class="font-semibold text-slate-900">{award.title}</div>
283
261
  {meta && <div class="text-xs text-slate-600">{meta}</div>}
284
262
  {award.summary && <div class="text-sm text-slate-700">{award.summary}</div>}
@@ -293,8 +271,8 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
293
271
 
294
272
  {hasDeclaration && (
295
273
  <>
296
- <div class="classic-divider my-8 h-0 border-t border-slate-200"></div>
297
- <section class="space-y-4">
274
+ <div class="my-8 h-0 border-t border-slate-200"></div>
275
+ <section class="av-print-avoid-break space-y-4">
298
276
  <h2 class="text-sm font-bold tracking-widest text-slate-900">DECLARATION</h2>
299
277
  {declaration.text && (
300
278
  <p class="text-sm leading-6 text-slate-700">{declaration.text}</p>
@@ -1,6 +1,6 @@
1
1
  ---
2
- import type { ResumeData } from "../../resume-templates/typescript-schema";
3
- import { formatDateRange } from "../../resume-templates/typescript-schema";
2
+ import type { ResumeData } from "./typescript-schema";
3
+ import { formatDateRange } from "./typescript-schema";
4
4
 
5
5
  interface Props {
6
6
  data: ResumeData;
@@ -25,6 +25,13 @@ for (const link of basics.links ?? []) {
25
25
  contactChips.push(link.label ?? formatUrlLabel(link.url));
26
26
  }
27
27
 
28
+ const normalizeSkillLabel = (value?: string) =>
29
+ (value ?? "")
30
+ .replace(/\s+/g, " ")
31
+ .replace(/[|•]+/g, " · ")
32
+ .replace(/(\d)([A-Za-z])/g, "$1 $2")
33
+ .trim();
34
+
28
35
  const experienceItems = data.experience ?? [];
29
36
  const projects = data.projects ?? [];
30
37
  const skills = data.skills ?? [];
@@ -34,22 +41,10 @@ const declaration = data.declaration ?? {};
34
41
  const hasDeclaration = Boolean(declaration.text || declaration.place || declaration.name);
35
42
  ---
36
43
 
37
- <style>
38
- @media print {
39
- .resume-template a {
40
- text-decoration: none;
41
- color: inherit;
42
- }
43
- .resume-template {
44
- background: white;
45
- }
46
- }
47
- </style>
48
-
49
44
  <div class="resume-template bg-slate-100 text-slate-900 print:bg-white">
50
45
  <main class="mx-auto max-w-4xl p-4 sm:p-6">
51
46
  <section class="rounded-2xl bg-white p-7 shadow-sm ring-1 ring-slate-200 sm:p-10 print:shadow-none print:ring-0">
52
- <header class="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
47
+ <header class="av-print-avoid-break flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
53
48
  <div>
54
49
  <h1 class="text-3xl font-bold tracking-tight">{basics.fullName}</h1>
55
50
  <p class="mt-1 text-sm font-medium text-slate-700">{basics.headline}</p>
@@ -63,7 +58,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
63
58
  </div>
64
59
 
65
60
  {basics.summary && (
66
- <div class="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700 sm:max-w-sm">
61
+ <div class="av-print-avoid-break rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700 sm:max-w-sm">
67
62
  <div class="text-xs font-semibold tracking-widest text-slate-500">SUMMARY</div>
68
63
  <p class="mt-2 leading-6">{basics.summary}</p>
69
64
  </div>
@@ -72,14 +67,14 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
72
67
 
73
68
  <div class="my-8 h-px bg-slate-200"></div>
74
69
 
75
- <div class="grid gap-10 lg:grid-cols-3">
70
+ <div class="grid gap-10 lg:grid-cols-3 print:grid-cols-1">
76
71
  <div class="lg:col-span-2">
77
72
  {experienceItems.length > 0 && (
78
73
  <>
79
74
  <h2 class="text-sm font-bold tracking-widest text-slate-900">EXPERIENCE</h2>
80
75
  <div class="mt-6 space-y-8">
81
76
  {experienceItems.map((item) => (
82
- <div class="relative pl-6">
77
+ <div class="av-print-avoid-break relative pl-6">
83
78
  <div class="absolute left-0 top-1.5 h-full w-px bg-slate-200"></div>
84
79
  <div class="absolute left-[-5px] top-1.5 h-3 w-3 rounded-full bg-slate-900"></div>
85
80
 
@@ -112,7 +107,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
112
107
  <h2 class="text-sm font-bold tracking-widest text-slate-900">PROJECTS</h2>
113
108
  <div class="mt-5 space-y-4">
114
109
  {projects.map((project) => (
115
- <div class="rounded-xl border border-slate-200 p-4">
110
+ <div class="av-print-avoid-break rounded-xl border border-slate-200 p-4">
116
111
  <div class="flex items-baseline justify-between">
117
112
  {project.link ? (
118
113
  <a class="font-semibold underline underline-offset-4" href={project.link}>
@@ -143,22 +138,22 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
143
138
 
144
139
  <aside class="space-y-8">
145
140
  {skills.length > 0 && (
146
- <section class="rounded-xl border border-slate-200 p-4">
141
+ <section class="av-print-avoid-break rounded-xl border border-slate-200 p-4">
147
142
  <h2 class="text-xs font-bold tracking-widest text-slate-500">SKILLS</h2>
148
- <div class="mt-3 flex flex-wrap gap-2">
143
+ <ul class="mt-3 list-disc space-y-1 pl-5 text-sm text-slate-700">
149
144
  {skills.map((skill) => (
150
- <span class="rounded-full bg-slate-100 px-3 py-1 text-xs">{skill.name}</span>
145
+ <li>{normalizeSkillLabel(skill.name)}</li>
151
146
  ))}
152
- </div>
147
+ </ul>
153
148
  </section>
154
149
  )}
155
150
 
156
151
  {education.length > 0 && (
157
- <section class="rounded-xl border border-slate-200 p-4">
152
+ <section class="av-print-avoid-break rounded-xl border border-slate-200 p-4">
158
153
  <h2 class="text-xs font-bold tracking-widest text-slate-500">EDUCATION</h2>
159
154
  <div class="mt-3 space-y-4 text-sm text-slate-700">
160
155
  {education.map((edu) => (
161
- <div>
156
+ <div class="av-print-avoid-break rounded-lg border border-slate-100 p-3">
162
157
  <div class="font-semibold">{edu.degree}</div>
163
158
  <div class="text-slate-600">{edu.school}</div>
164
159
  {edu.location && <div class="text-slate-500">{edu.location}</div>}
@@ -174,7 +169,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
174
169
  )}
175
170
 
176
171
  {highlights.length > 0 && (
177
- <section class="rounded-xl border border-slate-200 p-4">
172
+ <section class="av-print-avoid-break rounded-xl border border-slate-200 p-4">
178
173
  <h2 class="text-xs font-bold tracking-widest text-slate-500">HIGHLIGHTS</h2>
179
174
  <ul class="mt-3 space-y-2 text-sm leading-6 text-slate-700">
180
175
  {highlights.map((highlight) => (
@@ -189,7 +184,7 @@ const hasDeclaration = Boolean(declaration.text || declaration.place || declarat
189
184
  {hasDeclaration && (
190
185
  <>
191
186
  <div class="my-8 h-px bg-slate-200"></div>
192
- <section class="space-y-4">
187
+ <section class="av-print-avoid-break space-y-4">
193
188
  <h2 class="text-sm font-bold tracking-widest text-slate-900">DECLARATION</h2>
194
189
  {declaration.text && (
195
190
  <p class="text-sm leading-6 text-slate-700">{declaration.text}</p>
@@ -1,6 +1,6 @@
1
1
  ---
2
- import type { ResumeData } from "../../resume-templates/typescript-schema";
3
- import { formatDateRange } from "../../resume-templates/typescript-schema";
2
+ import type { ResumeData } from "./typescript-schema";
3
+ import { formatDateRange } from "./typescript-schema";
4
4
 
5
5
  interface Props {
6
6
  data: ResumeData;