create-foliokit 0.0.0-stage → 1.0.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -2
  3. package/bin/index.js +6 -0
  4. package/package.json +50 -4
  5. package/src/cli.js +111 -0
  6. package/src/generator.js +133 -0
  7. package/src/prompts.js +44 -0
  8. package/src/utils/errors.js +7 -0
  9. package/src/utils/paths.js +12 -0
  10. package/src/utils/progress.js +5 -0
  11. package/src/utils/project-name.js +30 -0
  12. package/templates/default/.gitignore +20 -0
  13. package/templates/default/data.ts +227 -0
  14. package/templates/default/index.html +23 -0
  15. package/templates/default/package-lock.json +1280 -0
  16. package/templates/default/package.json +28 -0
  17. package/templates/default/public/avatar-placeholder.svg +10 -0
  18. package/templates/default/public/footer-heart.png +0 -0
  19. package/templates/default/public/love-always-wins.png +0 -0
  20. package/templates/default/public/project-placeholder.svg +10 -0
  21. package/templates/default/public/robots.txt +2 -0
  22. package/templates/default/public/stars-foreground-landscape.svg +202 -0
  23. package/templates/default/public/stars-foreground-portrait.svg +202 -0
  24. package/templates/default/public/stars-landscape.svg +322 -0
  25. package/templates/default/public/stars-portrait.svg +322 -0
  26. package/templates/default/scripts/generate-seo.mjs +34 -0
  27. package/templates/default/src/App.tsx +552 -0
  28. package/templates/default/src/BrandIcon.tsx +36 -0
  29. package/templates/default/src/ProjectMagazineGallery.css +90 -0
  30. package/templates/default/src/ProjectMagazineGallery.tsx +210 -0
  31. package/templates/default/src/main.tsx +10 -0
  32. package/templates/default/src/styles.css +536 -0
  33. package/templates/default/src/utils.ts +2 -0
  34. package/templates/default/src/vite-env.d.ts +1 -0
  35. package/templates/default/tests/portfolio.test.js +96 -0
  36. package/templates/default/tsconfig.app.json +21 -0
  37. package/templates/default/tsconfig.json +7 -0
  38. package/templates/default/tsconfig.node.json +16 -0
  39. package/templates/default/vite.config.ts +38 -0
