feat: initialize server with essential files and configurations

- Added package.json and package-lock.json for project dependencies and metadata.
- Created server.js to handle HTTP requests, serve files, and implement security headers.
- Implemented minification functions for HTML, CSS, and JavaScript to optimize performance.
- Added style.css for styling the application with a responsive design.
- Introduced script.js for handling navigation and reveal animations.
- Removed unused profile.webp image.
This commit is contained in:
Florian Sylvain
2025-09-09 16:33:40 +02:00
parent f5acff7e9a
commit eb9f85ead0
9 changed files with 1447 additions and 523 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

After

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
File diff suppressed because one or more lines are too long
+231 -522
View File
@@ -2,534 +2,243 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Portfolio de Florian Sylvain, développeur web fullstack actuellement en poste." /> <meta name="description"
<link rel="icon" type="image/x-icon" href="/favicon.webp" /> content="Portfolio de Florian Sylvain, développeur web fullstack. Démonstration de design sobre, moderne et réactif." />
<link rel="canonical" href="https://floriansylvain.fr/" /> <link rel="icon" type="image/x-icon" href="/favicon.webp" />
<title>Portfolio Florian Sylvain - Développeur web fullstack</title> <link rel="canonical" href="https://floriansylvain.fr/" />
<meta name="theme-color" content="#0e120e" />
<title>Portfolio Florian Sylvain - Développeur web fullstack</title>
<style> <link rel="stylesheet" href="style.css" media="print" onload="this.media='all'; this.onload=null;" />
:root { <noscript>
--background: #0e120e; <link rel="stylesheet" href="style.css" />
--foreground: #e2f1e2; </noscript>
--surface: #121a12;
--surface-2: #162116;
--border: #161f16;
--accent: #7fc27f;
--accent-hover: #a7e6a7;
--accent-visited: #5e8a5e;
--muted: #8aa78a;
}
body {
background: var(--background);
color: var(--foreground);
font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
line-height: 1.6;
max-width: 1000px;
margin: auto;
padding: 16px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#header h1 {
font-size: clamp(1.6rem, 3.5vw, 2.25rem);
font-weight: 700;
letter-spacing: .2px;
color: var(--accent-hover);
margin-bottom: 8px;
}
#header img {
max-width: 128px;
height: auto;
display: block;
border: 1px solid var(--border);
}
#header div {
display: flex;
align-items: flex-start;
gap: 16px;
}
table {
width: 100%;
margin-bottom: 16px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
background: var(--surface);
border: 1px solid var(--border);
overflow: hidden;
}
#experiences col:first-child,
#projets col:first-child {
width: 120px;
}
#experiences col:last-child,
#projets col:last-child {
width: 100%;
}
th,
td {
padding: 10px 12px;
text-align: left;
vertical-align: top;
}
thead th {
background: var(--surface-2);
color: var(--accent);
font-weight: 600;
letter-spacing: .2px;
}
tbody td {
background: var(--surface);
}
tbody tr:nth-child(odd) td {
background: color-mix(in srgb, var(--surface) 92%, white 8%);
}
caption {
color: var(--muted);
font-size: .95em;
text-transform: uppercase;
letter-spacing: .06em;
padding: 8px 0 6px;
}
a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent);
text-decoration-thickness: 0.08em;
text-underline-offset: 2px;
}
a:hover {
color: var(--accent-hover);
text-decoration-color: var(--accent-hover);
}
a:visited {
color: var(--accent-visited);
}
a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
ul,
ol {
margin: 0;
padding-left: 20px;
}
nav li {
margin-bottom: 6px;
}
section h2,
footer h2 {
color: var(--accent);
font-size: clamp(1.2rem, 2.2vw, 1.4rem);
margin-top: 24px;
margin-bottom: 8px;
font-weight: 600;
letter-spacing: .2px;
}
footer {
border-top: 1px solid #1e2a1e;
margin-top: 32px;
padding-top: 12px;
font-size: .95em;
color: var(--muted);
background: var(--background);
}
#experiences thead th,
#projets thead th {
font-size: large;
}
@media (max-width: 576px) {
body {
padding: 8px;
}
#header div {
flex-direction: column;
}
table,
th,
td {
font-size: .95em;
}
}
</style>
</head> </head>
<body> <body id="top">
<header id="header"> <header class="site-header">
<h1>Portfolio de Florian Sylvain</h1> <div class="container inner">
<div> <a class="brand" href="#top" aria-label="Accueil">
<img width="128" height="120" src="profile.webp" alt="Photo de Florian Sylvain" /> <span class="logo">FS</span>
<p> <span>Florian Sylvain</span>
Diplômé d'un Bachelor Développeur Web à l'ESD Paris, </a>
autoentrepreneur, je suis passionné par l'informatique. <nav class="site-nav" id="primaryNav" aria-label="Navigation principale">
Actuellement en poste à temps plein en CDI chez DOLLEN <a href="#about">À propos</a>
(Bonnueil-sur-Marne) en tant que développeur web fullstack. <a href="#skills">Compétences</a>
</p> <a href="#experience">Expérience</a>
</div> <a href="#projects">Projets</a>
</header> <a href="#contact">Contact</a>
</nav>
<button class="menu-toggle" id="menuToggle" aria-controls="primaryNav" aria-expanded="false" type="button">
<span class="bars"><span></span></span>
Menu
</button>
</div>
</header>
<nav> <main class="container">
<h2>Navigation</h2> <aside class="banner reveal" role="note" aria-label="Annonce d'emploi urgente">
<ol> <span class="badge-urgent">Urgent</span>
<li><a href="#competences">Compétences et qualifications</a></li> <span>À la recherche d'opportunités <strong>CDI, CDD, Freelance </strong>en développement
<li><a href="#diplomes">Diplômes obtenus</a></li> <strong>Back</strong>, <strong>Front</strong>, <strong>DevOps</strong>, etc.</span>
<li><a href="#experiences">Expériences professionnelles</a></li> <a class="btn primary" href="#contact"></a>
<li><a href="#projets">Projets récents</a></li> </aside>
<li><a href="#contact">Contact et coordonnées</a></li>
<li><a href="#autres">Autres informations</a></li>
</ol>
</nav>
<main> <section id="about" class="hero reveal" aria-labelledby="hero-title">
<section id="competences"> <div class="hero-card">
<h2>Compétences et qualifications</h2> <p class="eyebrow">Développeur web fullstack</p>
<ul> <h1 id="hero-title">Je conçois, développe et déploie des applications web performantes et maintenables.</h1>
<li>Rédaction de spécifications et cahier de recettes.</li> <p>
<li>Maîtrise de PhP, Java, Go, Html5/Css3, Javascript, Typescript, SQL, VueJS, React, Flutter, Docker, Diplômé d'un Bachelor Développeur Web à l'ESD Paris, autoentrepreneur et passionné d'informatique.
Jenkins, Git, GCP, et de Nginx. Apprendre un nouveau language ou framework n'est absolument pas un Actuellement en CDI chez Dollen en tant que développeur web fullstack.
obstacle pour moi.</li>
<li>Maîtrise de l'anglais niveau C1</li>
<li>Maîtrise de la méthodologie agile, avec sprints, daily...</li>
</ul>
</section>
<section id="diplomes">
<h2>Diplômes obtenus</h2>
<table>
<thead>
<tr>
<th scope="col">Période</th>
<th scope="col">Établissement</th>
<th scope="col">Niveau</th>
<th scope="col">Diplôme</th>
</tr>
</thead>
<tbody>
<tr>
<td>2022 - 2024</td>
<td><a href="https://ecole-du-digital.com">ESD Paris</a></td>
<td>Bac +3 / RNCP 6</td>
<td>Bachelor Développeur Web</td>
</tr>
<tr>
<td>2020 - 2022</td>
<td><a href="https://univ-gustave-eiffel.fr">Université Gustave Eiffel</a></td>
<td>Bac +2</td>
<td>DUT Informatique</td>
</tr>
<tr>
<td>2017 - 2020</td>
<td>Lycée Polyvalent René Cassin</td>
<td>Bac</td>
<td>Baccalauréat STI2D <i>(mention bien)</i></td>
</tr>
</tbody>
</table>
</section>
<section id="experiences">
<h2>Expériences professionnelles</h2>
<table>
<colgroup>
<col>
<col>
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">CDI Développeur Web Fullstack</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><strong>Entreprise</strong></th>
<td><a href="https://dollen.fr">Dollen</a></td>
</tr>
<tr>
<th scope="row"><strong>Période</strong></th>
<td>Nov. 2024 - En cours</td>
</tr>
<tr>
<th scope="row"><strong>Missions</strong></th>
<td>
<ul>
<li>Conception logiciel (diagramme de classes <strong>UML</strong>)</li>
<li>
Développement d'un back office
<ul>
<li>PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, Domain Driven
Design</li>
</ul>
</li>
<li>
Déploiement et intégration continue du back-office en production et pré-production
<ul>
<li>Docker, Debian, Nginx, GitHub Action
</ul>
</li>
<li>
Migration de base de données
<ul>
<li>SQLite, PostgreSQL, MariaDB</li>
</ul>
</li>
<li>Peer reviewing</li>
<li>
Gestion de projet Agile
<ul>
<li>Trello, GitHub Projects, daily sprints</li>
</ul>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<table>
<colgroup>
<col>
<col>
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">Apprenti Développeur Fullstack</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><strong>Entreprise</strong></th>
<td><a href="https://lecadeaupresent.com">Présent</a></td>
</tr>
<tr>
<th scope="row"><strong>Période</strong></th>
<td>Avr. 2023 - Sep. 2024 (1 an et 5 mois)</td>
</tr>
<tr>
<th scope="row"><strong>Missions</strong></th>
<td>
<ul>
<li>
Développement d'une
<ul>
<li>application mobile interne
<ul>
<li>Kotlin</li>
</ul>
</li>
<li>application mobile B2C
<ul>
<li>Flutter</li>
</ul>
</li>
<li>API REST monolithique
<ul>
<li>Typescript ,Fastify ,Prisma, Jest, MySql, Docker, GCP, Domain Driven
Design</li>
</ul>
</li>
<li>SPA pour Desktop et Mobile
<ul>
<li>React, Typescript, Zustand, Chakra UI</li>
</ul>
</li>
</ul>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
<table>
<colgroup>
<col>
<col>
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">Apprenti Développeur R&amp;D</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><strong>Entreprise</strong></th>
<td><a href="https://wattearth.com">WATT Earth</a></td>
</tr>
<tr>
<th scope="row"><strong>Période</strong></th>
<td>Oct. 2021 - Sept. 2022 (1 an)</td>
</tr>
<tr>
<th scope="row"><strong>Missions</strong></th>
<td>
<ul>
<li>
Conception et développement de fonctionnalités innovantes au cœur des produits WATT
Earth :
<ul>
<li>VueJS, Typescript, PHP, Python, MySql, Warp10, Grafana</li>
</ul>
</li>
<li>Maquettage et conception d'interfaces utilisateurs (<strong>Figma</strong>)</li>
<li>Réalisation de spécifications et de cahiers de recettes</li>
<li>
Intégration de technologies dans la solution de l'entreprise, correspondant aux
besoins client :
<ul>
<li>Modbus, LoRaWAN, Zigbee, ...</li>
</ul>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</section>
<section id="projets">
<h2>Projets récents</h2>
<table>
<colgroup>
<col>
<col>
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">GoCMS</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><strong>Description</strong></th>
<td>GoCMS est un projet de système de gestion de contenu développé en Go. Le but à terme de ce
projet en cours de developpement est de me faciliter la création de site web qui nécessitent
des fonctionnalités comme celles d'un Wordpress, notamment un système de blog et d'articles,
mais sans avoir recours à Wordpress ou équivalent. L'architecture du projet est en DDD,
Domain Driven Design, et le front de l'interface d'administration est généré en SSR via le
moteur de templating html de Go. J'utilise également Bootstrap pour avoir de belles
interface sans devoir fournir trop d'effort de développement front. J'héberge une démo du
projet en CI/CD via Jenkins sur mon serveur dédié Debian, contenerisé via Docker et exposé
via Nginx.</td>
</tr>
<tr>
<th scope="row"><strong>Stack</strong></th>
<td>Go, HTML, CSS, JS, Bootstrap, Docker, Nginx, DDD, Jenkins, Debian, SQLite, Git</td>
</tr>
<tr>
<th scope="row"><strong>Dépôt</strong></th>
<td><a href="https://github.com/Floriansylvain/GoCMS">GoCMS</a></td>
</tr>
</tbody>
</table>
<table>
<colgroup>
<col>
<col>
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">SnippetsManager</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><strong>Description</strong></th>
<td>Projet réalisé dans le cadre d'un cours de Framework Backend à l'ESD, inspiré de l'outil
open-source massCode dont le principe est de gérer des snippets (bouts de code). Il s'agit
d'une API conçu en Typescript, Express, Prisma, MySql, Docker et Jest.</td>
</tr>
<tr>
<th scope="row"><strong>Stack</strong></th>
<td>Typescript, Express, Prisma, MySql, Docker, Jest, NodeJS, Git, TSX</td>
</tr>
<tr>
<th scope="row"><strong>Dépôt</strong></th>
<td><a href="https://github.com/Floriansylvain/SnippetsManager">SnippetsManager</a></td>
</tr>
</tbody>
</table>
<table>
<colgroup>
<col>
<col>
</colgroup>
<thead>
<tr>
<th scope="col" colspan="2">spotify-playlist-embed</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><strong>Description</strong></th>
<td>Petit projet réalisé dans le cadre d'un cours de Framework Frontend à l'ESD, il s'agit d'un
lecteur de playlist spotify réalisé en NextJS et Typescript, avec la spotify-web-apinode
ainsi que Tailwindcss.</td>
</tr>
<tr>
<th scope="row"><strong>Stack</strong></th>
<td>NextJS, Typescript, Tailwindcss, Spotify API, TSX, Git, Vercel</td>
</tr>
<tr>
<th scope="row"><strong>Dépôt</strong></th>
<td><a
href="https://github.com/Floriansylvain/spotify-playlist-embed">spotify-playlist-embed</a>
</td>
</tr>
</tbody>
</table>
</section>
<section id="contact">
<h2>Contact</h2>
<p>Vous pouvez me contacter via les réseaux sociaux suivants :</p>
<ul>
<li><a href="https://linkedin.com/in/florian-sylvain-0651021a9">LinkedIn</a></li>
</ul>
<p>Autre moyens de contact indisponible sur ce portfolio pour éviter les crawlers malveillants et autre
récolte de données, qui résulteront en spam et démarchage incéssants.</p>
</section>
</main>
<footer id="autres">
<h2>Pourquoi un site aussi simple ?</h2>
<p>Ce site pèse moins de 14ko.
<a href="https://endtimes.dev/why-your-website-should-be-under-14kb-in-size">Why your website should be
under 14kB in size
</a>
</p> </p>
<a href="#header">Remonter &#8593;</a> <div class="cta">
<p>Dernière mise à jour : 2025/08/23</p> <a class="btn primary" href="#projects">Voir mes projets</a>
</footer> <a class="btn" href="#contact">Me contacter</a>
</div>
</div>
</section>
<section id="skills" class="card reveal">
<h2>Compétences et qualifications</h2>
<p class="lead">J'aime les outils simples et le code clair.</p>
<div class="skill-groups">
<div class="group">
<h3>Backend</h3>
<div class="tags">
<span class="tag">PHP</span><span class="tag">Symfony</span><span class="tag">Doctrine</span>
<span class="tag">Node</span><span class="tag">Fastify</span><span class="tag">Prisma</span>
<span class="tag">Jest</span><span class="tag">Go</span>
<span class="tag">Java</span><span class="tag">C#</span><span class="tag">C++</span>
</div>
</div>
<div class="group">
<h3>Frontend</h3>
<div class="tags">
<span class="tag">Vue</span>
<span class="tag">Nuxt</span>
<span class="tag">React</span>
<span class="tag">Next</span>
<span class="tag">TypeScript</span>
<span class="tag">HTML</span>
<span class="tag">CSS</span>
<span class="tag">Tailwind</span>
</div>
</div>
<div class="group">
<h3>Mobile</h3>
<div class="tags">
<span class="tag">Flutter</span>
<span class="tag">Kotlin</span>
<span class="tag">React Native</span>
</div>
</div>
<div class="group">
<h3>Base de données</h3>
<div class="tags">
<span class="tag">MySQL</span>
<span class="tag">PostgreSQL</span>
<span class="tag">SQLite</span>
<span class="tag">Supabase</span>
<span class="tag">Firebase</span>
</div>
</div>
<div class="group">
<h3>DevOps</h3>
<div class="tags">
<span class="tag">Docker</span>
<span class="tag">Nginx</span>
<span class="tag">Debian</span>
<span class="tag">CI/CD</span>
<span class="tag">GitHub Actions</span>
<span class="tag">Jenkins</span>
<span class="tag">Dockploy</span>
<span class="tag">Coolify</span>
<span class="tag">GCP</span>
<span class="tag">AWS</span>
</div>
</div>
<div class="group">
<h3>Pratiques</h3>
<div class="tags"><span class="tag">DDD</span><span class="tag">Tests</span><span class="tag">Agile</span>
<span class="tag">Anglais C1</span>
</div>
</div>
</div>
</section>
<section id="education" class="card reveal">
<h2>Diplômes obtenus</h2>
<div class="timeline">
<div class="t-item revealed">
<div class="t-role">Bachelor Développeur Web — <em>ESD Paris</em></div>
<div class="t-meta">2022 - 2024 • Bac +3 / RNCP 6</div>
</div>
<div class="t-item revealed">
<div class="t-role">DUT Informatique — <em>Université Gustave Eiffel</em></div>
<div class="t-meta">2020 - 2022 • Bac +2</div>
</div>
<div class="t-item revealed">
<div class="t-role">Baccalauréat STI2D <i>(mention bien) </i><em> Lycée René Cassin</em></div>
<div class="t-meta">2017 - 2020</div>
</div>
</div>
</section>
<section id="experience" class="card reveal">
<h2>Expériences professionnelles</h2>
<div class="timeline">
<div class="t-item">
<div class="t-role">CDI — Développeur Web Fullstack</div>
<div class="t-org">Entreprise : Dollen • Période : Nov. 2024 - En cours</div>
<ul class="t-bullets">
<li>Conception logiciel (diagrammes de classes <strong>UML</strong>).</li>
<li>Back-office: PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, DDD.</li>
<li>CI/CD et déploiement: Docker, Debian, Nginx, GitHub Actions.</li>
<li>Migrations: SQLite, PostgreSQL, MariaDB.</li>
<li>Peer review et gestion agile (Trello, GitHub Projects).</li>
</ul>
</div>
<div class="t-item">
<div class="t-role">Apprenti — Développeur Fullstack</div>
<div class="t-org">Entreprise : <a href="https://lecadeaupresent.com">Présent </a> • Avr. 2023 - Sep. 2024
</div>
<ul class="t-bullets">
<li>Apps mobiles: Kotlin (interne), Flutter (B2C).</li>
<li>API REST monolithique: TypeScript, Fastify, Prisma, Jest, MySQL, Docker, GCP, DDD.</li>
<li>SPA desktop/mobile: React, TypeScript, Zustand, Chakra UI.</li>
</ul>
</div>
<div class="t-item">
<div class="t-role">Apprenti — Développeur R&amp;D</div>
<div class="t-org">Entreprise : <a href="https://wattearth.com">WATT Earth</a> • Oct. 2021 - Sept. 2022</div>
<ul class="t-bullets">
<li>Fonctionnalités cœur produit: VueJS, TS, PHP, Python, MySQL, Warp10, Grafana.</li>
<li>Maquettage et conception UI (Figma).</li>
<li>Spécifications et cahiers de recettes.</li>
<li>Intégration technologies: Modbus, LoRaWAN, Zigbee…</li>
</ul>
</div>
</div>
</section>
<section id="projects" class="card reveal">
<h2>Projets récents</h2>
<div class="proj-grid">
<a class="proj" href="https://github.com/Floriansylvain/GoCMS" aria-label="Voir le dépôt GoCMS">
<h3>GoCMS</h3>
<p>CMS en Go pour accélérer la création de sites avec blog/SSR, architecture <abbr
title="Domain Driven Design">DDD</abbr>, CI/CD sur serveur Debian (Docker, Nginx, Jenkins).</p>
<div class="stack">
<span class="badge">Go</span><span class="badge">SSR</span><span class="badge">Docker</span><span
class="badge">Nginx</span><span class="badge">SQLite</span>
</div>
<p>Voir le dépôt →</p>
</a>
<a class="proj" href="https://github.com/Floriansylvain/SnippetsManager"
aria-label="Voir le dépôt SnippetsManager">
<h3>SnippetsManager</h3>
<p>API inspirée de massCode pour gérer des snippets. TypeScript, Express, Prisma, MySQL, Docker, Jest.</p>
<div class="stack">
<span class="badge">TypeScript</span><span class="badge">Express</span><span
class="badge">Prisma</span><span class="badge">MySQL</span>
</div>
<p>Voir le dépôt →</p>
</a>
<a class="proj" href="https://github.com/Floriansylvain/spotify-playlist-embed"
aria-label="Voir le dépôt spotify-playlist-embed">
<h3>spotify-playlist-embed</h3>
<p>Lecteur de playlist Spotify en Next.js + TS, avec spotify-web-api-node et TailwindCSS.</p>
<div class="stack">
<span class="badge">Next.js</span><span class="badge">TypeScript</span><span
class="badge">TailwindCSS</span><span class="badge">Spotify API</span>
</div>
<p>Voir le dépôt →</p>
</a>
</div>
</section>
<section id="contact" class="card reveal">
<h2>Contact</h2>
<p>Échangeons sur vos besoins, missions ou opportunités.</p>
<p><a href="https://linkedin.com/in/florian-sylvain-0651021a9">LinkedIn</a></p>
<p class="lead">Autres moyens de contact non listés pour éviter la collecte automatisée.</p>
</section>
</main>
<footer class="container reveal" id="autres">
<p>Pourquoi si simple ? Ce site reste léger et sobre. <a
href="https://endtimes.dev/why-your-website-should-be-under-14kb-in-size">Pourquoi viser &lt; 14 kB</a></p>
<a class="to-top" href="#top" aria-label="Revenir en haut"></a>
<p>Dernière mise à jour : 2025/08/23</p>
</footer>
<script src="script.js" defer></script>
</body> </body>
</html> </html>
+15
View File
@@ -0,0 +1,15 @@
{
"name": "compact-portfolio-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "compact-portfolio-server",
"version": "1.0.0",
"engines": {
"node": ">=16"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "compact-portfolio-server",
"version": "1.0.0",
"private": true,
"description": "Node server to serve minified, compressed portfolio",
"main": "server.js",
"type": "commonjs",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=16"
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

+77
View File
@@ -0,0 +1,77 @@
const prefersReduced =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const revealables = document.querySelectorAll(".reveal");
if (prefersReduced) {
revealables.forEach((el) => el.classList.add("revealed"));
} else {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries)
if (e.isIntersecting) {
e.target.classList.add("revealed");
io.unobserve(e.target);
}
},
{ threshold: 0.08, rootMargin: "0px 0px -10% 0px" }
);
revealables.forEach((el) => io.observe(el));
}
const navLinks = [...document.querySelectorAll(".site-nav a")];
const sections = navLinks
.map((a) => document.querySelector(a.getAttribute("href")))
.filter(Boolean);
let currentSection = null;
const spy = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
const id = "#" + e.target.id;
navLinks.forEach((a) => a.removeAttribute("aria-current"));
const active = navLinks.find(
(a) => a.getAttribute("href") === id
);
if (active) active.setAttribute("aria-current", "page");
if (currentSection !== id) {
currentSection = id;
history.replaceState(null, null, id);
}
}
});
},
{ rootMargin: "-40% 0% -50% 0%", threshold: 0.01 }
);
sections.forEach((s) => s && spy.observe(s));
const menuBtn = document.getElementById("menuToggle");
const nav = document.getElementById("primaryNav");
if (menuBtn && nav) {
const closeMenu = () => {
nav.classList.remove("is-open");
menuBtn.setAttribute("aria-expanded", "false");
};
const openMenu = () => {
nav.classList.add("is-open");
menuBtn.setAttribute("aria-expanded", "true");
};
const toggleMenu = () => {
nav.classList.contains("is-open") ? closeMenu() : openMenu();
};
menuBtn.addEventListener("click", toggleMenu);
nav.addEventListener("click", (e) => {
const t = e.target;
if (t && t.matches("a")) closeMenu();
});
document.addEventListener("click", (e) => {
if (!nav.contains(e.target) && !menuBtn.contains(e.target)) closeMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu();
});
}
+595
View File
@@ -0,0 +1,595 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const crypto = require("crypto");
const PORT = process.env.PORT || 3000;
const ROOT = __dirname;
const cspHashes = {
scripts: new Set(),
styles: new Set(),
};
function calculateHash(content, algorithm = "sha256") {
return crypto
.createHash(algorithm)
.update(content, "utf8")
.digest("base64");
}
function extractInlineContent(html) {
const scriptMatches =
html.match(/<script(?![^>]*src=)[^>]*>([\s\S]*?)<\/script>/gi) || [];
const styleMatches = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || [];
cspHashes.scripts.clear();
cspHashes.styles.clear();
scriptMatches.forEach((match) => {
const content = match.replace(/<script[^>]*>|<\/script>/gi, "").trim();
if (content) {
const hash = calculateHash(content);
cspHashes.scripts.add(`'sha256-${hash}'`);
console.log(`Inline script hash: sha256-${hash}`);
}
});
styleMatches.forEach((match) => {
const content = match.replace(/<style[^>]*>|<\/style>/gi, "").trim();
if (content) {
const hash = calculateHash(content);
cspHashes.styles.add(`'sha256-${hash}'`);
console.log(`Inline style hash: sha256-${hash}`);
}
});
}
function generateCSP() {
const scriptSources = Array.from(cspHashes.scripts);
const styleSources = Array.from(cspHashes.styles);
let scriptSrc = "'self'";
if (scriptSources.length > 0) {
scriptSrc += ` ${scriptSources.join(" ")}`;
}
if (scriptSources.length === 0) {
scriptSrc += " 'unsafe-inline'";
}
let styleSrc = "'self'";
if (styleSources.length > 0) {
styleSrc += ` ${styleSources.join(" ")}`;
}
if (styleSources.length === 0) {
styleSrc += " 'unsafe-inline'";
}
const csp = [
"default-src 'self'",
`script-src ${scriptSrc}`,
`style-src ${styleSrc}`,
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"media-src 'self'",
"object-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"manifest-src 'self'",
].join("; ");
const hashCount = scriptSources.length + styleSources.length;
console.log(`Generated CSP with ${hashCount} inline content hashes:`);
console.log(`${csp}\n`);
return csp;
}
function minifyCSS(src) {
let out = src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\s+/g, " ")
.replace(/\s*([{}:;,>+~\(\)\[\]'"*\/])\s*/g, "$1")
.replace(/;}/g, "}")
.replace(/\b0+(px|em|rem|vh|vw|%|s|ms)\b/g, "0")
.replace(/\b0+\.(\d+)/g, ".$1") // 0.5 -> .5
.replace(/#([0-9a-fA-F])\1([0-9a-fA-F])\2([0-9a-fA-F])\3\b/g, "#$1$2$3")
.replace(
/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/g,
(m, r, g, b) => {
const toHex = (n) => parseInt(n).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
)
.replace(/calc\(\s*([^)]+)\s*\)/g, (m, expr) => {
return `calc(${expr.replace(/\s+/g, "")})`;
})
.replace(/(['"])([a-zA-Z0-9\-_]+)\1/g, (m, q, val) => {
if (/^[a-zA-Z\-_][a-zA-Z0-9\-_]*$/.test(val)) return val;
return m;
})
.replace(
/([^{}]+)\{([^}]*)\}([^{}]+)\{([^}]*)\}/g,
(m, sel1, rules1, sel2, rules2) => {
if (rules1 === rules2) return `${sel1},${sel2}{${rules1}}`;
return m;
}
)
.replace(/[^{}]+\{\s*\}/g, "")
.replace(/(margin|padding):([^;]+)/g, (m, prop, vals) => {
const parts = vals.trim().split(/\s+/);
if (parts.length === 4) {
const [t, r, b, l] = parts;
if (t === r && r === b && b === l) return `${prop}:${t}`;
if (t === b && r === l) return `${prop}:${t} ${r}`;
} else if (parts.length === 2) {
const [tb, lr] = parts;
if (tb === lr) return `${prop}:${tb}`;
}
return m;
})
.replace(/border-radius:([^;]+)/g, (m, vals) => {
const parts = vals.trim().split(/\s+/);
if (parts.length === 4 && parts.every((p) => p === parts[0])) {
return `border-radius:${parts[0]}`;
}
return m;
})
.replace(
/-webkit-(transform|transition|animation|box-shadow|border-radius):/g,
"$1:"
)
.replace(
/-moz-(transform|transition|animation|box-shadow|border-radius):/g,
"$1:"
)
.replace(/-ms-(transform|transition|animation):/g, "$1:")
.replace(
/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g,
"translate($1,$2)"
)
.replace(/scale\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (m, x, y) => {
return x === y ? `scale(${x})` : `scale(${x},${y})`;
})
.replace(/@media\s+([^{]+)\s*\{/g, "@media $1{")
.replace(/linear-gradient\(\s*([^)]+)\s*\)/g, (m, args) => {
return `linear-gradient(${args.replace(/\s*,\s*/g, ",")})`;
})
.replace(/radial-gradient\(\s*([^)]+)\s*\)/g, (m, args) => {
return `radial-gradient(${args.replace(/\s*,\s*/g, ",")})`;
})
.replace(/,\s+/g, ",")
.replace(/:\s+/g, ":")
.replace(/;\s+/g, ";")
.replace(/\{\s+/g, "{")
.replace(/\s+\}/g, "}")
.replace(/>\s+/g, ">")
.replace(/\+\s+/g, "+")
.replace(/~\s+/g, "~");
return out.replace(/\r?\n/g, "").trim();
}
function minifyHTML(src) {
let out = src
.replace(/<!--(?!\[if)[\s\S]*?-->/g, "")
.replace(/>\s+</g, "><")
.replace(/>\s+([^<]+?)\s+</g, ">$1<")
.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (m, css) => {
return m.replace(css, minifyCSS(css));
})
.replace(/\s+([\w-]+)=["']([^"'\s<>]+)["']/g, (m, attr, val) => {
if (/^[a-zA-Z0-9._:/-]+$/.test(val)) return ` ${attr}=${val}`;
return m;
})
.replace(/\s*=\s*/g, "=")
.replace(/\s+>/g, ">")
.replace(/\s{2,}/g, " ")
.replace(/<([^>]+)\s+>/g, "<$1>")
.replace(/\s+[\w-]+=["']["']/g, "")
.replace(/\s+type=["']text\/(javascript|css)["']/g, "");
return out.replace(/\r?\n/g, "").trim();
}
function minifyJS(src) {
let out = "";
let i = 0,
n = src.length;
let inStr = false,
strQuote = "",
inTemplate = false,
escape = false;
while (i < n) {
const ch = src[i];
const next = src[i + 1];
if (inStr) {
out += ch;
if (!escape && ch === strQuote) {
inStr = false;
strQuote = "";
}
escape = !escape && ch === "\\";
i++;
continue;
}
if (inTemplate) {
out += ch;
if (!escape && ch === "`") {
inTemplate = false;
}
escape = !escape && ch === "\\";
i++;
continue;
}
if (ch === '"' || ch === "'") {
inStr = true;
strQuote = ch;
out += ch;
i++;
continue;
}
if (ch === "`") {
inTemplate = true;
out += ch;
i++;
continue;
}
if (ch === "/" && next === "*") {
i += 2;
while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++;
i += 2;
continue;
}
if (ch === "/" && next === "/") {
i += 2;
while (i < n && src[i] !== "\n") i++;
continue;
}
out += ch;
i++;
}
let finalOut = "";
inStr = false;
strQuote = "";
inTemplate = false;
escape = false;
for (let j = 0; j < out.length; j++) {
const ch = out[j];
const prev = finalOut[finalOut.length - 1];
if (inStr) {
finalOut += ch;
if (!escape && ch === strQuote) {
inStr = false;
strQuote = "";
}
escape = !escape && ch === "\\";
continue;
}
if (inTemplate) {
finalOut += ch;
if (!escape && ch === "`") {
inTemplate = false;
}
escape = !escape && ch === "\\";
continue;
}
if (ch === '"' || ch === "'") {
inStr = true;
strQuote = ch;
finalOut += ch;
continue;
}
if (ch === "`") {
inTemplate = true;
finalOut += ch;
continue;
}
if (/\s/.test(ch)) {
let k = j + 1;
while (k < out.length && /\s/.test(out[k])) k++;
const nxt = out[k];
if (
(/[a-zA-Z0-9_$]/.test(prev) && /[a-zA-Z0-9_$]/.test(nxt)) ||
(prev === "return" && /[a-zA-Z0-9_$"]/.test(nxt))
) {
finalOut += " ";
}
j = k - 1;
continue;
}
if (/[=;:,{}()<>+\-*\/\?|&!]/.test(ch) && finalOut.endsWith(" ")) {
finalOut = finalOut.slice(0, -1);
}
finalOut += ch;
}
return finalOut
.replace(/\r?\n/g, "")
.replace(/;}/g, "}")
.replace(/===true/g, "")
.replace(/!==false/g, "")
.replace(/==true/g, "")
.replace(/!=false/g, "")
.replace(/\["([a-zA-Z_$][a-zA-Z0-9_$]*)"\]/g, ".$1")
.trim();
}
const files = new Map();
function contentTypeFor(fp) {
const ext = path.extname(fp).toLowerCase();
switch (ext) {
case ".html":
return "text/html; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".js":
return "application/javascript; charset=utf-8";
case ".webp":
return "image/webp";
case ".xml":
return "application/xml; charset=utf-8";
case ".txt":
return "text/plain; charset=utf-8";
default:
return "application/octet-stream";
}
}
function cacheControlFor(fp) {
const ext = path.extname(fp).toLowerCase();
if (ext === ".html") return "no-cache";
if (ext === ".css" || ext === ".js")
return "public, max-age=31536000, immutable";
if (ext === ".webp") return "public, max-age=31536000, immutable";
if (ext === ".xml" || ext === ".txt") return "public, max-age=3600";
return "public, max-age=3600";
}
function etag(buf) {
let h = 2166136261 >>> 0;
for (let i = 0; i < buf.length; i++) {
h ^= buf[i];
h = Math.imul(h, 16777619);
}
return 'W/"' + (h >>> 0).toString(16) + "-" + buf.length + '"';
}
function preCompress(buf) {
return new Promise((resolve) => {
const result = { raw: buf };
try {
zlib.brotliCompress(
buf,
{
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
},
},
(err, br) => {
if (!err && br) result.br = br;
zlib.gzip(
buf,
{
level: 9,
windowBits: 15,
memLevel: 9,
},
(gzErr, gz) => {
if (!gzErr && gz) result.gz = gz;
resolve(result);
}
);
}
);
} catch {
zlib.gzip(buf, { level: 9 }, (gzErr, gz) => {
if (!gzErr && gz) result.gz = gz;
resolve(result);
});
}
});
}
async function loadAndMinify(relPath, minify) {
const abs = path.join(ROOT, relPath);
const src = await fs.promises.readFile(abs);
const originalContent = src.toString("utf8");
if (relPath === "index.html") {
extractInlineContent(originalContent);
}
const body = minify ? Buffer.from(minify(originalContent), "utf8") : src;
const compressed = await preCompress(body);
const stats = await fs.promises.stat(abs);
const originalSize = src.length;
const minifiedSize = body.length;
const brSize = compressed.br?.length || 0;
const gzSize = compressed.gz?.length || 0;
console.log(`${relPath}:`);
console.log(` Original: ${originalSize} bytes`);
if (minify)
console.log(
` Minified: ${minifiedSize} bytes (${(
((originalSize - minifiedSize) / originalSize) *
100
).toFixed(1)}% reduction)`
);
if (brSize)
console.log(
` Brotli: ${brSize} bytes (${(
(brSize / originalSize) *
100
).toFixed(1)}% of original)`
);
if (gzSize)
console.log(
` Gzip: ${gzSize} bytes (${((gzSize / originalSize) * 100).toFixed(
1
)}% of original)`
);
console.log("");
const entry = {
path: relPath,
mtimeMs: stats.mtimeMs,
type: contentTypeFor(relPath),
cache: cacheControlFor(relPath),
body,
etag: etag(body),
br: compressed.br,
brEtag: compressed.br ? etag(compressed.br) : undefined,
gz: compressed.gz,
gzEtag: compressed.gz ? etag(compressed.gz) : undefined,
};
files.set("/" + relPath.replace(/\\/g, "/"), entry);
}
async function build() {
console.log("Building and compressing files...\n");
await loadAndMinify("index.html", minifyHTML);
await loadAndMinify("style.css", minifyCSS);
await loadAndMinify("script.js", minifyJS);
for (const f of [
"favicon.webp",
"profile.webp",
"robots.txt",
"sitemap.xml",
]) {
if (fs.existsSync(path.join(ROOT, f))) {
await loadAndMinify(f, null);
}
}
const cspPolicy = generateCSP();
files.set("__csp__", cspPolicy);
}
function setSecurityHeaders(res) {
const cspPolicy = files.get("__csp__");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-XSS-Protection", "1; mode=block");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
res.setHeader(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()"
);
if (cspPolicy) {
res.setHeader("Content-Security-Policy", cspPolicy);
}
}
function negotiateEncoding(req) {
const ae = req.headers["accept-encoding"] || "";
const hasBr = /\bbr\b/.test(ae);
const hasGz = /\bgzip\b/.test(ae);
return { br: hasBr, gz: hasGz };
}
function serveEntry(req, res, entry) {
setSecurityHeaders(res);
res.setHeader("Content-Type", entry.type);
res.setHeader("Cache-Control", entry.cache);
res.setHeader("Vary", "Accept-Encoding");
const enc = negotiateEncoding(req);
let body = entry.body;
let tag = entry.etag;
if (enc.br && entry.br) {
body = entry.br;
tag = entry.brEtag;
res.setHeader("Content-Encoding", "br");
} else if (enc.gz && entry.gz) {
body = entry.gz;
tag = entry.gzEtag;
res.setHeader("Content-Encoding", "gzip");
}
res.setHeader("ETag", tag);
if (req.headers["if-none-match"] === tag) {
res.statusCode = 304;
return res.end();
}
res.statusCode = 200;
res.end(body);
}
function notFound(req, res) {
setSecurityHeaders(res);
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("404 Not Found");
}
function handler(req, res) {
const url = new URL(req.url, `http://${req.headers.host}`);
let pathname = url.pathname;
if (pathname === "/") pathname = "/index.html";
const entry = files.get(pathname);
if (entry) return serveEntry(req, res, entry);
const fallback = path.join(ROOT, pathname);
if (fs.existsSync(fallback) && fs.statSync(fallback).isFile()) {
const raw = fs.readFileSync(fallback);
const tmpEntry = {
type: contentTypeFor(fallback),
cache: cacheControlFor(fallback),
body: raw,
etag: etag(raw),
};
return serveEntry(req, res, tmpEntry);
}
return notFound(req, res);
}
async function start() {
await build();
const server = http.createServer(handler);
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
}
start().catch((err) => {
console.error("Failed to start server:", err);
process.exit(1);
});
+514
View File
@@ -0,0 +1,514 @@
/* Base */
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
:root {
--background: #0e120e;
--foreground: #e2f1e2;
--muted: #8aa78a;
--surface: #101710;
--surface-2: #142014;
--border: #182418;
--accent: #7fc27f;
--accent-2: #a7e6a7;
--font-body: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
--font-heading: ui-rounded, system-ui, -apple-system, Segoe UI, Roboto,
Arial, sans-serif;
}
body {
margin: 0;
color: var(--foreground);
background: radial-gradient(
80rem 50rem at 110% -10%,
#1a321a10 20%,
transparent 60%
),
radial-gradient(60rem 40rem at -10% 10%, #2a4a2a14 20%, transparent 60%),
var(--background);
font-family: var(--font-body);
line-height: 1.65;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
/* Top Bar */
.site-header {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: saturate(1.2) blur(6px);
border-bottom: 1px solid #1b281b;
background: linear-gradient(180deg, #0f150fdd, #0f150fb0 60%, #0f150f80);
height: 70px;
display: flex;
align-items: center;
}
.site-header .inner {
display: flex;
align-items: center;
gap: 16px;
justify-content: space-between;
position: relative;
height: 100%;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
color: var(--foreground);
text-decoration: none;
}
.logo {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
font-weight: 700;
font-family: var(--font-heading);
color: #0e120e;
background: conic-gradient(
from 120deg,
var(--accent),
var(--accent-2),
var(--accent) 75%
);
box-shadow: 0 1px 0 #0008, 0 6px 24px #0006;
border: 1px solid #2a392a;
}
.site-nav {
display: flex;
gap: 16px;
align-items: center;
}
.site-nav a {
color: var(--muted);
text-decoration: none;
padding: 10px 8px;
border-radius: 8px;
position: relative;
}
.site-nav a:hover,
.site-nav a[aria-current="page"] {
color: var(--foreground);
}
.site-nav a::after {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 6px;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0;
transform: scaleX(0.5);
transition: opacity 0.25s, transform 0.25s;
}
.site-nav a:hover::after,
.site-nav a[aria-current="page"]::after {
opacity: 1;
transform: none;
}
/* Mobile nav toggle */
.menu-toggle {
display: none;
align-items: center;
gap: 8px;
border: 1px solid #2a392a;
background: var(--surface);
color: var(--foreground);
border-radius: 10px;
padding: 8px 10px;
cursor: pointer;
}
.menu-toggle .bars {
width: 18px;
height: 12px;
position: relative;
display: inline-block;
}
.menu-toggle .bars::before,
.menu-toggle .bars::after,
.menu-toggle .bars span {
content: "";
position: absolute;
left: 0;
right: 0;
height: 2px;
background: var(--foreground);
border-radius: 2px;
}
.menu-toggle .bars::before {
top: 0;
}
.menu-toggle .bars::after {
bottom: 0;
}
.menu-toggle .bars span {
top: 5px;
}
/* Hero */
.hero {
display: block;
text-align: center;
}
.hero-card {
max-width: 800px;
margin: 0 auto;
display: block;
}
.eyebrow {
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.85rem;
margin: 0 0 6px;
text-align: center;
}
.hero h1 {
font-family: var(--font-heading);
font-size: clamp(1.9rem, 4.2vw, 2.8rem);
line-height: 1.15;
margin: 0 0 10px;
color: var(--accent-2);
margin-bottom: 1.5rem;
}
.hero p {
margin: 0 0 14px;
margin-bottom: 2rem;
}
.cta {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
/* Remove any image-specific styles that might still be present */
.hero img,
.hero-image,
.about-image {
display: none;
}
/* Sections */
section {
margin-top: 28px;
}
.card {
background: color-mix(in srgb, var(--surface) 96%, black 4%);
border: 1px solid var(--border);
border-radius: 16px;
padding: 18px;
}
h2 {
font-family: var(--font-heading);
font-size: clamp(1.2rem, 2.2vw, 1.4rem);
color: var(--accent);
margin: 0 0 10px;
}
p.lead {
color: var(--muted);
margin-top: 0;
}
/* Skills (grouped tags) */
.skill-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.group {
background: linear-gradient(180deg, #111a11, #0d130d);
border: 1px solid #223222;
border-radius: 14px;
padding: 12px;
}
.group h3 {
margin: 0 0 8px;
font-size: 0.95rem;
color: var(--accent-2);
letter-spacing: 0.02em;
font-weight: 600;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
border: 1px solid #263626;
border-radius: 999px;
padding: 4px 8px;
font-size: 0.85rem;
color: var(--muted);
}
/* Timeline */
.timeline {
position: relative;
padding-left: 20px;
}
.timeline::before {
content: "";
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: #204020;
}
.t-item {
position: relative;
margin: 14px 0;
padding-left: 18px;
}
.t-item::before {
content: "";
position: absolute;
left: -1px;
top: 0.45rem;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px #0f150f;
}
.t-role {
font-weight: 600;
}
.t-org a {
color: var(--accent);
}
.t-bullets {
margin: 0.3rem 0 0.2rem 1rem;
}
/* Projects */
.proj-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.proj {
border: 1px solid var(--border);
border-radius: 14px;
background: linear-gradient(180deg, #131b13, #0f150f);
padding: 14px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.proj:hover {
transform: translateY(-3px);
box-shadow: 0 14px 40px #0006;
}
.stack {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.badge {
border: 1px solid #263626;
border-radius: 999px;
padding: 4px 8px;
color: var(--muted);
font-size: 0.85rem;
}
/* Links */
a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent);
text-decoration-thickness: 0.08em;
text-underline-offset: 2px;
}
a:hover {
color: var(--accent-2);
text-decoration-color: var(--accent-2);
}
a:visited {
color: var(--accent);
}
/* Urgent banner */
.banner {
background: linear-gradient(180deg, #1a231a, #121812);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
margin-top: 18px;
}
.badge-urgent {
background: #ff5858;
color: #0e120e;
border: 1px solid #ff9a9a;
border-radius: 999px;
padding: 4px 10px;
font-weight: 700;
font-family: var(--font-heading);
letter-spacing: 0.02em;
}
.banner .btn {
margin-left: auto;
}
/* Links */
a {
color: var(--accent);
text-decoration: underline;
text-decoration-color: var(--accent);
text-decoration-thickness: 0.08em;
text-underline-offset: 2px;
}
a:hover {
color: var(--accent-2);
text-decoration-color: var(--accent-2);
}
a:visited {
color: var(--accent);
}
/* Clickable project cards */
.proj {
text-decoration: none;
color: inherit;
display: block;
}
.proj:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* Footer */
footer {
border-top: 1px solid #1e2a1e;
margin-top: 38px;
padding: 18px 0;
color: var(--muted);
}
footer .to-top {
float: right;
}
/* Reveal + Scrollspy */
.reveal {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.6s ease, transform 0.6s ease;
will-change: opacity, transform;
}
.revealed {
opacity: 1;
transform: none;
}
/* Responsive */
@media (max-width: 840px) {
.hero {
grid-template-columns: 1fr;
}
.avatar {
width: 140px;
height: 140px;
}
}
@media (max-width: 760px) {
.container {
padding: 16px;
}
.menu-toggle {
display: inline-flex;
}
.site-nav {
position: fixed;
left: 16px;
right: 16px;
top: 78px; /* 70px header height + 8px gap */
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px;
gap: 0;
display: none;
box-shadow: 0 10px 30px #0006;
z-index: 25;
}
.site-nav.is-open {
display: flex;
flex-direction: column;
}
.site-nav a {
display: block;
padding: 12px 10px;
border-radius: 8px;
}
}