@@ -0,0 +1,536 @@
1
+ :root {
2
+ font-family: 'Inter', sans-serif;
3
+ color-scheme: dark;
4
+ color: #ffffffde;
5
+ background: #000000;
6
+ font-synthesis: none;
7
+ text-rendering: optimizeLegibility;
8
+ font-optical-sizing: auto;
9
+ scroll-behavior: smooth;
10
+ scroll-padding-top: 58px;
11
+ --obsidian: #000000;
12
+ --surface: #030303;
13
+ --card: #050505;
14
+ --graphite: #070707;
15
+ --elevated: #090909;
16
+ --secondary: #0b0b0b;
17
+ --lime: #e5cf9c;
18
+ --pink: #ec4899;
19
+ --violet: #a78bfa;
20
+ --warm-white: #ffffffde;
21
+ --muted: #99a1af;
22
+ --text-muted: #707a8b;
23
+ --border: #282828;
24
+ --stitch: #1c1c1c;
25
+ --frame-dot: #242424;
26
+ --frame-dot-hover: #3a352b;
27
+ --content-width: 880px;
28
+ --page-gutter: 16px;
29
+ --section-space: 20px;
30
+ --section-space-end: 22px;
31
+ --panel-inset: 12px;
32
+ --text-2xs: 9px;
33
+ --text-xs: 10px;
34
+ --text-sm: 11px;
35
+ --text-base: 12px;
36
+ --heading-sm: 20px;
37
+ --heading-md: 24px;
38
+ --interaction-duration: 220ms;
39
+ --interaction-ease: cubic-bezier(.2, .7, .2, 1);
40
+ --heading-font: 'Space Grotesk', sans-serif;
41
+ --body-font: 'Inter', sans-serif;
42
+ --mono-font: 'JetBrains Mono', monospace;
43
+ }
44
+
45
+ * { box-sizing: border-box; }
46
+ html, body { overflow-x: clip; }
47
+ body { min-width: 320px; margin: 0; color: var(--warm-white); background: #000000; isolation: isolate; }
48
+ body::before {
49
+ position: fixed;
50
+ z-index: -1;
51
+ inset: 0;
52
+ pointer-events: none;
53
+ background: url('/stars-landscape.svg') center / cover no-repeat;
54
+ opacity: .9;
55
+ content: '';
56
+ }
57
+ body::after {
58
+ --near-stars: url('/stars-foreground-landscape.svg');
59
+ position: fixed;
60
+ z-index: -1;
61
+ inset: -2%;
62
+ pointer-events: none;
63
+ background-image:
64
+ radial-gradient(ellipse at 16% 26%, rgba(255, 255, 255, .004), transparent 43%),
65
+ radial-gradient(ellipse at 82% 70%, rgba(255, 255, 255, .003), transparent 44%),
66
+ radial-gradient(ellipse at 50% 100%, rgba(255, 255, 255, .002), transparent 48%),
67
+ var(--near-stars);
68
+ background-position: center;
69
+ background-size: 100% 100%, 100% 100%, 100% 100%, cover;
70
+ background-repeat: no-repeat;
71
+ opacity: .96;
72
+ mix-blend-mode: screen;
73
+ transform-origin: center;
74
+ animation: galaxy-depth 36s ease-in-out infinite alternate;
75
+ will-change: transform;
76
+ content: '';
77
+ }
78
+ @keyframes galaxy-depth {
79
+ from { transform: translate3d(-.45%, .3%, 0) scale(1); }
80
+ to { transform: translate3d(.35%, -.25%, 0) scale(1.025); }
81
+ }
82
+ a { color: inherit; text-decoration: none; transition: color var(--interaction-duration) var(--interaction-ease); }
83
+ a:hover, a:focus-visible { color: var(--lime); }
84
+ button, a { -webkit-tap-highlight-color: transparent; }
85
+ button:focus-visible, a:focus-visible { outline: 2px solid var(--lime); outline-offset: 3px; }
86
+ h1, h2, h3, p { margin-top: 0; }
87
+ h1, h2, h3 { font-family: var(--heading-font); }
88
+ .mono { font-family: var(--mono-font); }
89
+ .section-wrap { width: min(var(--content-width), calc(100% - var(--page-gutter) - var(--page-gutter))); margin-inline: auto; }
90
+ .dotted-frame { position: relative; }
91
+ .dotted-frame::before { position: absolute; z-index: 0; inset: 0; pointer-events: none; background-image: repeating-linear-gradient(90deg, var(--frame-dot) 0 1px, transparent 1px 5px), repeating-linear-gradient(90deg, var(--frame-dot) 0 1px, transparent 1px 5px), repeating-linear-gradient(0deg, var(--frame-dot) 0 1px, transparent 1px 5px), repeating-linear-gradient(0deg, var(--frame-dot) 0 1px, transparent 1px 5px); background-repeat: no-repeat; background-position: center top, center bottom, left center, right center; background-size: calc(100% - 16px) 1px, calc(100% - 16px) 1px, 1px calc(100% - 16px), 1px calc(100% - 16px); content: ''; transition: background-image .18s ease; }
92
+ .dotted-frame > * { position: relative; z-index: 1; }
93
+
94
+ .site-header {
95
+ position: sticky;
96
+ top: 0;
97
+ z-index: 10;
98
+ display: flex;
99
+ min-height: 48px;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ padding-inline: max(var(--page-gutter), calc((100vw - var(--content-width)) / 2));
103
+ border-bottom: 1px dotted var(--frame-dot);
104
+ background: color-mix(in srgb, var(--obsidian) 94%, transparent);
105
+ backdrop-filter: blur(12px);
106
+ }
107
+ @supports (animation-timeline: scroll()) {
108
+ .site-header {
109
+ animation: header-scroll-transparency linear both;
110
+ animation-timeline: scroll(root block);
111
+ animation-range: 0px 120px;
112
+ }
113
+ }
114
+ @keyframes header-scroll-transparency {
115
+ from { background-color: color-mix(in srgb, var(--obsidian) 94%, transparent); }
116
+ to { background-color: color-mix(in srgb, var(--obsidian) 48%, transparent); }
117
+ }
118
+ .wordmark { color: var(--warm-white); font: 700 14px/1 var(--heading-font); letter-spacing: -.035em; transition: color var(--interaction-duration) var(--interaction-ease); }
119
+ .wordmark:hover, .wordmark:focus-visible { color: var(--lime); }
120
+ .wordmark-dot { color: var(--lime); }
121
+ .main-nav { display: flex; align-items: center; gap: 16px; color: var(--muted); font-size: var(--text-sm); }
122
+ .main-nav a { padding: 4px 1px; }
123
+ .text-interaction { position: relative; display: inline-block; transition: color var(--interaction-duration) var(--interaction-ease); }
124
+ a.text-interaction:hover, a.text-interaction:focus-visible,
125
+ a:hover .text-interaction, a:focus-visible .text-interaction,
126
+ .project-card:hover .text-interaction, .project-card:focus-within .text-interaction,
127
+ .portfolio-history-row:hover .text-interaction, .portfolio-history-row:focus-within .text-interaction,
128
+ .contact-submit:hover .text-interaction, .contact-submit:focus-visible .text-interaction { color: var(--lime); }
129
+ .main-nav .nav-link.is-active { color: var(--lime); }
130
+ .main-nav .nav-link { transition: color var(--interaction-duration) var(--interaction-ease); }
131
+ .main-nav .nav-link:hover, .main-nav .nav-link:focus-visible { color: inherit; }
132
+ .main-nav .nav-link.is-active:hover, .main-nav .nav-link.is-active:focus-visible { color: var(--lime); }
133
+ .nav-label-swap { position: relative; display: block; height: 1lh; overflow: hidden; }
134
+ .nav-label-swap::after {
135
+ position: absolute;
136
+ inset: 0;
137
+ font: inherit;
138
+ line-height: 1lh;
139
+ white-space: nowrap;
140
+ color: transparent;
141
+ background-image:
142
+ linear-gradient(105deg, transparent 34%, rgba(255, 240, 204, .08) 43%, rgba(255, 240, 204, .42) 48%, rgba(255, 240, 204, .78) 50%, rgba(255, 240, 204, .42) 52%, rgba(255, 240, 204, .08) 57%, transparent 66%),
143
+ linear-gradient(105deg, transparent 47%, rgba(255, 240, 204, .9) 50%, transparent 53%);
144
+ background-size: 240% 100%, 240% 100%;
145
+ background-position: 100% 50%, 100% 50%;
146
+ background-clip: text;
147
+ -webkit-background-clip: text;
148
+ -webkit-text-fill-color: transparent;
149
+ content: attr(data-label);
150
+ opacity: 0;
151
+ pointer-events: none;
152
+ }
153
+ .main-nav .nav-link:hover .nav-label-swap::after,
154
+ .main-nav .nav-link:focus-visible .nav-label-swap::after { animation: nav-label-shimmer 1.05s cubic-bezier(.2, .7, .2, 1) 2 both; }
155
+ @keyframes nav-label-shimmer {
156
+ 0%, 100% { background-position: 100% 50%, 100% 50%; opacity: 0; }
157
+ 12%, 72% { opacity: 1; }
158
+ 88% { background-position: 0% 50%, 0% 50%; opacity: .8; }
159
+ }
160
+ @media (prefers-reduced-motion: reduce) {
161
+ .main-nav .nav-link:hover .nav-label-swap::after,
162
+ .main-nav .nav-link:focus-visible .nav-label-swap::after { animation: none; }
163
+ }
164
+ .nav-label-primary, .nav-label-copy {
165
+ display: block;
166
+ height: 1lh;
167
+ line-height: 1lh;
168
+ white-space: nowrap;
169
+ transition: transform 400ms cubic-bezier(.22, .75, .25, 1);
170
+ }
171
+ .nav-label-primary { color: inherit; }
172
+ .nav-label-copy {
173
+ position: absolute;
174
+ top: 100%;
175
+ left: 0;
176
+ color: color-mix(in srgb, var(--lime) 72%, var(--warm-white));
177
+ }
178
+ .main-nav .nav-link:hover .nav-label-primary,
179
+ .main-nav .nav-link:focus-visible .nav-label-primary,
180
+ .main-nav .nav-link:hover .nav-label-copy,
181
+ .main-nav .nav-link:focus-visible .nav-label-copy { transform: translateY(-100%); }
182
+ a > svg, a > img, button > svg, button > img { transition: transform var(--interaction-duration) var(--interaction-ease), color var(--interaction-duration) var(--interaction-ease), filter var(--interaction-duration) var(--interaction-ease); }
183
+ a:not(.social-link):not(.contact-social-link):hover > svg, a:not(.social-link):not(.contact-social-link):focus-visible > svg, button:hover > svg, button:focus-visible > svg { transform: scale(1.06); color: var(--lime); }
184
+ a:not(.social-link):not(.contact-social-link):hover > img, a:not(.social-link):not(.contact-social-link):focus-visible > img, button:not(.skill-tile):hover > img, button:not(.skill-tile):focus-visible > img { transform: scale(1.06); filter: brightness(0) saturate(100%) invert(86%) sepia(17%) saturate(560%) hue-rotate(356deg) brightness(96%) contrast(90%); }
185
+ .menu-toggle { display: none; color: var(--warm-white); border: 0; background: transparent; transition: color var(--interaction-duration) var(--interaction-ease); }
186
+ .menu-toggle:hover, .menu-toggle:focus-visible { color: var(--lime); }
187
+
188
+ .hero {
189
+ position: relative;
190
+ display: block;
191
+ padding: 18px 0 16px;
192
+ border-left: 1px dashed var(--frame-dot);
193
+ padding-left: 12px;
194
+ padding-right: 12px;
195
+ text-align: left;
196
+ }
197
+ .hero::before { display: none; }
198
+ .hero > * { position: relative; z-index: 1; }
199
+ .hero::after {
200
+ position: absolute;
201
+ right: 0;
202
+ bottom: 0;
203
+ left: 0;
204
+ height: 1px;
205
+ background: repeating-linear-gradient(90deg, var(--stitch) 0 4px, transparent 4px 8px);
206
+ content: '';
207
+ }
208
+ .profile-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 1px 0 15px; }
209
+ .profile-identity { display: flex; min-width: 0; align-items: center; gap: 15px; }
210
+ .profile-copy { min-width: 0; }
211
+ .profile-photo-stage { position: relative; width: 100px; height: 96px; flex: 0 0 100px; }
212
+ .profile-photo { position: absolute; top: 6px; left: 8px; z-index: 2; display: block; width: 84px; height: 84px; border: 1px solid color-mix(in srgb, var(--warm-white) 26%, transparent); border-radius: 50%; object-fit: cover; object-position: center; }
213
+ .hero h1 { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 7px; margin: 0; color: var(--warm-white); font: 600 25px/1.15 var(--heading-font); letter-spacing: -.05em; }
214
+ .name-heart-wrap { position: relative; display: inline-flex; flex: 0 0 auto; align-items: center; }
215
+ .name-heart-icon { display: block; width: 20px; height: 20px; object-fit: contain; animation: heart-beat 1.15s ease-in-out infinite; transform-origin: center; }
216
+ .skill-name, .site-tooltip { padding: 6px 9px; border: 1px dotted var(--frame-dot-hover); border-radius: 4px; color: var(--warm-white); background: var(--elevated); font: 10px/1.3 var(--body-font); font-weight: 400; letter-spacing: 0; text-align: center; white-space: nowrap; box-shadow: 0 4px 14px rgba(0, 0, 0, .16); }
217
+ .heart-message { position: absolute; top: 50%; left: calc(100% + 10px); z-index: 6; width: max-content; max-width: min(190px, calc(100vw - 24px)); padding: 7px 10px; border: 1px dotted var(--frame-dot-hover); border-radius: 4px; color: var(--warm-white); background: var(--elevated); font: 11px/1.35 var(--body-font); letter-spacing: .01em; text-align: center; white-space: nowrap; box-shadow: 0 4px 14px rgba(0, 0, 0, .16); opacity: 0; visibility: hidden; pointer-events: none; transform: translate(-3px, -50%); transition: opacity 180ms var(--interaction-ease), transform 180ms var(--interaction-ease), visibility 180ms; }
218
+ @media (hover: hover) and (pointer: fine) {
219
+ .name-heart-wrap:hover .heart-message { opacity: 1; visibility: visible; transform: translate(0, -50%); }
220
+ }
221
+ .site-tooltip { position: fixed; z-index: 100; max-width: min(220px, calc(100vw - 24px)); overflow: hidden; text-overflow: ellipsis; opacity: 0; pointer-events: none; transform: translate(-50%, -100%) scale(.98); animation: tooltip-enter .16s ease both; }
222
+ .site-tooltip.is-below { transform: translate(-50%, 0) scale(.98); }
223
+ @keyframes tooltip-enter { from { opacity: 0; transform: translate(-50%, -100%) scale(.97); } to { opacity: 1; transform: translate(-50%, -100%) scale(1); } }
224
+ .site-tooltip.is-below { animation-name: tooltip-enter-below; }
225
+ @keyframes tooltip-enter-below { from { opacity: 0; transform: translate(-50%, 0) scale(.97); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }
226
+ .profile-intro { display: flex; width: 100%; flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 4px; margin: 6px 0 0; color: var(--muted); font: 14px/1.4 var(--body-font); }
227
+ .waving-hand { display: inline-block; margin-right: 2px; transform-origin: 70% 70%; animation: wave-hand 2.4s ease-in-out infinite; }
228
+ .profile-intro .profile-role { margin: 0; color: #D6B77A; font: 500 15px/1.4 var(--body-font); text-shadow: none; }
229
+ @supports ((background-clip: text) or (-webkit-background-clip: text)) {
230
+ .profile-intro .profile-role {
231
+ background-image:
232
+ linear-gradient(105deg, #A88A52 0%, #D6B77A 25%, #FFF0CC 50%, #D6B77A 75%, #A88A52 100%),
233
+ linear-gradient(100deg, transparent 0%, rgba(255, 240, 204, 0) 42%, rgba(255, 240, 204, .6) 47%, rgba(255, 255, 239, 1) 50%, rgba(255, 240, 204, .6) 53%, rgba(255, 240, 204, 0) 58%, transparent 100%);
234
+ background-size: 100% 100%, 240% 100%;
235
+ background-position: 0 0, 100% 50%;
236
+ background-clip: text;
237
+ -webkit-background-clip: text;
238
+ -webkit-text-fill-color: transparent;
239
+ animation: role-metallic-shimmer 2.8s linear infinite;
240
+ }
241
+ }
242
+ .profile-metadata { display: flex; flex-direction: column; flex-wrap: wrap; align-items: flex-end; justify-content: center; gap: 8px; margin: 0; color: var(--muted); font: 12px/1.4 var(--mono-font); }
243
+ .profile-metadata-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
244
+ .profile-metadata-item svg { flex: 0 0 auto; color: var(--text-muted); }
245
+ .profile-time-item::before { display: none; }
246
+ .profile-divider { height: 2px; background: repeating-linear-gradient(135deg, var(--stitch) 0 3px, transparent 3px 7px); }
247
+ .profile-bio { box-sizing: border-box; width: 100%; max-width: 100%; min-width: 0; margin: 13px 0 0; color: var(--muted); font: 13px/1.65 var(--body-font); text-align: left; }
248
+ .profile-bio p { min-width: 0; margin: 0; overflow-wrap: anywhere; white-space: pre-line; }
249
+ .profile-bio-content { width: 100%; max-width: 100%; min-width: 0; }
250
+ .profile-bio-copy { display: grid; min-width: 0; gap: 10px; }
251
+ .about-social-icons { margin-top: 14px; }
252
+ .profile-photo-placeholder { display: grid; place-items: center; border: 1px solid var(--border); background: radial-gradient(circle at 35% 25%, #242424, #050505 72%); color: var(--lime); font: 600 30px/1 var(--heading-font); letter-spacing: -.04em; }
253
+ .portfolio-content-section { margin-top: var(--section-space); }
254
+ .portfolio-content-panel { padding: 16px; }
255
+ .portfolio-content-panel > header { margin-bottom: 12px; }
256
+ .portfolio-content-panel h2 { margin-bottom: 5px; font-size: var(--heading-md); }
257
+ .portfolio-content-panel h2 span { color: var(--lime); }
258
+ .portfolio-content-panel > header > p, .portfolio-content-card > p { color: var(--muted); }
259
+ .portfolio-content-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 10px; }
260
+ .portfolio-content-card { min-width: 0; padding: 12px; border: 1px solid var(--border); background: var(--surface); color: var(--warm-white); }
261
+ .portfolio-content-card h3 { margin-bottom: 5px; font: 600 14px/1.3 var(--heading-font); }
262
+ .portfolio-content-card p { margin-bottom: 7px; font-size: var(--text-sm); line-height: 1.55; }
263
+ .portfolio-content-card .portfolio-content-organization, .portfolio-content-card .portfolio-content-date { color: var(--text-muted); }
264
+ .portfolio-content-card img { display: block; width: 100%; max-height: 180px; margin-bottom: 10px; border-radius: 3px; object-fit: cover; }
265
+ .testimonial-card blockquote { margin: 0 0 14px; color: var(--warm-white); font: italic 13px/1.6 var(--body-font); }
266
+ .testimonial-card img { width: 48px; height: 48px; margin-bottom: 10px; border-radius: 50%; }
267
+ .testimonial-avatar-fallback { display: grid; width: 48px; height: 48px; margin-bottom: 10px; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--lime); background: var(--card); font: 600 14px/1 var(--heading-font); }
268
+ .testimonial-card .portfolio-content-organization { margin-bottom: 0; }
269
+ [hidden] { display: none !important; }
270
+ .portfolio-content-card a { display: inline-block; margin: 2px 10px 2px 0; color: var(--lime); font-size: var(--text-sm); }
271
+ .portfolio-history-panel { padding: 0; }
272
+ .portfolio-history-panel > header { margin: 0; padding: 16px; border-bottom: 1px dashed var(--border); }
273
+ .portfolio-history-panel > header h2 { margin: 0; }
274
+ .portfolio-history-list { display: grid; }
275
+ .portfolio-history-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(100px, 22%); align-items: center; gap: 16px; min-width: 0; padding: 18px 16px; border-bottom: 1px dashed var(--frame-dot); transition: border-color var(--interaction-duration) var(--interaction-ease); }
276
+ .portfolio-history-row.is-education-row { grid-template-columns: minmax(0, 1fr) minmax(100px, 22%); }
277
+ .portfolio-history-row:last-child { border-bottom: 0; }
278
+ .portfolio-history-row:hover, .portfolio-history-row:focus-within { border-color: color-mix(in srgb, var(--lime) 58%, var(--frame-dot)); }
279
+ .portfolio-history-logo { width: 56px; height: 56px; margin: 0; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); object-fit: contain; }
280
+ .portfolio-history-logo-placeholder { display: grid; place-items: center; color: var(--text-muted); font: 500 10px/1 var(--body-font); }
281
+ .portfolio-history-main { min-width: 0; }
282
+ .portfolio-history-main h3 { margin: 0 0 4px; color: var(--warm-white); font: 600 var(--text-md)/1.35 var(--heading-font); }
283
+ .portfolio-history-row .portfolio-content-organization, .portfolio-history-date, .portfolio-history-description { margin: 0; color: var(--text-muted); font-size: var(--text-sm); line-height: 1.5; }
284
+ .portfolio-history-date { justify-self: end; text-align: right; }
285
+ .portfolio-history-link { display: inline-flex; margin-left: 8px; color: var(--text-muted); font-size: 1.15em; text-decoration: none; transition: color .2s ease, transform .2s ease; }
286
+ .portfolio-history-link:hover, .portfolio-history-link:focus-visible { color: var(--lime); transform: translate(2px, -2px); }
287
+ .portfolio-history-description { margin-top: 5px; }
288
+ @media (max-width: 600px) {
289
+ .portfolio-history-panel > header { padding: 14px 12px; }
290
+ .portfolio-history-row { grid-template-columns: auto minmax(0, 1fr); gap: 12px; padding: 15px 12px; }
291
+ .portfolio-history-row.is-education-row { grid-template-columns: minmax(0, 1fr); }
292
+ .portfolio-history-logo { width: 46px; height: 46px; border-radius: 10px; }
293
+ .portfolio-history-date { grid-column: 2; justify-self: start; text-align: left; }
294
+ .portfolio-history-row.is-education-row .portfolio-history-date { grid-column: 1; }
295
+ }
296
+ .metallic-shimmer-text { color: #D6B77A; }
297
+ @supports ((background-clip: text) or (-webkit-background-clip: text)) {
298
+ .metallic-shimmer-text {
299
+ background-image:
300
+ linear-gradient(105deg, #A88A52 0%, #D6B77A 25%, #FFF0CC 50%, #D6B77A 75%, #A88A52 100%),
301
+ linear-gradient(100deg, transparent 0%, rgba(255, 240, 204, 0) 43%, rgba(255, 240, 204, .55) 48%, rgba(255, 255, 239, 1) 50%, rgba(255, 240, 204, .55) 52%, rgba(255, 240, 204, 0) 57%, transparent 100%);
302
+ background-size: 100% 100%, 230% 100%;
303
+ background-position: 0 0, 100% 50%;
304
+ background-clip: text;
305
+ -webkit-background-clip: text;
306
+ -webkit-text-fill-color: transparent;
307
+ animation: bio-role-metallic-shimmer 2.8s linear infinite;
308
+ }
309
+ }
310
+ @keyframes bio-role-metallic-shimmer {
311
+ from { background-position: 0 0, 100% 50%; }
312
+ to { background-position: 0 0, 0% 50%; }
313
+ }
314
+ .social-links { display: flex; max-width: 100%; flex-wrap: wrap; justify-content: flex-start; gap: 7px; margin-top: 14px; }
315
+ .social-link { display: inline-flex; width: auto; min-width: 0; height: 35px; flex: 0 0 auto; align-items: center; justify-content: flex-start; gap: 7px; padding: 6px 9px; border: 1px dotted var(--frame-dot); border-radius: 5px; color: var(--muted); background: var(--surface); font: 10px/1.2 var(--body-font); transition: transform var(--interaction-duration) var(--interaction-ease), color var(--interaction-duration) var(--interaction-ease), border-color var(--interaction-duration) var(--interaction-ease), background var(--interaction-duration) var(--interaction-ease), opacity var(--interaction-duration) var(--interaction-ease); }
316
+ .social-link:hover, .social-link:focus-visible,
317
+ .contact-social-link:hover, .contact-social-link:focus-visible { color: var(--lime); border-color: color-mix(in srgb, var(--lime) 58%, var(--frame-dot)); }
318
+ .social-link:hover .brand-icon, .social-link:focus-visible .brand-icon,
319
+ .contact-social-link:hover > img, .contact-social-link:focus-visible > img { filter: brightness(0) saturate(100%) invert(86%) sepia(17%) saturate(560%) hue-rotate(356deg) brightness(96%) contrast(90%); }
320
+ .social-link:hover .social-icon, .social-link:focus-visible .social-icon, .contact-social-link:hover > svg, .contact-social-link:focus-visible > svg, .contact-social-link:hover > img, .contact-social-link:focus-visible > img { transform: translateY(-2px); opacity: 1; }
321
+ .social-link:active .social-icon, .contact-social-link:active > svg, .contact-social-link:active > img { transform: translateY(0); opacity: .72; }
322
+ .social-link.is-unavailable { cursor: default; }
323
+ .social-icon { display: block; width: 16px; height: 16px; object-fit: contain; opacity: .82; transition: opacity var(--interaction-duration) var(--interaction-ease), transform var(--interaction-duration) var(--interaction-ease); }
324
+ .brand-icon-fallback { display: inline-grid; flex: 0 0 auto; place-items: center; overflow: hidden; border: 1px solid color-mix(in srgb, var(--lime) 38%, var(--frame-dot)); border-radius: 3px; color: var(--muted); background: color-mix(in srgb, var(--surface) 82%, transparent); font: 700 6px/1 var(--mono-font); letter-spacing: -.04em; }
325
+ .social-link:hover .brand-icon-fallback, .social-link:focus-visible .brand-icon-fallback, .contact-social-link:hover .brand-icon-fallback, .contact-social-link:focus-visible .brand-icon-fallback { border-color: var(--lime); color: var(--lime); }
326
+ .resume-icon { color: var(--muted); }
327
+
328
+ .section-rule { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 8px; letter-spacing: .09em; }
329
+ .rule-dots { height: 1px; flex: 1; background: repeating-linear-gradient(90deg, var(--stitch) 0 4px, transparent 4px 8px); }
330
+ .eyebrow { display: inline-block; color: var(--muted); font-size: 9px; letter-spacing: .1em; }
331
+ .projects { position: relative; padding: var(--section-space) 0 var(--section-space-end) 14px; border-left: 1px solid var(--frame-dot); text-align: left; }
332
+ .projects::before { position: absolute; top: 0; right: 0; left: 0; height: 1px; background: repeating-linear-gradient(90deg, var(--stitch) 0 4px, transparent 4px 8px); content: ''; }
333
+ .projects-intro { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; padding: 5px 0 10px; }
334
+ .projects-intro h2 { margin: 0; font-size: clamp(22px, 2.5vw, 25px); line-height: 1.12; font-weight: 600; }
335
+ .projects-period { color: var(--lime); }
336
+ .project-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 7px; text-align: left; }
337
+ .project-card { display: grid; min-width: 0; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: center; gap: 22px; padding: 15px; border: 1px dashed var(--frame-dot); border-radius: 4px; background: color-mix(in srgb, var(--card) 72%, transparent); }
338
+ .project-thumbnail { display: block; width: 100%; min-width: 0; aspect-ratio: 16 / 9; border: 1px solid rgba(164, 165, 168, .12); border-radius: 4px; object-fit: cover; }
339
+ .project-thumbnail-placeholder { display: grid; place-items: center; background: radial-gradient(circle at 35% 25%, #242424, #050505 72%); color: var(--lime); font: 600 26px/1 var(--heading-font); }
340
+ .project-thumbnail.is-desaturated { aspect-ratio: 1.9 / 1; background: #f3f6fa; filter: grayscale(1); object-fit: contain; transition: filter 450ms ease; }
341
+ .project-card:hover .project-thumbnail.is-desaturated, .project-card:focus-within .project-thumbnail.is-desaturated { filter: grayscale(0); }
342
+ .project-info { display: flex; min-width: 0; flex-direction: column; gap: 9px; padding: 1px 0; }
343
+ .project-heading-row { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
344
+ .project-info > h3 { margin: 0; color: var(--warm-white); font: 600 15px/1.25 var(--heading-font); letter-spacing: -.025em; }
345
+ .project-info > p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
346
+ .project-stack { display: flex; flex-direction: column; gap: 5px; }
347
+ .project-stack h4 { margin: 0; color: var(--warm-white); font: 600 12px/1.25 var(--heading-font); }
348
+ .project-tech-list, .project-actions { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; }
349
+ .project-tech-pill { display: inline-flex; min-height: 24px; align-items: center; padding: 3px 8px; border: 1px solid var(--border); border-radius: 3px; color: var(--warm-white); background: var(--surface); font-size: 10px; line-height: 1.2; white-space: nowrap; }
350
+ .project-actions a, .project-actions .project-action-placeholder { display: inline-flex; min-height: 26px; align-items: center; justify-content: center; gap: 5px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 3px; color: var(--muted); background: color-mix(in srgb, var(--surface) 88%, transparent); font-size: 10px; line-height: 1.25; text-align: left; transition: color var(--interaction-duration) var(--interaction-ease), border-color var(--interaction-duration) var(--interaction-ease), background var(--interaction-duration) var(--interaction-ease); }
351
+ .project-actions .project-action-placeholder { max-width: 190px; color: var(--text-muted); font-family: var(--body-font); cursor: pointer; }
352
+ .project-actions a:hover, .project-actions a:focus-visible, .project-actions .project-action-placeholder:hover { color: var(--lime); }
353
+ .project-actions a > svg, .project-actions a > img, .project-actions .project-action-placeholder > svg { display: block; width: 14px; height: 14px; flex: 0 0 14px; object-fit: contain; transition: transform var(--interaction-duration) var(--interaction-ease), filter var(--interaction-duration) var(--interaction-ease), color var(--interaction-duration) var(--interaction-ease); }
354
+ .project-actions a:focus-visible, .project-actions .project-action-placeholder:focus-visible { outline: 1px solid var(--lime); outline-offset: 1px; }
355
+
356
+ .skills, .contact-section { position: relative; padding: var(--section-space) 0 var(--section-space-end); }
357
+ .contact-section::before { position: absolute; top: 0; right: 0; left: 0; height: 1px; background: repeating-linear-gradient(90deg, var(--stitch) 0 4px, transparent 4px 8px); content: ''; }
358
+ .hero { margin-bottom: 4px; }
359
+ .skills-panel { padding: 11px 14px 13px; border: 1px dashed var(--frame-dot); background: var(--surface); }
360
+ .skills-panel::before { display: none; }
361
+ .skills-panel > h2 { position: relative; z-index: 1; margin: 0 0 9px; padding-bottom: 7px; border-bottom: 1px dotted var(--frame-dot); color: var(--warm-white); font: 600 20px/1.1 var(--heading-font); letter-spacing: -.045em; text-transform: lowercase; }
362
+ .skills-grid { display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
363
+ .skill-tile { position: relative; display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; padding: 0; border: 1px solid transparent; border-radius: 4px; color: var(--warm-white); background: transparent; }
364
+ .skill-tile:hover { z-index: 2; }
365
+ .skill-logo { width: 32px; height: 32px; object-fit: contain; }
366
+ .skill-logo-fallback { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid color-mix(in srgb, var(--lime) 38%, var(--frame-dot)); border-radius: 4px; color: var(--lime); font: 700 8px/1 var(--mono-font); letter-spacing: -.02em; }
367
+ .skill-logo-fallback[hidden] { display: none; }
368
+ .skill-name { position: absolute; top: calc(100% + 7px); left: 50%; z-index: 8; min-width: max-content; opacity: 0; pointer-events: none; transform: translate(-50%, -3px) scale(.97); transition: opacity var(--interaction-duration) var(--interaction-ease), transform var(--interaction-duration) var(--interaction-ease); }
369
+ .skill-tile:hover .skill-name { opacity: 1; transform: translate(-50%, 0) scale(1); }
370
+ .contact-panel { padding: 12px; background: var(--surface); }
371
+ .contact-heading { padding: 1px 1px 9px; border-bottom: 1px dotted var(--frame-dot); }
372
+ .contact-heading h2 { margin: 0; color: var(--warm-white); font: 600 21px/1.15 var(--heading-font); letter-spacing: -.04em; }
373
+ .contact-heading h2 span { color: var(--lime); }
374
+ .contact-heading > p, .contact-subheading > p { margin: 5px 0 0; color: var(--muted); font: 11px/1.5 var(--body-font); }
375
+ .contact-content { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
376
+ .contact-form { min-width: 0; padding: 10px 14px 0 1px; }
377
+ .contact-subheading h3 { margin: 0; color: var(--warm-white); font: 500 14px/1.25 var(--heading-font); }
378
+ .contact-subheading > p { margin-top: 3px; }
379
+ .contact-field { display: flex; min-width: 0; align-items: center; gap: 8px; padding: 8px 2px; border-bottom: 1px dotted var(--frame-dot); color: var(--text-muted); transition: color var(--interaction-duration) var(--interaction-ease), border-color var(--interaction-duration) var(--interaction-ease); }
380
+ .contact-field:focus-within { border-color: var(--lime); color: var(--lime); }
381
+ .contact-field > svg { flex: 0 0 auto; }
382
+ .contact-field input, .contact-field textarea { width: 100%; min-width: 0; border: 0; outline: 0; color: var(--warm-white); background: transparent; font: 12px/1.45 var(--body-font); }
383
+ .contact-field textarea { min-height: 52px; resize: none; }
384
+ .contact-field input::placeholder, .contact-field textarea::placeholder { color: var(--text-muted); opacity: 1; }
385
+ .contact-message-field { align-items: flex-start; }
386
+ .contact-message-field > svg { margin-top: 2px; }
387
+ .contact-submit { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: center; gap: 7px; margin-top: 9px; border: 1px dotted var(--frame-dot); border-radius: 3px; color: var(--warm-white); background: transparent; font: 500 11px/1 var(--body-font); cursor: pointer; transition: color var(--interaction-duration) var(--interaction-ease); }
388
+ .contact-submit:hover, .contact-submit:focus-visible { color: var(--lime); }
389
+ .contact-submit:hover > svg, .contact-submit:focus-visible > svg { transform: scale(1.06); color: var(--lime); }
390
+ .contact-submit:active > svg { transform: scale(.94); }
391
+ .contact-submit:focus-visible { outline: 2px solid var(--lime); outline-offset: 2px; }
392
+ .contact-social-panel { min-width: 0; padding: 10px 1px 0 14px; border-left: 1px dotted var(--frame-dot); }
393
+ .contact-social-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; margin-top: 9px; }
394
+ .contact-social-link { display: flex; min-width: 0; min-height: 34px; align-items: center; justify-content: center; gap: 7px; padding: 6px 8px; border: 1px dotted var(--frame-dot); border-radius: 3px; color: var(--muted); background: transparent; font: 10px/1.25 var(--body-font); transition: color var(--interaction-duration) var(--interaction-ease), border-color var(--interaction-duration) var(--interaction-ease); }
395
+ .contact-social-link:hover, .contact-social-link:focus-visible { color: var(--lime); }
396
+ .contact-social-link > svg, .contact-social-link > img, .contact-brand-icon { display: block; width: 16px; height: 16px; flex: 0 0 16px; object-fit: contain; }
397
+ .contact-social-link > svg { color: var(--muted); }
398
+ .contact-social-link > svg, .contact-social-link > img { opacity: .82; transition: transform var(--interaction-duration) var(--interaction-ease), opacity var(--interaction-duration) var(--interaction-ease); }
399
+ .contact-brand-icon { fill: currentColor; }
400
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); }
401
+ .site-footer { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 17px var(--page-gutter) 20px; border-top: 1px dotted var(--frame-dot); color: var(--muted); text-align: center; }
402
+ .footer-social-links { justify-content: center; margin-top: 4px; }
403
+ .footer-copyright, .footer-credit, .footer-quote { margin: 0; font-family: var(--body-font); }
404
+ .footer-copyright { color: var(--text-muted); font-size: 10px; line-height: 1.45; }
405
+ .footer-credit { color: var(--muted); font-size: 10px; line-height: 1.45; }
406
+ .footer-heart { display: inline-block; width: 14px; height: 14px; margin: 0 2px; object-fit: contain; vertical-align: -3px; animation: footer-heart-pulse 1.8s ease-in-out infinite; }
407
+ .footer-quote { width: min(100%, var(--content-width)); margin-top: 5px; padding-top: 9px; border-top: 1px dotted color-mix(in srgb, var(--frame-dot) 72%, transparent); color: var(--text-muted); font-size: 10px; font-style: italic; line-height: 1.45; letter-spacing: .01em; }
408
+ @keyframes footer-heart-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.16); } }
409
+
410
+ @keyframes wave-hand {
411
+ 0%, 62%, 100% { transform: rotate(0); }
412
+ 8% { transform: rotate(13deg); }
413
+ 16% { transform: rotate(-8deg); }
414
+ 24% { transform: rotate(11deg); }
415
+ 32% { transform: rotate(-6deg); }
416
+ 40% { transform: rotate(7deg); }
417
+ 48% { transform: rotate(-3deg); }
418
+ 56% { transform: rotate(3deg); }
419
+ }
420
+ @keyframes role-glow {
421
+ from { text-shadow: 0 0 3px rgba(229, 207, 156, .2); }
422
+ to { text-shadow: 0 0 5px rgba(229, 207, 156, .32); }
423
+ }
424
+ @keyframes heart-beat {
425
+ 0%, 10%, 25%, 42%, 100% { transform: scale(1); filter: drop-shadow(0 0 0 rgba(229, 207, 156, 0)); }
426
+ 14% { transform: scale(1.14); filter: drop-shadow(0 0 3px rgba(229, 207, 156, .48)); }
427
+ 19% { transform: scale(1.03); }
428
+ 31% { transform: scale(1.2); filter: drop-shadow(0 0 5px rgba(229, 207, 156, .62)); }
429
+ 37% { transform: scale(1.05); }
430
+ }
431
+ @keyframes role-metallic-shimmer {
432
+ from { background-position: 0 0, 100% 50%; }
433
+ to { background-position: 0 0, 0% 50%; }
434
+ }
435
+
436
+ @media (prefers-reduced-motion: reduce) {
437
+ .name-heart-icon, .footer-heart, .profile-intro .profile-role, .metallic-shimmer-text { animation: none; }
438
+ }
439
+
440
+ @media (max-width: 760px) {
441
+ :root { --page-gutter: 14px; }
442
+ body::before { opacity: .2; }
443
+ body::after { display: none; animation: none; will-change: auto; }
444
+ .heart-message { top: auto; bottom: calc(100% + 5px); left: 50%; transform: translate(-50%, 3px); }
445
+ @media (hover: hover) and (pointer: fine) {
446
+ .name-heart-wrap:hover .heart-message { transform: translate(-50%, 0); }
447
+ }
448
+ .main-nav { gap: 12px; }
449
+ .project-grid { gap: 8px; }
450
+ }
451
+ @media (max-width: 600px) {
452
+ body::before { background-image: url('/stars-portrait.svg'); opacity: .16; }
453
+ :root { --page-gutter: 12px; --section-space: 16px; --section-space-end: 18px; --panel-inset: 10px; }
454
+ .site-header { min-height: 44px; }
455
+ .wordmark { font-size: 13px; }
456
+ .menu-toggle { display: grid; width: 36px; height: 36px; min-width: 44px; min-height: 44px; place-items: center; }
457
+ .main-nav { position: absolute; top: 100%; right: 0; left: 0; display: none; align-items: stretch; gap: 0; padding: 6px var(--page-gutter) 9px; border-bottom: 1px dotted var(--frame-dot); background: var(--surface); box-shadow: 0 10px 24px rgba(0, 0, 0, .5); }
458
+ .main-nav.is-open { display: flex; flex-direction: column; }
459
+ .main-nav > a { display: flex; align-items: center; min-height: 44px; padding: 10px 8px; border-bottom: 1px dotted var(--frame-dot); font-size: 13px; }
460
+ .hero { padding: 14px 12px 11px; }
461
+ .hero::before { display: none; }
462
+ .profile-header { flex-wrap: wrap; gap: 12px 14px; }
463
+ .profile-identity { flex: 1 1 230px; gap: 11px; }
464
+ .profile-photo-stage { width: 78px; height: 76px; flex-basis: 78px; }
465
+ .profile-photo { top: 6px; left: 7px; width: 64px; height: 64px; border-radius: 50%; }
466
+ .hero h1 { margin-top: 7px; font-size: 22px; }
467
+ .profile-intro { font-size: 13px; }
468
+ .profile-intro .profile-role { font-size: 14px; }
469
+ .profile-metadata { flex: 1 1 100%; flex-direction: row; align-items: center; justify-content: flex-start; gap: 12px; font-size: 10px; }
470
+ .profile-bio { max-width: 100%; font-size: 12px; line-height: 1.55; }
471
+ .social-links { gap: 6px; margin-top: 12px; }
472
+ .social-link { min-height: 36px; padding: 7px 10px; }
473
+ .projects { padding: var(--section-space) 0 var(--section-space-end) 12px; }
474
+ .projects-intro { gap: 4px; padding: 5px 0 9px; }
475
+ .projects-intro h2 { font-size: 22px; }
476
+ .project-grid { gap: 7px; }
477
+ .project-card { grid-template-columns: minmax(0, 1fr); gap: 10px; padding: 9px; }
478
+ .project-thumbnail { width: 100%; aspect-ratio: 16 / 9; }
479
+ .project-info { gap: 7px; }
480
+ .project-info > h3 { font-size: 14px; }
481
+ .project-info > p { font-size: 10px; }
482
+ .project-actions a { min-height: 32px; padding: 5px 10px; font-size: 11px; }
483
+ .skills-panel { padding: 9px 10px 11px; }
484
+ .skills-panel > h2 { margin-bottom: 7px; padding-bottom: 6px; font-size: 18px; }
485
+ .skills-grid { gap: 4px; }
486
+ .skill-tile { width: 38px; height: 38px; flex-basis: 38px; }
487
+ .skill-logo { width: 29px; height: 29px; }
488
+ .contact-panel { padding: 10px; }
489
+ .contact-panel::after { left: 10px; }
490
+ .contact-content { grid-template-columns: minmax(0, 1fr); }
491
+ .contact-form { padding: 9px 0 10px; }
492
+ .contact-social-panel { padding: 9px 0 0; border-top: 1px dotted var(--frame-dot); border-left: 0; }
493
+ .contact-social-link { min-height: 38px; }
494
+ .site-footer { padding: 16px var(--page-gutter) 18px; }
495
+ }
496
+ @media (max-width: 600px) and (hover: none) {
497
+ .heart-message { display: none; }
498
+ }
499
+ @media (max-width: 380px) {
500
+ :root { --page-gutter: 10px; }
501
+ .profile-metadata { gap: 4px; font-size: 9px; }
502
+ .social-links { gap: 4px; }
503
+ .project-card { gap: 8px; padding: 8px; }
504
+ .contact-social-links { grid-template-columns: repeat(2, minmax(0, 1fr)); }
505
+ }
506
+ main > section.section-wrap {
507
+ padding-inline: var(--panel-inset);
508
+ margin-bottom: var(--section-space);
509
+ border: 1px dashed var(--frame-dot);
510
+ border-radius: 0;
511
+ }
512
+ main > section.section-wrap::before,
513
+ main > section.section-wrap::after { display: none; }
514
+ .dotted-frame::before { display: none; }
515
+ .skills-panel, .contact-panel { border: 0; border-radius: 0; }
516
+ .project-card, .portfolio-history-row { border: 1px dashed var(--frame-dot); border-radius: 0; }
517
+ .project-card {
518
+ transition: border-color var(--interaction-duration) var(--interaction-ease);
519
+ }
520
+ .project-card:hover, .project-card:focus-within {
521
+ border-color: color-mix(in srgb, var(--lime) 58%, var(--frame-dot));
522
+ }
523
+ @media (prefers-reduced-motion: reduce) {
524
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
525
+ }
526
+ .skills-panel > h2,
527
+ .projects-intro h2,
528
+ .contact-heading h2 {
529
+ color: var(--warm-white);
530
+ font: 600 20px/1.15 var(--heading-font);
531
+ letter-spacing: -.04em;
532
+ text-transform: none;
533
+ }
534
+ .skills-panel > h2 span,
535
+ .projects-intro h2 span,
536
+ .contact-heading h2 span { color: inherit; }
@@ -0,0 +1,2 @@
1
+ export const projectCardId = (name: string) =>
2
+ `project-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert/strict'
2
+ import { access, readFile } from 'node:fs/promises'
3
+ import { createElement } from 'react'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { renderToStaticMarkup } from 'react-dom/server'
7
+ import { createServer } from 'vite'
8
+ import test from 'node:test'
9
+
10
+ test('portfolio content, optional sections, section ordering and custom sections follow data.ts', async () => {
11
+ const templateRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
12
+ const server = await createServer({ root: templateRoot, configFile: resolve(templateRoot, 'vite.config.ts'), server: { middlewareMode: true }, appType: 'custom' })
13
+ try {
14
+ const [{ default: App }, { portfolioConfig: config }] = await Promise.all([
15
+ server.ssrLoadModule('/src/App.tsx'),
16
+ server.ssrLoadModule('/data.ts'),
17
+ ])
18
+ const original = structuredClone(config)
19
+ const render = () => renderToStaticMarkup(createElement(App))
20
+
21
+ try {
22
+ let html = render()
23
+ const readableHtml = () => html.replaceAll('&#x27;', "'")
24
+ assert.match(html, /Your Name/)
25
+ assert.match(html, /Delhi, India/)
26
+ assert.equal(config.projects.length, 3)
27
+ assert.equal(config.testimonials.length, 3)
28
+ const projectCards = html.match(/<article class="project-card[^]*?<\/article>/g) ?? []
29
+ assert.equal(projectCards.length, 3)
30
+ assert.ok(projectCards.every((card) => !/<a\b/.test(card)), 'projects without URLs have no clickable action links')
31
+ const aboutSection = html.match(/<section class="profile-bio[^]*?<\/section>/)?.[0]
32
+ assert.ok(aboutSection)
33
+ assert.equal([...aboutSection.matchAll(/<p>/g)].length, 3, 'bio content renders as three paragraphs')
34
+ await access(resolve(templateRoot, 'public', config.profile.profileImage.slice(1)))
35
+ assert.ok(config.projects.every(({ image }) => image === '/project-placeholder.svg'))
36
+ await access(resolve(templateRoot, 'public', 'project-placeholder.svg'))
37
+ const styles = await readFile(resolve(templateRoot, 'src/styles.css'), 'utf8')
38
+ assert.match(styles, /\.profile-photo\s*\{[^}]*border-radius:\s*50%/)
39
+ assert.deepEqual(config.skills.map(({ name }) => name), [
40
+ 'HTML', 'CSS', 'JavaScript', 'React', 'TypeScript', 'Tailwind CSS', 'Vite', 'Redux', 'Markdown',
41
+ 'Node.js', 'Express.js', 'MongoDB', 'MySQL', 'PostgreSQL', 'C', 'Python', 'Java', 'Postman',
42
+ 'VS Code', 'Git', 'GitHub', 'npm', 'GitHub Actions',
43
+ ])
44
+ assert.equal(config.socialIcons.length, 4)
45
+ assert.deepEqual(config.socialIcons.map(({ url }) => url), [
46
+ 'https://github.com/your-username',
47
+ 'https://linkedin.com/in/your-username',
48
+ 'https://instagram.com/your-username',
49
+ 'https://x.com/your-username',
50
+ ])
51
+ assert.match(html, /aria-label="Social profiles and resume"/)
52
+ assert.match(html, /Follow &amp; connect\./)
53
+ assert.match(html, /Find me across these platforms\./)
54
+ assert.match(html, /data-tooltip="GitHub"/)
55
+ assert.match(html, /data-tooltip="LinkedIn"/)
56
+ assert.match(html, /data-tooltip="Instagram"/)
57
+ assert.match(html, /data-tooltip="X"/)
58
+ assert.ok(config.socialIcons.every(({ url }) => html.includes(`href="${url}"`)))
59
+ assert.match(html, /target="_blank" rel="noopener noreferrer"/)
60
+ assert.doesNotMatch(html, /href="(?:undefined|javascript:)/)
61
+ assert.match(readableHtml(), /Hey, there! I'm a developer who loves building products that people actually use\. Write a couple of sentences here about who you are, what you do, and what you're currently working on\./)
62
+ assert.match(readableHtml(), /Use this space to talk about your experience, the kinds of problems you enjoy solving, and the technologies you reach for most often\./)
63
+ assert.match(readableHtml(), /Wrap up with what you're excited about right now — side projects, open source, or whatever you're learning next/)
64
+
65
+ config.testimonials = []
66
+ assert.doesNotMatch(render(), /id="testimonials"/, 'empty testimonials are omitted')
67
+ config.testimonials = original.testimonials
68
+
69
+ config.experience = []
70
+ assert.doesNotMatch(render(), /id="experience"/, 'empty experience is omitted')
71
+ config.experience = original.experience
72
+
73
+ config.socialIcons[0].url = 'https://github.com/example'
74
+ html = render()
75
+ assert.match(html, /href="https:\/\/github\.com\/example"/)
76
+ config.socialIcons[0].url = 'javascript:alert(1)'
77
+ assert.doesNotMatch(render(), /href="javascript:alert\(1\)"/)
78
+ config.socialIcons[0].url = undefined
79
+
80
+ config.projects.push({ name: 'Data Driven Project', description: 'A project added through data.ts.', technologies: [] })
81
+ html = render()
82
+ assert.match(html, /Data Driven Project/)
83
+
84
+ config.profile.name = 'Casey Example'
85
+ html = render()
86
+ assert.match(html, /Casey Example/)
87
+ assert.match(html, /aria-label="Casey Example, home"/)
88
+ assert.match(html, /footer-copyright">© .*Casey Example/)
89
+ assert.doesNotMatch(html, /Your Name/)
90
+ } finally {
91
+ Object.assign(config, original)
92
+ }
93
+ } finally {
94
+ await server.close()
95
+ }
96
+ })