feat: refine portfolio content, contact, and supporting media
This commit is contained in:
@@ -96,6 +96,18 @@
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5"
|
||||
},
|
||||
{
|
||||
"id": "contact-avatar",
|
||||
"fileName": "project-contact-avatar.webp",
|
||||
"sha256": "42f014f90f6d5cb12cf7eecd8819a8b4eb1c3a7fc6c703c12681e9f21bd8b7fc",
|
||||
"mediaType": "image/webp",
|
||||
"width": 264,
|
||||
"height": 284,
|
||||
"durationMs": null,
|
||||
"rightsHolder": "Luca Pîslaru",
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "currency-converter-interface",
|
||||
"fileName": "project-currency-converter.webp",
|
||||
@@ -108,6 +120,18 @@
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "healthcare-platform-interface",
|
||||
"fileName": "project-healthcare-interface.webp",
|
||||
"sha256": "9a8b10be64c3461b6a16d4fb0b45db30cb0014c5356fea82bf05c23ae3818ca1",
|
||||
"mediaType": "image/webp",
|
||||
"width": 1280,
|
||||
"height": 478,
|
||||
"durationMs": null,
|
||||
"rightsHolder": "Luca Pîslaru",
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "medication-mobile-data-settings",
|
||||
"fileName": "project-medication-data-settings.webp",
|
||||
@@ -168,6 +192,42 @@
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "private-game-loop-poster",
|
||||
"fileName": "project-private-game-loop-poster.webp",
|
||||
"sha256": "0c23f90028a7338cd0fddc4bf4fca885d6085f89b170a22a8e4a6384ec041835",
|
||||
"mediaType": "image/webp",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"durationMs": null,
|
||||
"rightsHolder": "Luca Pîslaru",
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "private-game-loop-mp4",
|
||||
"fileName": "project-private-game-loop.mp4",
|
||||
"sha256": "30fc75ffbbf1c1fc048ed70cd744051c5a642b1b642847d242ffcb16c3d20a59",
|
||||
"mediaType": "video/mp4",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"durationMs": 5000,
|
||||
"rightsHolder": "Luca Pîslaru",
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "private-game-loop",
|
||||
"fileName": "project-private-game-loop.webm",
|
||||
"sha256": "8e4455cc1849a9d5e24c49c2d40b574dc5a50a876da4e861867bcedf7888b78e",
|
||||
"mediaType": "video/webm",
|
||||
"width": 960,
|
||||
"height": 540,
|
||||
"durationMs": 5000,
|
||||
"rightsHolder": "Luca Pîslaru",
|
||||
"license": "All rights reserved",
|
||||
"attribution": "Luca Pîslaru"
|
||||
},
|
||||
{
|
||||
"id": "social-preview-nightdrive",
|
||||
"fileName": "project-social-preview.webp",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"schema": "portfolio-asset-recipes/v1",
|
||||
"assets": [
|
||||
{ "id": "contact-avatar", "kind": "image", "output": "project-contact-avatar.webp", "maxWidth": 264, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||
{ "id": "healthcare-platform-interface", "kind": "image", "output": "project-healthcare-interface.webp", "maxWidth": 1280, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||
{ "id": "private-game-loop", "kind": "video", "output": "project-private-game-loop.webm", "maxWidth": 960, "startSeconds": 3, "durationSeconds": 5, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||
{ "id": "medication-mobile-logs", "kind": "image", "output": "project-medication-logs.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||
{ "id": "medication-mobile-data-settings", "kind": "image", "output": "project-medication-data-settings.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||
{ "id": "medication-mobile-notification-settings", "kind": "image", "output": "project-medication-notification-settings.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"role": "System Administrator & Full-Stack Developer",
|
||||
"valueProposition": "I build full-stack software and operate the Linux systems behind it, from offline-first applications to access-controlled self-hosted services and careful data migrations.",
|
||||
"location": { "city": "Bucharest", "country": "Romania", "countryCode": "RO" },
|
||||
"availability": "Based in Bucharest and open to on-site, hybrid, or remote roles locally, as well as international remote opportunities."
|
||||
"availability": "I’m based in Bucharest and open to local on-site, hybrid, and remote roles, as well as international remote opportunities."
|
||||
},
|
||||
"navigation": [
|
||||
{ "id": "start", "label": "Start" },
|
||||
@@ -32,10 +32,10 @@
|
||||
"dates": "August 2025-Present",
|
||||
"location": "Bucharest, Romania",
|
||||
"highlights": [
|
||||
"Built the theatre group's first owned web presence, replacing a social-media-only discovery path with an indexable website and direct contact options.",
|
||||
"Developed a custom Vue frontend and a headless WordPress publishing workflow that lets an editor publish news and promotional content without changing code.",
|
||||
"Configured role-separated CMS access and restricted administrative routes and endpoints while keeping exact access rules private.",
|
||||
"Manage the domain, cPanel hosting, DNS/TLS configuration, custom .htaccess behavior, and controlled Git/SSH deployments with verification and rollback."
|
||||
"I built the theatre group’s first website, giving visitors a place to find performances, news, and contact details beyond social media.",
|
||||
"I developed a custom Vue frontend and connected it to headless WordPress, so an editor can publish news and promotional content without changing code.",
|
||||
"I configured separate administrator and editor roles and restricted access to administrative routes and endpoints.",
|
||||
"I manage the domain and hosting, configure DNS and TLS, and deploy releases through Git and SSH with a verification and rollback process."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -45,8 +45,8 @@
|
||||
"dates": "June 2025-August 2025",
|
||||
"location": "Bucharest, Romania",
|
||||
"highlights": [
|
||||
"Contributed to eco-innovation work within the INOVATIV 2030 program.",
|
||||
"Developed and presented sustainable-technology business models."
|
||||
"I contributed to eco-innovation work within the INOVATIV 2030 program.",
|
||||
"I developed and presented business models for sustainable technologies."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -56,58 +56,58 @@
|
||||
"dates": "June 2025-August 2025",
|
||||
"location": "Bucharest, Romania",
|
||||
"highlights": [
|
||||
"Processed and analyzed datasets using SPSS and Microsoft Excel.",
|
||||
"Prepared quantitative reports supporting business-development decisions."
|
||||
"I processed and analyzed datasets using SPSS and Microsoft Excel.",
|
||||
"I prepared quantitative reports to support business-development decisions."
|
||||
]
|
||||
}
|
||||
],
|
||||
"systemsOperations": {
|
||||
"id": "professional-systems-operations",
|
||||
"title": "Professional systems operations",
|
||||
"summary": "Application delivery and hosting work with explicit boundaries between public content, authenticated administration, controlled releases, and recovery.",
|
||||
"summary": "I look after both application delivery and hosting, from the editing workflow to access controls, releases, and recovery.",
|
||||
"evidence": [
|
||||
"Public Vue frontend backed by a role-separated headless WordPress editing workflow.",
|
||||
"Private source control followed by a deliberate SSH deployment, post-release verification, and Git-based rollback path.",
|
||||
"Domain, DNS, TLS, cPanel, and restricted-route administration performed alongside application development."
|
||||
"I give editors a publishing workflow while keeping administration separate from the public website.",
|
||||
"I use version control to prepare releases, verify deployments, and roll back changes when needed.",
|
||||
"I maintain the domain, hosting, certificates, and access rules alongside the application."
|
||||
]
|
||||
},
|
||||
"infrastructure": {
|
||||
"id": "self-hosted-platform-lifecycle",
|
||||
"title": "Secure self-hosted platform and lifecycle",
|
||||
"summary": "A self-hosted Linux environment operated as a long-term systems practice: traffic enters through a controlled edge, selected services receive an additional identity boundary, changes are staged deliberately, and important data has local and offsite recovery paths.",
|
||||
"summary": "I run a self-hosted Linux environment that I use every day. I manage how services are reached, who can access them, how they’re updated, and how their data can be recovered.",
|
||||
"bands": [
|
||||
{
|
||||
"id": "ingress-and-tls",
|
||||
"title": "Ingress and TLS",
|
||||
"summary": "Route services through Nginx, manage DNS and certificate lifecycle, and use Fail2Ban at exposed entry points."
|
||||
"summary": "I configure reverse proxies, DNS, and TLS to make services reachable through controlled, encrypted entry points."
|
||||
},
|
||||
{
|
||||
"id": "identity-and-access",
|
||||
"title": "Identity and access",
|
||||
"summary": "Place selected services behind Authelia and constrain administrative, filesystem, and container access without publishing the underlying policy."
|
||||
"summary": "I manage authentication and permissions, adding access controls around services and limiting administrative access."
|
||||
},
|
||||
{
|
||||
"id": "service-lifecycle",
|
||||
"title": "Service lifecycle",
|
||||
"summary": "Deploy and update services with Docker Compose, including a full platform migration from a Raspberry Pi to a dedicated Linux server."
|
||||
"summary": "I deploy and update containerized services, manage configuration, and migrate workloads when the platform needs to change."
|
||||
},
|
||||
{
|
||||
"id": "operations-and-recovery",
|
||||
"title": "Operations and recovery",
|
||||
"summary": "Use Btrfs storage, a pre-upgrade backup script, rsync-based copies, and an offsite backup for important data."
|
||||
}
|
||||
"id": "operations-and-recovery",
|
||||
"title": "Operations and recovery",
|
||||
"summary": "I prepare backups before major upgrades and keep local and offsite copies of important data."
|
||||
}
|
||||
],
|
||||
"featuredOperation": {
|
||||
"title": "Migrating a stateful Nextcloud service",
|
||||
"context": "The existing standalone deployment contained much more than files: contacts, calendars, tasks, and collaboration data all had to survive a move to Nextcloud All-in-One.",
|
||||
"context": "I moved my daily-use Nextcloud deployment to All-in-One. Alongside files, I needed to preserve contacts, calendars, tasks, and collaboration data.",
|
||||
"actions": [
|
||||
"Aligned application versions and updated installed applications before migration.",
|
||||
"Backed up the source deployment and prepared rollback checkpoints.",
|
||||
"Converted the existing MySQL database to PostgreSQL before the All-in-One migration.",
|
||||
"Exported and imported application state, transferred the data directory, corrected ownership and permissions, and preserved required instance configuration.",
|
||||
"Validated access to the expected files and services before relying on the new deployment."
|
||||
"I aligned application versions and updated installed apps before the move.",
|
||||
"I backed up the existing deployment and prepared rollback checkpoints.",
|
||||
"I converted the MySQL database to PostgreSQL before migrating to All-in-One.",
|
||||
"I transferred application state and files, corrected ownership and permissions, and preserved the instance configuration.",
|
||||
"I checked access to files and services before switching to the new deployment."
|
||||
],
|
||||
"outcome": "The migration completed without data loss. The All-in-One deployment has remained Luca's active Nextcloud instance for roughly a year, including storage, synchronization, contacts, calendars, tasks, and Nextcloud Talk."
|
||||
"outcome": "I completed the migration without data loss and have continued using the same instance for roughly a year—for file sync, contacts, calendars, tasks, and Nextcloud Talk."
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
@@ -117,17 +117,17 @@
|
||||
"title": "Private self-hosted medication tracker",
|
||||
"visibility": "private",
|
||||
"maturity": "active",
|
||||
"summary": "A solo-built, self-hosted medication-tracking system with an offline-first Flutter client, TypeScript API, PostgreSQL database, and Vue administration interface.",
|
||||
"problem": "A private medication routine needed dependable offline tracking, reminders, history, and synchronization without turning personal data into a public product.",
|
||||
"contribution": "Independently designed, implemented, deployed, and operated the complete mobile app, API, administration interface, database, synchronization model, and self-hosted environment.",
|
||||
"summary": "I built a self-hosted medication tracker from scratch, with an offline-first Flutter app, a TypeScript API, PostgreSQL, and a Vue administration interface.",
|
||||
"problem": "I wanted tracking, reminders, and history that would work offline and synchronize with a server I control.",
|
||||
"contribution": "I designed, built, deployed, and operated the complete system: mobile app, API, administration interface, database, and synchronization model.",
|
||||
"technicalDecisions": [
|
||||
"Use Flutter, Riverpod, and Drift/SQLite so the mobile workflow remains useful offline and can queue changes before pulling server updates.",
|
||||
"Use Fastify, TypeScript, Drizzle, and PostgreSQL for the API and synchronized data model, alongside a Vue administration interface.",
|
||||
"Apply authentication, refresh-token, TOTP, backup-code, biometric-lock, and database access-boundary mechanisms while continuing to harden the system through tests and audit findings.",
|
||||
"Run the containerized service behind Nginx/TLS and an additional Authelia access layer.",
|
||||
"Support schedules, as-needed entries, stock tracking, dose history, reminders, background sync, exports, analytics, an Android widget, and English/Romanian localization."
|
||||
"I used Flutter, Riverpod, and Drift/SQLite to keep the app useful offline, queue local changes, and pull server updates when a connection is available.",
|
||||
"I built the API with Fastify, TypeScript, Drizzle, and PostgreSQL, alongside a Vue administration interface.",
|
||||
"I added authentication, token renewal, two-factor authentication, recovery codes, biometric locking, and database access controls, then used tests and audits to identify further improvements.",
|
||||
"I deployed the containerized service behind Nginx and TLS, with an additional Authelia access layer.",
|
||||
"I added schedules, as-needed entries, stock tracking, history, reminders, background sync, exports, analytics, an Android widget, and English/Romanian localization."
|
||||
],
|
||||
"outcome": "The system has supported Luca's private medication tracking consistently for several months. Real use exposed Android reminder failures; investigation restored dependable delivery, and current work is replacing the remaining recurring behavior with occurrence-specific reconciliation to address duplicate and stale reminders. The project works for its current private use but is not presented as production-ready.",
|
||||
"outcome": "I’ve used the system privately for several months. When Android reminders arrived inconsistently, I investigated the scheduling process and improved delivery. I’m now moving toward native alarms and reminders scheduled per occurrence to address duplicate and stale notifications. It works for my current use, with further work needed before a wider release.",
|
||||
"assetIds": [
|
||||
"medication-mobile-logs",
|
||||
"medication-mobile-data-settings",
|
||||
@@ -138,9 +138,9 @@
|
||||
{
|
||||
"kind": "image",
|
||||
"assetId": "medication-mobile-logs",
|
||||
"alt": "Medication activity screen using an unmistakably fictional demo profile.",
|
||||
"caption": "Daily activity and history in a fictional demo state.",
|
||||
"longDescription": "The mobile view groups scheduled entries and their recorded status without exposing real medication information."
|
||||
"alt": "Medication activity screen with a fictional demo profile.",
|
||||
"caption": "Daily activity and history, shown with demo data.",
|
||||
"longDescription": "Scheduled entries and their recorded status are grouped in a single activity view."
|
||||
},
|
||||
{
|
||||
"kind": "image",
|
||||
@@ -163,16 +163,16 @@
|
||||
"title": "Footy Tracker",
|
||||
"visibility": "public",
|
||||
"maturity": "active",
|
||||
"summary": "A two-person university computer-vision project that processed previously unseen football footage and generated an annotated output video.",
|
||||
"problem": "Build a time-constrained demonstration that could identify football players, referees, and the ball in match footage and explore jersey-number recognition.",
|
||||
"contribution": "Led the project's data and machine-learning work: dataset acquisition and conversion, YOLO configuration and detector training, jersey-classifier design, experimentation, checkpointing, evaluation, and demo integration. A collaborator created the initial repository structure and video-processing pipeline.",
|
||||
"summary": "I led the data and machine-learning work on a two-person university project that detects players and the ball in football footage and produces annotated video.",
|
||||
"problem": "We needed to detect players and the ball in unseen match footage under a university deadline and limited training resources. We also explored referee and jersey-number recognition.",
|
||||
"contribution": "I handled dataset preparation, YOLO training, jersey-classifier design, experiments, evaluation, and demo integration. My collaborator created the initial repository structure and video-processing pipeline.",
|
||||
"technicalDecisions": [
|
||||
"Convert SoccerNet tracking data into a reproducible YOLO training layout and keep training behavior configuration-driven.",
|
||||
"Compare pretrained ResNet, MobileNet, and EfficientNet variants for jersey classification.",
|
||||
"Add class weighting, checkpoint/resume, focal loss, mixup/cutmix, label smoothing, and early stopping as controlled experiments rather than assuming each technique would improve the model.",
|
||||
"Prefer the simpler ResNet34 configuration after an eight-hour ablation set showed that aggressive augmentation increased overfitting and reduced validation performance."
|
||||
"I converted SoccerNet tracking data into a reproducible YOLO training layout and controlled training through configuration files.",
|
||||
"I compared pretrained ResNet, MobileNet, and EfficientNet variants for jersey classification.",
|
||||
"I tested class weighting, focal loss, mixup/cutmix, label smoothing, and early stopping, with checkpoints so training could resume.",
|
||||
"I chose a simpler ResNet34 configuration after an eight-hour set of ablation experiments showed that aggressive augmentation increased overfitting and reduced validation performance."
|
||||
],
|
||||
"outcome": "The submitted demo processed previously unseen, compressed broadcast footage and retained strong player detection. Measured validation results were 0.942 mAP@0.5 for players, 0.726 for referees, and 0.198 for the ball. The best jersey classifier reached approximately 0.778 validation accuracy, while integrated jersey labels remained inconsistent. The result is a working academic demonstration, not a production tracking system.",
|
||||
"outcome": "I tested the demo on previously unseen, compressed broadcast footage, where player detection held up well. My measured validation results were 0.942 mAP@0.5 for players, 0.726 for referees, and 0.198 for the ball. The best jersey classifier reached approximately 0.778 validation accuracy, but jersey labels remained inconsistent in the integrated demo. We delivered a working academic demonstration; ball detection and jersey recognition still need improvement.",
|
||||
"url": "https://github.com/LucaPisl/footy-tracker",
|
||||
"assetIds": []
|
||||
},
|
||||
@@ -181,15 +181,15 @@
|
||||
"title": "Opinion Dynamics Simulation",
|
||||
"visibility": "public",
|
||||
"maturity": "experimental",
|
||||
"summary": "An agent-based research experiment for observing how local interactions, network structure, and cascade conditions shape group-level opinion patterns.",
|
||||
"problem": "Explore how simplified local rules can produce polarization, rapid cascades, and uneven final distributions.",
|
||||
"contribution": "Built the simulation and analysis views used to inspect modeled runs and compare changes over time.",
|
||||
"summary": "I built an agent-based simulation to explore how local interactions and network structure shape opinion patterns across a group.",
|
||||
"problem": "I wanted to explore how simple local rules can produce polarization, rapid cascades, and uneven final distributions.",
|
||||
"contribution": "I built the simulation and analysis views to inspect runs and compare changes over time.",
|
||||
"technicalDecisions": [
|
||||
"Keep the work explicitly experimental rather than presenting it as a predictive model of real people.",
|
||||
"Expose polarization, cascade thresholds, graph degree, revolt frequency, and final distributions as inspectable outputs.",
|
||||
"Document performance and network-formation limitations alongside the results."
|
||||
"I used simplified agents and rules to study modeled behavior.",
|
||||
"I exposed polarization, cascade thresholds, graph degree, revolt frequency, and final distributions as inspectable outputs.",
|
||||
"I documented performance and network-formation limitations alongside the results."
|
||||
],
|
||||
"outcome": "The project produced useful exploratory visualizations and made the model's assumptions and limitations visible; conclusions remain limited to the simulated scenarios.",
|
||||
"outcome": "I used the visualizations to compare simulated scenarios and examine the model’s assumptions. These results describe the simulation; they do not predict how real people behave.",
|
||||
"url": "https://github.com/LucaPisl/opinion-dynamics-simulation",
|
||||
"assetIds": [
|
||||
"opinion-polarization-chart",
|
||||
@@ -233,39 +233,43 @@
|
||||
"title": "Private game prototype",
|
||||
"visibility": "private",
|
||||
"maturity": "prototype",
|
||||
"summary": "A private three-week prototype built to test a compact gameplay loop under a deliberately small scope.",
|
||||
"problem": "Test a game concept quickly while keeping the scope deliberately small.",
|
||||
"contribution": "Built and iterated on the prototype during a three-week development window.",
|
||||
"summary": "I built a side-view action prototype over three weeks, focusing on a short, playable loop.",
|
||||
"problem": "I wanted to test a game concept within a short development window.",
|
||||
"contribution": "I developed and iterated on the prototype, keeping the scope small enough to play and refine quickly.",
|
||||
"technicalDecisions": [
|
||||
"Focused the work on a short gameplay loop rather than a full production release."
|
||||
"I focused development on the core gameplay loop."
|
||||
],
|
||||
"outcome": "No real title, link, video, or identifying media.",
|
||||
"assetIds": []
|
||||
"outcome": "The clip shows the prototype in play, with character movement and pursuing enemies.",
|
||||
"assetIds": ["private-game-loop", "private-game-loop-mp4", "private-game-loop-poster"],
|
||||
"mediaLayout": "single",
|
||||
"media": [{ "kind": "video", "assetId": "private-game-loop-mp4", "fallbackAssetId": "private-game-loop", "posterAssetId": "private-game-loop-poster", "alt": "Side-view action prototype with a moving character and pursuing enemies.", "caption": "A short gameplay loop from my three-week prototype.", "longDescription": "The character moves through a blue industrial arena while enemies follow." }]
|
||||
},
|
||||
{
|
||||
"id": "private-healthcare-platform",
|
||||
"title": "Private healthcare platform",
|
||||
"visibility": "private",
|
||||
"maturity": "active",
|
||||
"summary": "A confidentiality-safe summary of Luca's web-interface contribution.",
|
||||
"problem": "A healthcare workflow needed a focused web-based interface.",
|
||||
"contribution": "Contributed to the private platform's web-interface experience.",
|
||||
"summary": "I contributed to the web interface for a healthcare platform.",
|
||||
"problem": "I worked on presenting the platform’s purpose through a focused web interface.",
|
||||
"contribution": "The landing page introduces bloodwork and treatment tracking, with clear sign-in and registration actions.",
|
||||
"technicalDecisions": [
|
||||
"Keeps product details and any identifying interface data outside the public portfolio."
|
||||
"I worked within the platform’s visual direction and web experience."
|
||||
],
|
||||
"outcome": "No product identity, private link, screenshot, or health data.",
|
||||
"assetIds": []
|
||||
"outcome": "This view shows the public-facing entry point to the platform.",
|
||||
"assetIds": ["healthcare-platform-interface"],
|
||||
"mediaLayout": "single",
|
||||
"media": [{ "kind": "image", "assetId": "healthcare-platform-interface", "alt": "Healthcare landing page with a purple visual theme and a registration action.", "caption": "Landing-page detail." }]
|
||||
},
|
||||
{
|
||||
"id": "currency-converter",
|
||||
"title": "Currency Converter",
|
||||
"visibility": "public",
|
||||
"maturity": "learning",
|
||||
"summary": "A scoped .NET/Avalonia desktop project integrating external exchange-rate data and practicing typed UI development.",
|
||||
"problem": "Practice a focused desktop workflow for currency conversion with external exchange-rate data.",
|
||||
"contribution": "Implemented the application as a scoped learning exercise.",
|
||||
"technicalDecisions": ["Uses .NET and Avalonia to explore a typed desktop interface."],
|
||||
"outcome": "A scoped project for developing desktop application fundamentals.",
|
||||
"summary": "I built a currency-conversion desktop app with C#/.NET and Avalonia, using external exchange-rate data.",
|
||||
"problem": "I wanted to practice desktop UI development with a useful data-driven workflow.",
|
||||
"contribution": "I implemented currency selection, dated conversions, and a view for comparing results across several destination currencies.",
|
||||
"technicalDecisions": ["I used .NET and Avalonia to build a typed desktop interface."],
|
||||
"outcome": "The project helped me develop desktop application and external-data integration skills.",
|
||||
"url": "https://github.com/LucaPisl/CurrencyConverterAvalonia",
|
||||
"assetIds": ["currency-converter-interface"],
|
||||
"mediaLayout": "single",
|
||||
@@ -284,13 +288,13 @@
|
||||
"title": "Linux Modding Guide",
|
||||
"visibility": "public",
|
||||
"maturity": "active",
|
||||
"summary": "Ongoing community documentation focused on practical Linux troubleshooting and reusable technical explanations.",
|
||||
"problem": "Useful Linux troubleshooting knowledge benefits from clear, reusable documentation.",
|
||||
"contribution": "Wrote and maintained community-facing technical guidance.",
|
||||
"summary": "I write and maintain community documentation for practical Linux troubleshooting.",
|
||||
"problem": "I wanted to turn troubleshooting knowledge into guidance other people could follow and reuse.",
|
||||
"contribution": "I explain the steps and reasoning behind resolving issues, so readers can apply the approach to their own setup.",
|
||||
"technicalDecisions": [
|
||||
"Favors practical explanations that help readers understand and resolve issues."
|
||||
"I favor practical explanations that help readers understand and resolve issues."
|
||||
],
|
||||
"outcome": "An ongoing example of practical technical writing.",
|
||||
"outcome": "I continue to maintain the guide as a resource for the community.",
|
||||
"url": "https://github.com/LucaPisl/LinuxModdingGuide",
|
||||
"assetIds": []
|
||||
}
|
||||
@@ -308,8 +312,6 @@
|
||||
{ "name": "TLS" },
|
||||
{ "name": "Authelia" },
|
||||
{ "name": "Fail2Ban" },
|
||||
{ "name": "Btrfs" },
|
||||
{ "name": "rsync" },
|
||||
{ "name": "Nextcloud" },
|
||||
{ "name": "Proxmox/KVM" },
|
||||
{ "name": "Git" }
|
||||
@@ -334,9 +336,7 @@
|
||||
{ "name": "PostgreSQL" },
|
||||
{ "name": "SQL" },
|
||||
{ "name": "Drift/SQLite" },
|
||||
{ "name": "Drizzle ORM" },
|
||||
{ "name": "SPSS" },
|
||||
{ "name": "Microsoft Excel" }
|
||||
{ "name": "Drizzle ORM" }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -344,24 +344,24 @@
|
||||
"technologies": [
|
||||
{ "name": "PyTorch" },
|
||||
{ "name": "Ultralytics/YOLO" },
|
||||
{ "name": "OpenCV" },
|
||||
{ "name": "SoccerNet" }
|
||||
{ "name": "OpenCV" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"contact": {
|
||||
"introduction": "I’m open to System Administrator and Full-Stack Developer opportunities in Bucharest, including on-site, hybrid, and remote work, as well as international remote roles.",
|
||||
"email": "luca.pislaru@gmail.com",
|
||||
"introduction": "I’m looking for System Administrator or Full-Stack Developer roles in Bucharest—on-site, hybrid, or remote—and remote opportunities internationally.",
|
||||
"email": { "localPart": "luca.pislaru", "domain": "gmail.com" },
|
||||
"avatarAssetId": "contact-avatar",
|
||||
"social": [
|
||||
{ "service": "LinkedIn", "url": "https://linkedin.com/in/luca-pisl" },
|
||||
{ "service": "GitHub", "url": "https://github.com/LucaPisl" },
|
||||
{ "service": "GitLab", "url": "https://gitlab.com/luca_pisl" },
|
||||
{ "service": "LinkedIn", "url": "https://linkedin.com/in/luca-pisl" }
|
||||
{ "service": "GitLab", "url": "https://gitlab.com/luca_pisl" }
|
||||
],
|
||||
"cvMessage": "For a current CV or role-specific information, contact me directly."
|
||||
"cvMessage": "Get in touch for my CV or to talk about a role."
|
||||
},
|
||||
"metadata": {
|
||||
"title": "Luca Pîslaru — System Administrator & Full-Stack Developer",
|
||||
"description": "Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.",
|
||||
"description": "I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.",
|
||||
"socialPreviewAssetId": "social-preview-nightdrive"
|
||||
},
|
||||
"soundtrack": {
|
||||
|
||||
BIN
inputs/prepared-assets/project-contact-avatar.webp
Normal file
BIN
inputs/prepared-assets/project-contact-avatar.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
inputs/prepared-assets/project-healthcare-interface.webp
Normal file
BIN
inputs/prepared-assets/project-healthcare-interface.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
inputs/prepared-assets/project-private-game-loop-poster.webp
Normal file
BIN
inputs/prepared-assets/project-private-game-loop-poster.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
inputs/prepared-assets/project-private-game-loop.mp4
Normal file
BIN
inputs/prepared-assets/project-private-game-loop.mp4
Normal file
Binary file not shown.
BIN
inputs/prepared-assets/project-private-game-loop.webm
Normal file
BIN
inputs/prepared-assets/project-private-game-loop.webm
Normal file
Binary file not shown.
14
playwright.content.config.ts
Normal file
14
playwright.content.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Focused checks against an already built, prerendered dist/ directory.
|
||||
// These do not need the full suite's fault-injection client.
|
||||
export default defineConfig({
|
||||
testDir: './tests/browser',
|
||||
testMatch: 'contact-refinements.spec.ts',
|
||||
workers: 2,
|
||||
use: { serviceWorkers: 'block' },
|
||||
projects: [
|
||||
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile-chromium', use: { ...devices['Pixel 5'] } },
|
||||
],
|
||||
});
|
||||
@@ -98,6 +98,7 @@ const readManifest = async (repositoryRoot) => {
|
||||
|
||||
const assetReferences = (content) => {
|
||||
const references = [];
|
||||
if (content.contact?.avatarAssetId !== undefined) references.push(content.contact.avatarAssetId);
|
||||
const projects = content.projects;
|
||||
if (typeof projects !== 'object' || projects === null || Array.isArray(projects)) {
|
||||
throw new Error('Portfolio content must contain project collections.');
|
||||
|
||||
@@ -9,12 +9,11 @@ const publicMetadata = Object.freeze({
|
||||
name: 'Luca Pîslaru',
|
||||
role: 'System Administrator & Full-Stack Developer',
|
||||
title: 'Luca Pîslaru — System Administrator & Full-Stack Developer',
|
||||
description: 'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.',
|
||||
email: 'mailto:luca.pislaru@gmail.com',
|
||||
description: 'I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.',
|
||||
sameAs: Object.freeze([
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
'https://github.com/LucaPisl',
|
||||
'https://gitlab.com/luca_pisl',
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
]),
|
||||
location: Object.freeze({ city: 'Bucharest', countryCode: 'RO' }),
|
||||
socialImagePath: '/assets/project-social-preview.webp',
|
||||
@@ -121,13 +120,13 @@ function verifyMetadata(html, canonicalUrl) {
|
||||
try { jsonLd = JSON.parse(jsonLdText); } catch { throw new Error('JSON-LD is not valid JSON.'); }
|
||||
exactObjectKeys(jsonLd, ['@context', '@type', 'name', 'description', 'url', 'mainEntity'], 'ProfilePage JSON-LD');
|
||||
const person = jsonLd.mainEntity;
|
||||
exactObjectKeys(person, ['@type', 'name', 'jobTitle', 'description', 'url', 'email', 'homeLocation', 'sameAs'], 'Person JSON-LD');
|
||||
exactObjectKeys(person, ['@type', 'name', 'jobTitle', 'description', 'url', 'homeLocation', 'sameAs'], 'Person JSON-LD');
|
||||
exactObjectKeys(person.homeLocation, ['@type', 'address'], 'homeLocation JSON-LD');
|
||||
exactObjectKeys(person.homeLocation.address, ['@type', 'addressLocality', 'addressCountry'], 'PostalAddress JSON-LD');
|
||||
if (jsonLd['@context'] !== 'https://schema.org' || jsonLd['@type'] !== 'ProfilePage' ||
|
||||
jsonLd.name !== publicMetadata.name || jsonLd.description !== description || jsonLd.url !== canonicalUrl ||
|
||||
person['@type'] !== 'Person' || person.name !== publicMetadata.name || person.jobTitle !== publicMetadata.role ||
|
||||
person.description !== description || person.url !== canonicalUrl || person.email !== publicMetadata.email ||
|
||||
person.description !== description || person.url !== canonicalUrl ||
|
||||
person.homeLocation['@type'] !== 'Place' || person.homeLocation.address['@type'] !== 'PostalAddress' ||
|
||||
person.homeLocation.address.addressLocality !== publicMetadata.location.city ||
|
||||
person.homeLocation.address.addressCountry !== publicMetadata.location.countryCode ||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { loadProductionInputs } from '../src/content/load-production-inputs.ts';
|
||||
import { createPageMetadata, createStaticHostArtifacts } from '../src/content/metadata.ts';
|
||||
import { contactAddress, readableContactAddress } from '../src/content/contact-email.ts';
|
||||
import { Window } from 'happy-dom';
|
||||
import { parseArtifactContract, parseBuildAuthority } from './build-contract.mjs';
|
||||
import { parseExactConfigArgument } from './config-argument.mjs';
|
||||
@@ -232,19 +233,31 @@ const assertSemanticHtml = (html, inputs, contract) => {
|
||||
...inputs.content.infrastructure.bands.flatMap((band) => [band.title, band.summary]),
|
||||
...inputs.content.projects.selected.flatMap((project) => [project.title, project.summary]),
|
||||
...inputs.content.toolkit.flatMap((group) => [group.title, ...group.technologies.map((technology) => technology.name)]),
|
||||
inputs.content.contact.email,
|
||||
];
|
||||
for (const copy of requiredVisibleCopy) {
|
||||
if (!semanticText.includes(copy)) throw new Error(`Missing visible semantic evidence: ${copy}`);
|
||||
}
|
||||
const mailLinks = [...document.querySelectorAll('a[href^="mailto:"]')];
|
||||
const startMailLinks = [...document.querySelectorAll('#start a[href^="mailto:"]')];
|
||||
const contactMailLinks = [...document.querySelectorAll('#contact a[href^="mailto:"]')];
|
||||
if (mailLinks.length !== 2 || startMailLinks.length !== 1 || contactMailLinks.length !== 1 || mailLinks.some((link) =>
|
||||
link.getAttribute('href') !== `mailto:${inputs.content.contact.email}` || link.textContent !== 'Email Luca'
|
||||
)) {
|
||||
throw new Error('Mailto links must be exactly one Email Luca action beneath each hero and contact section.');
|
||||
const contactActions = [...document.querySelectorAll('#start a[href="#contact"]')];
|
||||
if (contactActions.length !== 1 || contactActions[0].textContent !== 'Contact Me' ||
|
||||
!isVisibleSemanticNode(contactActions[0], appRoot)) {
|
||||
throw new Error('The hero must contain one visible Contact Me link to the contact section.');
|
||||
}
|
||||
if (document.querySelector('a[href^="mailto:"]') || html.includes(contactAddress(inputs.content.contact.email))) {
|
||||
throw new Error('The initial document must not expose a complete email address or mailto link.');
|
||||
}
|
||||
const disclosures = [...document.querySelectorAll('#contact details#contact-email-details')];
|
||||
if (disclosures.length !== 1 || disclosures[0].hasAttribute('open') ||
|
||||
disclosures[0].querySelector(':scope > summary')?.textContent !== 'Email Me' ||
|
||||
!isVisibleSemanticNode(disclosures[0], appRoot)) {
|
||||
throw new Error('Contact must contain a closed native Email Me disclosure.');
|
||||
}
|
||||
disclosures[0].setAttribute('open', '');
|
||||
const readableEmail = disclosures[0].querySelector('.contact-email__address');
|
||||
if (readableEmail?.textContent !== readableContactAddress(inputs.content.contact.email) ||
|
||||
!isVisibleSemanticNode(readableEmail, appRoot)) {
|
||||
throw new Error('Email disclosure must provide a readable address without JavaScript.');
|
||||
}
|
||||
disclosures[0].removeAttribute('open');
|
||||
|
||||
const moduleScript = document.querySelector(`script[type="module"][src="/${contract.entryModule}"]`);
|
||||
const snapshot = document.querySelector('script#portfolio-inputs[type="application/json"]');
|
||||
@@ -353,7 +366,7 @@ const assertMetadata = (html, expected) => {
|
||||
} catch {
|
||||
throw new Error('JSON-LD is malformed.');
|
||||
}
|
||||
if (/"(?:streetAddress|postalCode|telephone|image|interactionStatistic)"\s*:/iu.test(jsonLdMatch[1])) {
|
||||
if (/"(?:streetAddress|postalCode|telephone|image|interactionStatistic|email)"\s*:/iu.test(jsonLdMatch[1])) {
|
||||
throw new Error('JSON-LD contains unsafe private, portrait, or interaction metadata.');
|
||||
}
|
||||
if (jsonLd['@type'] !== 'ProfilePage' || jsonLd.mainEntity?.['@type'] !== 'Person') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import './styles/components.css';
|
||||
import './styles/world.css';
|
||||
import './styles/evolution.css';
|
||||
import './styles/chromatic.css';
|
||||
import './styles/refinements.css';
|
||||
import './styles/print.css';
|
||||
|
||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useLazyMedia } from '@/composables/useLazyMedia';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
@@ -28,8 +28,12 @@ const playing = shallowRef(false);
|
||||
const unavailable = shallowRef(false);
|
||||
const status = shallowRef('Video ready to play.');
|
||||
const savedTime = shallowRef(0);
|
||||
const automatic = computed(() => props.allowAutomatic && !unavailable.value);
|
||||
const lazy = useLazyMedia(figure, automatic);
|
||||
const userPaused = ref(false);
|
||||
const pageVisible = ref(true);
|
||||
const enhanced = ref(false);
|
||||
const automatic = computed(() => props.allowAutomatic && !unavailable.value && !userPaused.value && pageVisible.value);
|
||||
const lazy = useLazyMedia(figure, automatic, '0px');
|
||||
const fallbackSource = computed(() => props.sources.find(({ type }) => type === 'video/mp4') ?? props.sources[0]);
|
||||
let failureTimer: number | undefined;
|
||||
|
||||
const clearFailureTimer = (): void => {
|
||||
@@ -51,7 +55,10 @@ const detach = async (): Promise<void> => {
|
||||
|
||||
watch(() => lazy.shouldAttach.value, (attached, previous) => {
|
||||
if (!attached && previous) void detach();
|
||||
if (attached) void nextTick(() => video.value?.load());
|
||||
if (attached) void nextTick(() => {
|
||||
// Explicit Play loads its sources before play(); a later load would abort it.
|
||||
if (!lazy.explicitRequested.value) video.value?.load();
|
||||
});
|
||||
}, { flush: 'post' });
|
||||
|
||||
watch(() => props.allowAutomatic, (allowed) => {
|
||||
@@ -92,6 +99,7 @@ const play = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const pause = (): void => {
|
||||
userPaused.value = true;
|
||||
video.value?.pause();
|
||||
playing.value = false;
|
||||
status.value = 'Video paused.';
|
||||
@@ -100,9 +108,25 @@ const pause = (): void => {
|
||||
|
||||
const togglePlayback = (): void => {
|
||||
if (playing.value) pause();
|
||||
else void play();
|
||||
else {
|
||||
userPaused.value = false;
|
||||
void play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibility = (): void => {
|
||||
pageVisible.value = !document.hidden;
|
||||
if (!pageVisible.value) {
|
||||
video.value?.pause();
|
||||
lazy.clearExplicit();
|
||||
}
|
||||
};
|
||||
onMounted(() => {
|
||||
enhanced.value = true;
|
||||
handleVisibility();
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
});
|
||||
|
||||
const restoreTime = (): void => {
|
||||
const element = video.value;
|
||||
if (element !== null && Number.isFinite(savedTime.value) && savedTime.value > 0) {
|
||||
@@ -111,13 +135,14 @@ const restoreTime = (): void => {
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
clearFailureTimer();
|
||||
video.value?.pause();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media>
|
||||
<figure ref="figure" class="project-media project-media--video" data-project-media :data-enhanced="enhanced">
|
||||
<video
|
||||
ref="video"
|
||||
:poster="poster"
|
||||
@@ -125,7 +150,12 @@ onUnmounted(() => {
|
||||
:height="height"
|
||||
:aria-label="accessibleLabel"
|
||||
preload="none"
|
||||
muted
|
||||
loop
|
||||
:autoplay="automatic"
|
||||
playsinline
|
||||
@play="playing = true; status = 'Video playing.'"
|
||||
@pause="playing = false"
|
||||
@loadedmetadata="restoreTime"
|
||||
@error="markUnavailable"
|
||||
@ended="playing = false; status = 'Video ended.'"
|
||||
@@ -145,5 +175,6 @@ onUnmounted(() => {
|
||||
</button>
|
||||
<p data-testid="video-status">{{ status }}</p>
|
||||
</div>
|
||||
<a v-if="fallbackSource" class="video-fallback" :href="fallbackSource.src" target="_blank" rel="noopener noreferrer">Open gameplay video</a>
|
||||
</figure>
|
||||
</template>
|
||||
|
||||
@@ -226,7 +226,7 @@ onUnmounted(() => removePageListeners());
|
||||
<div class="reading-stage">
|
||||
<div class="reading-viewport">
|
||||
<main id="portfolio-content" class="portfolio-main" tabindex="-1">
|
||||
<HeroJourney :identity="content.identity" :email="content.contact.email" @explore="navigation.onSectionLinkClick" />
|
||||
<HeroJourney :identity="content.identity" @explore="navigation.onSectionLinkClick" @contact="navigation.onSectionLinkClick" />
|
||||
|
||||
<PortfolioSection id="profile" :layout="sectionLayouts.profile" :heading="sectionLabel('profile')">
|
||||
<p>{{ content.profile.introduction }}</p>
|
||||
|
||||
@@ -1,17 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue';
|
||||
import BrandIcon from './BrandIcon.vue';
|
||||
import { productionInputsKey } from '@/app';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import { contactAddress, readableContactAddress } from '@/content/contact-email';
|
||||
import type { ContactContent } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
contact: Readonly<ContactContent>;
|
||||
}>();
|
||||
const inputs = inject(productionInputsKey, undefined);
|
||||
const avatar = computed(() => inputs?.manifest.assets.find(({ id }) => id === props.contact.avatarAssetId));
|
||||
const avatarUrl = computed(() => avatar.value && inputs ? assetUrl(inputs.manifest, avatar.value.id) : undefined);
|
||||
const enhanced = ref(false);
|
||||
const details = ref<HTMLDetailsElement | null>(null);
|
||||
const revealed = ref(false);
|
||||
const address = ref('');
|
||||
const copyStatus = ref('');
|
||||
const readableAddress = computed(() => readableContactAddress(props.contact.email));
|
||||
|
||||
onMounted(() => { enhanced.value = true; });
|
||||
const revealAddress = (): void => {
|
||||
address.value = contactAddress(props.contact.email);
|
||||
};
|
||||
const handleToggle = (): void => {
|
||||
revealed.value = details.value?.open ?? false;
|
||||
copyStatus.value = '';
|
||||
if (revealed.value) revealAddress();
|
||||
};
|
||||
const closeDisclosure = (): void => {
|
||||
if (details.value?.open) {
|
||||
details.value.open = false;
|
||||
details.value.querySelector('summary')?.focus();
|
||||
}
|
||||
};
|
||||
const copyAddress = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(contactAddress(props.contact.email));
|
||||
copyStatus.value = 'Copied';
|
||||
} catch {
|
||||
copyStatus.value = 'Select the address to copy it.';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="avatar && avatarUrl" class="contact-avatar" :src="avatarUrl" alt="Illustrated avatar" :width="avatar.width!" :height="avatar.height!" loading="lazy" decoding="async" />
|
||||
<div class="contact-destination">
|
||||
<p class="contact-destination__copy">{{ contact.introduction }}</p>
|
||||
<a class="contact-link" :href="`mailto:${contact.email}`">Email Luca</a>
|
||||
<p class="contact-destination__email">{{ contact.email }}</p>
|
||||
<div class="contact-email" :data-enhanced="enhanced" :data-revealed="revealed" @keydown.esc="closeDisclosure">
|
||||
<div class="contact-email__action" @pointerenter="revealAddress" @focusin="revealAddress">
|
||||
<a v-if="enhanced" class="contact-link" :href="address ? `mailto:${address}` : '#contact-email-details'" @pointerdown="revealAddress" @click="revealAddress">Email Me</a>
|
||||
<span v-if="enhanced" class="contact-email__preview" aria-hidden="true">{{ address }}</span>
|
||||
</div>
|
||||
<details id="contact-email-details" ref="details" class="contact-email__details" @toggle="handleToggle">
|
||||
<summary>{{ enhanced ? (revealed ? 'Hide email' : 'Show email') : 'Email Me' }}</summary>
|
||||
<div class="contact-email__disclosure">
|
||||
<span class="contact-email__address">{{ enhanced && address ? address : readableAddress }}</span>
|
||||
<button v-if="enhanced" type="button" class="contact-email__copy" @click="copyAddress">Copy email</button>
|
||||
<span v-if="enhanced" class="contact-email__status" role="status">{{ copyStatus }}</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<p class="contact-destination__cv">{{ contact.cvMessage }}</p>
|
||||
</div>
|
||||
<ul class="contact-social">
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { PortfolioContent } from '@/content/types';
|
||||
|
||||
defineProps<{
|
||||
identity: Readonly<PortfolioContent['identity']>;
|
||||
email: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
explore: [event: MouseEvent];
|
||||
contact: [event: MouseEvent];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,7 @@ const emit = defineEmits<{
|
||||
<p class="hero-journey__value">{{ identity.valueProposition }}</p>
|
||||
<p class="hero-journey__availability">{{ identity.availability }}</p>
|
||||
<div class="hero-journey__actions">
|
||||
<a class="hero-journey__action hero-journey__action--primary" :href="`mailto:${email}`">Email Luca</a>
|
||||
<a class="hero-journey__action hero-journey__action--primary" href="#contact" @click="emit('contact', $event)">Contact Me</a>
|
||||
<a class="hero-journey__action" href="#selected-builds" @click="emit('explore', $event)">Explore work</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ withDefaults(defineProps<{ project: Readonly<PublicProject>; allowAutomaticVideo
|
||||
:allow-automatic-video="allowAutomaticVideo"
|
||||
/>
|
||||
<p>{{ project.contribution }}</p>
|
||||
<p>{{ project.outcome }}</p>
|
||||
<a v-if="project.visibility === 'public' && project.url" class="project-link" :href="project.url" target="_blank" rel="noopener noreferrer">{{ project.title }}</a>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@ export const MEDIA_ACTIVATION_MARGIN = '300px 0px 300px 0px';
|
||||
export function useLazyMedia(
|
||||
target: Readonly<Ref<Element | null>>,
|
||||
allowAutomatic: MaybeRef<boolean>,
|
||||
activationMargin = MEDIA_ACTIVATION_MARGIN,
|
||||
): LazyMediaState {
|
||||
const nearby = shallowRef(false);
|
||||
const explicitRequested = shallowRef(false);
|
||||
@@ -44,7 +45,7 @@ export function useLazyMedia(
|
||||
const inActivationRange = entries.some((entry) => entry.isIntersecting);
|
||||
nearby.value = inActivationRange;
|
||||
if (!inActivationRange) clearExplicit();
|
||||
}, { root, rootMargin: MEDIA_ACTIVATION_MARGIN, threshold: 0 });
|
||||
}, { root, rootMargin: activationMargin, threshold: 0 });
|
||||
observer.observe(element);
|
||||
};
|
||||
observe();
|
||||
|
||||
9
src/content/contact-email.ts
Normal file
9
src/content/contact-email.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ContactContent } from './types';
|
||||
|
||||
// The page carries separate parts; a complete address is assembled only when
|
||||
// someone interacts. This deters simple harvesting, not a determined crawler.
|
||||
export const contactAddress = (email: ContactContent['email']): string =>
|
||||
`${email.localPart}@${email.domain}`;
|
||||
|
||||
export const readableContactAddress = (email: ContactContent['email']): string =>
|
||||
`${email.localPart} [at] ${email.domain.replaceAll('.', ' [dot] ')}`;
|
||||
@@ -22,7 +22,6 @@ interface PersonJsonLd {
|
||||
jobTitle: string;
|
||||
description: string;
|
||||
url: string;
|
||||
email: string;
|
||||
homeLocation: PlaceJsonLd;
|
||||
sameAs?: string[];
|
||||
}
|
||||
@@ -104,7 +103,6 @@ export const createPageMetadata = (
|
||||
jobTitle: content.identity.role,
|
||||
description: content.metadata.description,
|
||||
url: site.canonicalUrl,
|
||||
email: `mailto:${content.contact.email}`,
|
||||
homeLocation: {
|
||||
'@type': 'Place',
|
||||
address: {
|
||||
|
||||
@@ -201,7 +201,12 @@ const portfolioContentSchema = z.object({
|
||||
}).strict()).min(1),
|
||||
contact: z.object({
|
||||
introduction: visibleString,
|
||||
email: z.email(),
|
||||
email: z.object({ localPart: visibleString, domain: visibleString }).strict().refine(
|
||||
({ localPart, domain }) => !localPart.includes('@') && !domain.includes('@') &&
|
||||
z.email().safeParse(`${localPart}@${domain}`).success,
|
||||
'Contact email parts must form a valid address.',
|
||||
),
|
||||
avatarAssetId: opaqueId.optional(),
|
||||
social: z.array(z.object({
|
||||
service: z.enum(['GitHub', 'GitLab', 'LinkedIn']),
|
||||
url: httpUrl,
|
||||
@@ -334,6 +339,7 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs =
|
||||
const { content, manifest, site } = productionInputsSchema.parse(input);
|
||||
const manifestIds = new Set(manifest.assets.map((asset) => asset.id));
|
||||
const referencedAssetIds = [
|
||||
...(content.contact.avatarAssetId === undefined ? [] : [content.contact.avatarAssetId]),
|
||||
...content.projects.selected.flatMap((project) => project.assetIds),
|
||||
...content.projects.additional.flatMap((project) => project.assetIds),
|
||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||
|
||||
@@ -133,7 +133,8 @@ export interface ContactSocialLink {
|
||||
|
||||
export interface ContactContent {
|
||||
introduction: string;
|
||||
email: string;
|
||||
email: { localPart: string; domain: string };
|
||||
avatarAssetId?: string;
|
||||
social: readonly ContactSocialLink[];
|
||||
cvMessage: string;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
* Keep this union exhaustive when a new public technology or social service is
|
||||
* introduced so an icon cannot silently regress to a placeholder.
|
||||
*/
|
||||
export type BrandIconLabel =
|
||||
import { conceptIcons } from './concept-icons';
|
||||
|
||||
export type BrandIconLabel = keyof typeof conceptIcons
|
||||
| 'Linux'
|
||||
| 'Docker'
|
||||
| 'Nginx'
|
||||
@@ -144,6 +146,7 @@ export const brandIcons: Readonly<Record<BrandIconLabel, BrandIconDefinition>> =
|
||||
GitHub: fromSimpleIcons(siGithub),
|
||||
GitLab: fromSimpleIcons(siGitlab),
|
||||
LinkedIn: linkedIn,
|
||||
...conceptIcons,
|
||||
} as const;
|
||||
|
||||
export const brandIconLabels = Object.freeze(Object.keys(brandIcons) as BrandIconLabel[]);
|
||||
|
||||
36
src/icons/concept-icons.ts
Normal file
36
src/icons/concept-icons.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Concept symbols from @phosphor-icons/vue 2.2.1, regular weight (MIT).
|
||||
// These describe a protocol or capability; they are not product logos.
|
||||
export const conceptIcons = {
|
||||
"DNS": {
|
||||
"title": "DNS",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.62,87.62,0,0,1-6.4,32.94l-44.7-27.49a15.92,15.92,0,0,0-6.24-2.23l-22.82-3.08a16.11,16.11,0,0,0-16,7.86h-8.72l-3.8-7.86a15.91,15.91,0,0,0-11-8.67l-8-1.73L96.14,104h16.71a16.06,16.06,0,0,0,7.73-2l12.25-6.76a16.62,16.62,0,0,0,3-2.14l26.91-24.34A15.93,15.93,0,0,0,166,49.1l-.36-.65A88.11,88.11,0,0,1,216,128ZM143.31,41.34,152,56.9,125.09,81.24,112.85,88H96.14a16,16,0,0,0-13.88,8l-8.73,15.23L63.38,84.19,74.32,58.32a87.87,87.87,0,0,1,69-17ZM40,128a87.53,87.53,0,0,1,8.54-37.8l11.34,30.27a16,16,0,0,0,11.62,10l21.43,4.61L96.74,143a16.09,16.09,0,0,0,14.4,9h1.48l-7.23,16.23a16,16,0,0,0,2.86,17.37l.14.14L128,205.94l-1.94,10A88.11,88.11,0,0,1,40,128Zm102.58,86.78,1.13-5.81a16.09,16.09,0,0,0-4-13.9,1.85,1.85,0,0,1-.14-.14L120,174.74,133.7,144l22.82,3.08,45.72,28.12A88.18,88.18,0,0,1,142.58,214.78Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/globe-hemisphere-west.svg",
|
||||
"license": "MIT"
|
||||
},
|
||||
"TLS": {
|
||||
"title": "TLS",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M128,112a28,28,0,0,0-8,54.83V184a8,8,0,0,0,16,0V166.83A28,28,0,0,0,128,112Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,128,152Zm80-72H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/lock-key.svg",
|
||||
"license": "MIT"
|
||||
},
|
||||
"Fail2Ban": {
|
||||
"title": "Fail2Ban",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/shield-check.svg",
|
||||
"license": "MIT"
|
||||
},
|
||||
"SQL": {
|
||||
"title": "SQL",
|
||||
"hex": "AAB9CC",
|
||||
"path": "M128,24C74.17,24,32,48.6,32,80v96c0,31.4,42.17,56,96,56s96-24.6,96-56V80C224,48.6,181.83,24,128,24Zm80,104c0,9.62-7.88,19.43-21.61,26.92C170.93,163.35,150.19,168,128,168s-42.93-4.65-58.39-13.08C55.88,147.43,48,137.62,48,128V111.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64ZM69.61,53.08C85.07,44.65,105.81,40,128,40s42.93,4.65,58.39,13.08C200.12,60.57,208,70.38,208,80s-7.88,19.43-21.61,26.92C170.93,115.35,150.19,120,128,120s-42.93-4.65-58.39-13.08C55.88,99.43,48,89.62,48,80S55.88,60.57,69.61,53.08ZM186.39,202.92C170.93,211.35,150.19,216,128,216s-42.93-4.65-58.39-13.08C55.88,195.43,48,185.62,48,176V159.36c17.06,15,46.23,24.64,80,24.64s62.94-9.68,80-24.64V176C208,185.62,200.12,195.43,186.39,202.92Z",
|
||||
"viewBox": "0 0 256 256",
|
||||
"source": "https://github.com/phosphor-icons/core/blob/main/assets/regular/database.svg",
|
||||
"license": "MIT"
|
||||
}
|
||||
} as const;
|
||||
219
src/styles/refinements.css
Normal file
219
src/styles/refinements.css
Normal file
@@ -0,0 +1,219 @@
|
||||
/* Contact follows the same compact reading card as the rest of the journey. */
|
||||
#contact {
|
||||
position: relative;
|
||||
}
|
||||
#contact > h2 {
|
||||
padding-inline-end: 110px;
|
||||
}
|
||||
.contact-avatar {
|
||||
position: absolute;
|
||||
inset-block-start: 76px;
|
||||
inset-inline-end: clamp(28px, 4vw, 58px);
|
||||
inline-size: 88px;
|
||||
block-size: 96px;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 5px 12px rgb(0 0 0 / 20%));
|
||||
}
|
||||
.contact-destination__copy {
|
||||
font-size: clamp(23px, 1.95vw, 29px);
|
||||
line-height: 1.3;
|
||||
max-inline-size: 34ch;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.contact-email {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
}
|
||||
.contact-email__action {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.contact-email .contact-link {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.contact-email__preview {
|
||||
position: absolute;
|
||||
inset-inline-start: 116px;
|
||||
white-space: nowrap;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(-24px);
|
||||
transition: opacity 200ms ease, transform 260ms ease, visibility 260ms;
|
||||
}
|
||||
@media (hover: hover) and (min-width: 768px) {
|
||||
.contact-email__action:is(:hover, :focus-within) .contact-email__preview {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.contact-email__details {
|
||||
display: contents;
|
||||
}
|
||||
.contact-email__details > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
.contact-email__details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.contact-email__details:not([open]) > .contact-email__disclosure {
|
||||
display: none;
|
||||
}
|
||||
.contact-email__disclosure {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 14px;
|
||||
padding-block-start: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.contact-email__address {
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.contact-email__copy {
|
||||
min-block-size: 44px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-email__status {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.contact-email[data-enhanced='false'] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.contact-email[data-enhanced='false'] .contact-email__action {
|
||||
display: none;
|
||||
}
|
||||
.contact-email[data-enhanced='false'] summary {
|
||||
inline-size: fit-content;
|
||||
padding-inline: 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--cyan);
|
||||
color: #201031;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
.portfolio-section .contact-social > li {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.contact-social {
|
||||
align-items: center;
|
||||
gap: 12px 20px;
|
||||
margin-block-end: 0;
|
||||
padding-block-start: 16px !important;
|
||||
}
|
||||
.contact-social-link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-card .project-media-gallery {
|
||||
margin-block: 20px;
|
||||
}
|
||||
.project-card .project-media {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.project-media--video video {
|
||||
display: block;
|
||||
inline-size: 100%;
|
||||
block-size: auto;
|
||||
}
|
||||
.video-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.project-media--video[data-enhanced='false'] .video-controls {
|
||||
display: none;
|
||||
}
|
||||
.video-controls > p {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.video-fallback {
|
||||
display: block;
|
||||
margin-block-start: 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.contact-avatar {
|
||||
inset-block-start: 48px;
|
||||
inline-size: 64px;
|
||||
block-size: 70px;
|
||||
}
|
||||
#contact > h2 {
|
||||
padding-inline-end: 76px;
|
||||
}
|
||||
.contact-email__preview {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#contact > h2 {
|
||||
padding-inline-end: 76px;
|
||||
}
|
||||
.contact-avatar {
|
||||
inset-block-start: 32px;
|
||||
inset-inline-end: 22px;
|
||||
inline-size: 64px;
|
||||
block-size: 70px;
|
||||
}
|
||||
.contact-destination__copy {
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.contact-social {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 768px) and (max-height: 559px) {
|
||||
.contact-avatar {
|
||||
inset-block-start: 42px;
|
||||
inset-inline-end: 24px;
|
||||
inline-size: 64px;
|
||||
block-size: 70px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.contact-email__preview {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.contact-avatar {
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 60px;
|
||||
block-size: 65px;
|
||||
}
|
||||
.contact-email__preview,
|
||||
.contact-email__copy,
|
||||
.contact-email__status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ async function enterState(
|
||||
for (const link of links) link.closest("li")?.remove();
|
||||
});
|
||||
await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0);
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ test("forced colors preserves visible focus, controls, content, and the Contact
|
||||
|
||||
await clickNavigationLink(page, "#contact");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
|
||||
@@ -151,7 +151,7 @@ async function assertReflowGeometry(page: Page): Promise<void> {
|
||||
|
||||
await clickNavigationLink(page, "#contact");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
}
|
||||
|
||||
test("reflows at a 320 CSS pixel mobile viewport without lost content or controls", async ({
|
||||
|
||||
96
tests/browser/contact-refinements.spec.ts
Normal file
96
tests/browser/contact-refinements.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
|
||||
test('start navigation, contact identity, and toolkit icons use the approved content', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const response = await page.goto(distUrl);
|
||||
const html = await response!.text();
|
||||
expect(html).not.toContain('luca.pislaru@gmail.com');
|
||||
expect(html).not.toContain('mailto:');
|
||||
await expect(page.locator('.contact-email')).toHaveAttribute('data-enhanced', 'true');
|
||||
await page.locator('.hero-journey__action--primary').click();
|
||||
await expect(page).toHaveURL(/#contact$/);
|
||||
await expect(page.locator('#contact')).toBeInViewport();
|
||||
await expect(page.locator('.contact-avatar')).toBeVisible();
|
||||
await expect(page.locator('.contact-social-link')).toHaveText(['LinkedIn', 'GitHub', 'GitLab']);
|
||||
const missing = await page.locator('[data-testid="toolkit-technology"]').evaluateAll(items => items.filter(item => !item.querySelector('svg')).map(item => item.textContent));
|
||||
expect(missing).toEqual([]);
|
||||
for (const name of ['SPSS', 'SoccerNet', 'Microsoft Excel', 'Btrfs', 'rsync']) {
|
||||
await expect(page.locator('#toolkit')).not.toContainText(name);
|
||||
}
|
||||
});
|
||||
|
||||
test('email is revealed on focus, with an explicit disclosure and copy fallback', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`${distUrl}/#contact`);
|
||||
const email = page.getByRole('link', { name: 'Email Me', exact: true });
|
||||
await expect(email).toHaveAttribute('href', '#contact-email-details');
|
||||
await expect(page.locator('.contact-email__preview')).not.toBeVisible();
|
||||
await email.focus();
|
||||
await expect(email).toHaveAttribute('href', 'mailto:luca.pislaru@gmail.com');
|
||||
await page.locator('#contact summary').click();
|
||||
await expect(page.locator('.contact-email__address')).toHaveText('luca.pislaru@gmail.com');
|
||||
await page.getByRole('button', { name: 'Copy email' }).click();
|
||||
await expect(page.locator('.contact-email__status')).toHaveText(/Copied|Select the address to copy it\./);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#contact summary')).toBeFocused();
|
||||
await expect(page.locator('#contact-email-details')).not.toHaveAttribute('open');
|
||||
});
|
||||
|
||||
test('contact fits desktop and stays aligned and usable across narrow widths', async ({ page, distUrl }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'Viewport matrix is run once.');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`${distUrl}/#contact`);
|
||||
for (const [width, height] of [[1440, 900], [1280, 720], [768, 1024], [390, 844], [320, 740], [844, 390]]) {
|
||||
await page.setViewportSize({ width: width!, height: height! });
|
||||
await page.locator('#contact').scrollIntoViewIfNeeded();
|
||||
const geometry = await page.locator('#contact').evaluate(section => {
|
||||
const box = section.getBoundingClientRect();
|
||||
const avatar = section.querySelector('.contact-avatar')!.getBoundingClientRect();
|
||||
return {
|
||||
height: box.height,
|
||||
overflow: document.documentElement.scrollWidth > innerWidth,
|
||||
socialY: [...section.querySelectorAll('.contact-social-link')].map(el => el.getBoundingClientRect().y),
|
||||
avatarInside: avatar.right <= box.right && avatar.bottom < section.querySelector('.contact-destination__copy')!.getBoundingClientRect().top,
|
||||
};
|
||||
});
|
||||
expect(geometry.overflow, `${width}px overflow`).toBe(false);
|
||||
expect(Math.max(...geometry.socialY) - Math.min(...geometry.socialY), `${width}px social alignment`).toBeLessThan(1);
|
||||
expect(geometry.avatarInside, `${width}px avatar`).toBe(true);
|
||||
if (width! >= 1280) expect(geometry.height).toBeLessThan(height! - 140);
|
||||
await page.screenshot({ path: testInfo.outputPath(`contact-${width}x${height}.png`) });
|
||||
}
|
||||
});
|
||||
|
||||
test('native email disclosure remains usable without JavaScript', async ({ browser, distUrl }) => {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 } });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${distUrl}/#contact`);
|
||||
await expect(page.locator('.contact-email__address')).not.toBeVisible();
|
||||
await page.locator('#contact summary').click();
|
||||
await expect(page.locator('.contact-email__address')).toHaveText('luca.pislaru [at] gmail [dot] com');
|
||||
await expect(page.locator('.video-fallback')).toHaveAttribute('href', '/assets/project-private-game-loop.mp4');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('reduced motion keeps the gameplay still until Play and supports pause', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(distUrl);
|
||||
const figure = page.locator('.project-media--video');
|
||||
await figure.scrollIntoViewIfNeeded();
|
||||
await expect(figure.locator('source')).toHaveCount(0);
|
||||
await figure.getByRole('button', { name: 'Play video' }).click();
|
||||
await expect(figure.getByRole('button', { name: 'Pause video' })).toBeVisible();
|
||||
expect(await figure.locator('video').evaluate(element => { const video = element as HTMLVideoElement; return { muted: video.muted, loop: video.loop, paused: video.paused }; })).toEqual({ muted: true, loop: true, paused: false });
|
||||
await figure.getByRole('button', { name: 'Pause video' }).click();
|
||||
await expect(figure.getByRole('button', { name: 'Play video' })).toBeVisible();
|
||||
await expect(figure.locator('source')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('contact and supporting media have no serious accessibility violations', async ({ page, distUrl }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto(`${distUrl}/#contact`);
|
||||
await page.locator('#contact summary').click();
|
||||
const result = await new AxeBuilder({ page }).include('#contact').include('#additional-work').analyze();
|
||||
expect(result.violations.filter(violation => ['serious', 'critical'].includes(violation.impact ?? ''))).toEqual([]);
|
||||
});
|
||||
@@ -226,10 +226,10 @@ test('an aborted generated client entry leaves the complete SSR portfolio usable
|
||||
await expect(page.locator('main > section')).toHaveCount(8);
|
||||
await expect(page.locator('nav a')).toHaveCount(8);
|
||||
await expect(
|
||||
page.locator('a[href="mailto:luca.pislaru@gmail.com"]'),
|
||||
page.locator('#contact summary'),
|
||||
).toBeVisible();
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
||||
await expect(page.locator('#contact')).toBeInViewport();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ test("tabs from the skip link through navigation and every contact action", asyn
|
||||
const expectedTargets = [
|
||||
{ selector: ".skip-link", href: "#portfolio-content" },
|
||||
...navHrefs.map((href) => ({ selector: `nav a[href="${href}"]`, href })),
|
||||
{ selector: '.hero-journey__action--primary', href: 'mailto:luca.pislaru@gmail.com' },
|
||||
{ selector: '.hero-journey__action--primary', href: '#contact' },
|
||||
{ selector: '.hero-journey__actions a[href="#selected-builds"]', href: "#selected-builds" },
|
||||
{
|
||||
selector: 'a[href="https://github.com/LucaPisl/footy-tracker"]',
|
||||
@@ -66,6 +66,14 @@ test("tabs from the skip link through navigation and every contact action", asyn
|
||||
'a[href="https://github.com/LucaPisl/opinion-dynamics-simulation"]',
|
||||
href: "https://github.com/LucaPisl/opinion-dynamics-simulation",
|
||||
},
|
||||
{
|
||||
selector: '.video-controls button',
|
||||
href: null,
|
||||
},
|
||||
{
|
||||
selector: '.video-fallback',
|
||||
href: '/assets/project-private-game-loop.mp4',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'a[href="https://github.com/LucaPisl/CurrencyConverterAvalonia"]',
|
||||
@@ -76,9 +84,17 @@ test("tabs from the skip link through navigation and every contact action", asyn
|
||||
href: "https://github.com/LucaPisl/LinuxModdingGuide",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href^="mailto:"]',
|
||||
selector: '#contact :is(a, summary):text-is("Email Me")',
|
||||
href: "mailto:luca.pislaru@gmail.com",
|
||||
},
|
||||
{
|
||||
selector: '#contact summary',
|
||||
href: null,
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href="https://linkedin.com/in/luca-pisl"]',
|
||||
href: "https://linkedin.com/in/luca-pisl",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href="https://github.com/LucaPisl"]',
|
||||
href: "https://github.com/LucaPisl",
|
||||
@@ -87,17 +103,13 @@ test("tabs from the skip link through navigation and every contact action", asyn
|
||||
selector: '#contact a[href="https://gitlab.com/luca_pisl"]',
|
||||
href: "https://gitlab.com/luca_pisl",
|
||||
},
|
||||
{
|
||||
selector: '#contact a[href="https://linkedin.com/in/luca-pisl"]',
|
||||
href: "https://linkedin.com/in/luca-pisl",
|
||||
},
|
||||
];
|
||||
|
||||
for (const target of expectedTargets) {
|
||||
await page.keyboard.press("Tab");
|
||||
const locator = page.locator(target.selector);
|
||||
await expect(locator).toBeFocused();
|
||||
await expect(locator).toHaveAttribute("href", target.href);
|
||||
if (target.href !== null) await expect(locator).toHaveAttribute("href", target.href);
|
||||
await assertVisibleUnobscuredFocus(page);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ test('F7 replaces a failed poster with exactly one CSS fallback and keeps its de
|
||||
expect(await readUnhandledRejections(page)).toEqual([]);
|
||||
});
|
||||
|
||||
test('F8 remains authenticated and request-free while the public manifest has no approved video', async ({ page, distUrl }) => {
|
||||
test('F8 remains authenticated and request-free before the gameplay video is requested', async ({ page, distUrl }) => {
|
||||
const mediaRequests: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (/\.(?:mp4|webm)(?:$|[?#])/iu.test(request.url())) mediaRequests.push(request.url());
|
||||
@@ -40,7 +40,8 @@ test('F8 remains authenticated and request-free while the public manifest has no
|
||||
await page.goto(authorizedFaultUrl(distUrl, 'F8-video-failure'), { waitUntil: 'networkidle' });
|
||||
|
||||
expect((await readFaultSnapshot(page)).fault).toBe('F8-video-failure');
|
||||
await expect(page.locator('video, video source')).toHaveCount(0);
|
||||
await expect(page.locator('video')).toHaveCount(1);
|
||||
await expect(page.locator('video source')).toHaveCount(0);
|
||||
expect(mediaRequests).toEqual([]);
|
||||
await assertSemanticFaultOracle(page);
|
||||
expect(errors.pageErrors).toEqual([]);
|
||||
|
||||
@@ -31,7 +31,7 @@ test('approved still evidence uses manifest assets and intrinsic lazy image sema
|
||||
await expect(page.locator('main > section')).toHaveCount(8);
|
||||
await expect(page.locator('#selected-builds article')).toHaveCount(3);
|
||||
await expect(page.locator('#additional-work article')).toHaveCount(4);
|
||||
await expect(page.locator('[data-project-media]')).toHaveCount(approvedStillCount);
|
||||
await expect(page.locator('[data-project-media]')).toHaveCount(projectPresentations.length);
|
||||
await expect(page.locator('[data-project-media] img')).toHaveCount(expectedImages.length);
|
||||
expect(await page.locator('[data-project-media] img').evaluateAll((images) => images.map((image) => ({
|
||||
src: new URL((image as HTMLImageElement).src).pathname,
|
||||
@@ -41,8 +41,9 @@ test('approved still evidence uses manifest assets and intrinsic lazy image sema
|
||||
loading: (image as HTMLImageElement).loading,
|
||||
decoding: (image as HTMLImageElement).decoding,
|
||||
})))).toEqual(expectedImages.map((image) => ({ ...image, loading: 'lazy', decoding: 'async' })));
|
||||
await expect(page.locator('video, video source')).toHaveCount(0);
|
||||
await expect(page.locator('[data-project-id="private-medication-tracker"] a')).toHaveCount(0);
|
||||
await expect(page.locator('video')).toHaveCount(1);
|
||||
await expect(page.locator('video source')).toHaveCount(0);
|
||||
await expect(page.locator('[data-project-id="private-medication-tracker"] a.project-link')).toHaveCount(0);
|
||||
await expect(page.locator('.journey-poster')).not.toHaveAttribute('src', /.+/u);
|
||||
expect(videoRequests).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -37,10 +37,10 @@ const navigationLabels: Readonly<Record<(typeof sectionIds)[number], string>> =
|
||||
};
|
||||
|
||||
const visibleTextLeafSelector =
|
||||
"h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a,svg text";
|
||||
"h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a,summary,svg text";
|
||||
const visibleTextSelector = [
|
||||
".site-header :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a)",
|
||||
".portfolio-main :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a)",
|
||||
".portfolio-main :is(h1,h2,h3,h4,p,dt,dd,li,figcaption,caption,th,td,a,summary)",
|
||||
".portfolio-main svg text",
|
||||
].join(",");
|
||||
|
||||
@@ -111,6 +111,7 @@ const expectedVisibleTextSegments = (
|
||||
(project.media ?? []).flatMap((presentation) => [
|
||||
presentation.caption,
|
||||
presentation.longDescription,
|
||||
...(presentation.kind === "video" ? ["Open gameplay video"] : []),
|
||||
...(presentation.kind === "chart"
|
||||
? [
|
||||
"Variables",
|
||||
@@ -136,7 +137,7 @@ const expectedVisibleTextSegments = (
|
||||
content.identity.role,
|
||||
content.identity.valueProposition,
|
||||
content.identity.availability,
|
||||
"Email Luca",
|
||||
"Contact Me",
|
||||
"Explore work",
|
||||
...section("profile", [
|
||||
content.profile.introduction,
|
||||
@@ -197,7 +198,6 @@ const expectedVisibleTextSegments = (
|
||||
project.summary,
|
||||
...projectMediaText(project),
|
||||
project.contribution,
|
||||
project.outcome,
|
||||
...projectLinkText(project),
|
||||
]),
|
||||
),
|
||||
@@ -212,8 +212,7 @@ const expectedVisibleTextSegments = (
|
||||
"contact",
|
||||
[
|
||||
content.contact.introduction,
|
||||
"Email Luca",
|
||||
content.contact.email,
|
||||
"Email Me",
|
||||
content.contact.cvMessage,
|
||||
...content.contact.social.map((link) => link.service),
|
||||
],
|
||||
@@ -230,6 +229,7 @@ const rawVisibleTextSegments = async (
|
||||
({ html, leafSelector, rootSelector }) => {
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const isHidden = (element: Element): boolean => {
|
||||
if (element.closest('.project-media--video[data-enhanced="false"] .video-controls')) return true;
|
||||
for (
|
||||
let current: Element | null = element;
|
||||
current;
|
||||
@@ -420,12 +420,14 @@ test("keeps every raw SSR reading-path segment visible without JavaScript", asyn
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
const email = page.locator('a[href="mailto:luca.pislaru@gmail.com"]');
|
||||
await expect(email).toHaveCount(2);
|
||||
await expect(email).toHaveText(["Email Luca", "Email Luca"]);
|
||||
await expect(page.locator('a[href^="mailto:"]')).toHaveCount(0);
|
||||
const email = page.locator('#contact-email-details');
|
||||
await email.locator('summary').click();
|
||||
await expect(email.locator('.contact-email__address')).toBeVisible();
|
||||
await expect(email.locator('.contact-email__address')).toHaveText('luca.pislaru [at] gmail [dot] com');
|
||||
await expect(
|
||||
page.getByText(
|
||||
"For a current CV or role-specific information, contact me directly.",
|
||||
"Get in touch for my CV or to talk about a role.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
@@ -528,5 +530,5 @@ test("retains native section navigation when scripts are disabled", async ({
|
||||
|
||||
await expect(page).toHaveURL(`${distUrl}/#contact`);
|
||||
await expect(page.locator("#contact")).toBeInViewport();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ test('boots reduced motion without requesting the live scene or automatic media'
|
||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||
await expect(page.locator('.journey-hud')).toBeVisible();
|
||||
await expect(page.locator('nav[aria-label="Portfolio sections"]')).toBeVisible();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
await expect(page.getByRole('status')).toContainText('reduced motion requested');
|
||||
expect(requests.some((url) => environmentChunkPattern.test(url))).toBe(false);
|
||||
await expect(page.locator('video[src], video source[src]')).toHaveCount(0);
|
||||
|
||||
@@ -151,7 +151,7 @@ test('renders the validated eight-section portfolio structure before responsive
|
||||
.toEqual(['#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact']);
|
||||
await expect(page.locator('#selected-builds article, #additional-work article')).not.toHaveCount(0);
|
||||
await expect(page.locator('#toolkit [data-testid="toolkit-technology"]')).not.toHaveCount(0);
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toHaveCount(1);
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toHaveCount(1);
|
||||
await expect(page.locator('#contact a[target="_blank"]')).not.toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,11 +100,10 @@ test('exposes the complete semantic portfolio from the generated document', asyn
|
||||
);
|
||||
await expect(page.locator('.route-label')).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Explore work' })).toHaveAttribute('href', '#selected-builds');
|
||||
await expect(page.getByRole('link', { name: 'Email Luca' })).toHaveCount(2);
|
||||
await expect(page.getByRole('link', { name: 'Contact Me' })).toHaveAttribute('href', '#contact');
|
||||
await expect(page.getByRole('link', { name: 'Email Me' })).toHaveCount(1);
|
||||
await expect(page.locator('[data-featured-operation]')).toContainText('without data loss');
|
||||
expect(await page.locator('[data-testid="toolkit-technology"]', { hasText: 'DNS' }).locator('span').evaluate(
|
||||
(label) => getComputedStyle(label).gridColumn,
|
||||
)).toBe('1 / -1');
|
||||
await expect(page.locator('[data-testid="toolkit-technology"]', { hasText: 'DNS' }).locator('svg')).toHaveCount(1);
|
||||
expect(await page.locator('.hero-journey').evaluate((hero) => {
|
||||
const content = hero.querySelector('.hero-journey__content')!;
|
||||
const heroStyle = getComputedStyle(hero);
|
||||
@@ -172,8 +171,10 @@ test('keeps canonical native anchors and safe external destinations', async ({
|
||||
}
|
||||
|
||||
const email = page.locator('a[href^="mailto:"]');
|
||||
await expect(email).toHaveCount(2);
|
||||
await expect(email).toHaveText(['Email Luca', 'Email Luca']);
|
||||
await expect(email).toHaveCount(0);
|
||||
await page.getByRole('link', { name: 'Email Me' }).focus();
|
||||
await expect(email).toHaveCount(1);
|
||||
await expect(email).toHaveText('Email Me');
|
||||
expect(await email.evaluateAll((links) => [...new Set(links.map((link) => link.getAttribute('href')))])).toEqual(
|
||||
['mailto:luca.pislaru@gmail.com'],
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ test("falls back to one poster and no scene loop when the deferred scene chunk i
|
||||
await expect(page.locator("main > section")).toHaveCount(8);
|
||||
await revealNavigationLinks(page);
|
||||
await expect(page.locator('nav a[href="#contact"]')).toBeVisible();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
@@ -74,7 +74,7 @@ test("contains a WebGL initialization failure without hiding the portfolio", asy
|
||||
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
||||
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
||||
await expect(page.locator("#portfolio-content")).toBeVisible();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await readSceneInstrumentation(page)).activeAnimationFrames,
|
||||
|
||||
@@ -466,7 +466,7 @@ test.describe("production visual composition", () => {
|
||||
});
|
||||
await page.locator('nav a[href="#contact"]').click();
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0);
|
||||
await captureViewport(page, testInfo, "visual-missing-social");
|
||||
});
|
||||
@@ -482,7 +482,7 @@ test.describe("production visual composition", () => {
|
||||
await expect(page.locator(".journey-hud__section")).toHaveText("Contact");
|
||||
await expect(page.locator(".journey-hud__arrival")).toHaveText("ARRIVED");
|
||||
await expect(page.locator("#contact")).toBeInViewport();
|
||||
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
||||
await expect(page.locator('#contact :is(a, summary):text-is("Email Me")')).toBeVisible();
|
||||
await captureViewport(page, testInfo, "visual-contact-arrival");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ const digest = 'a'.repeat(64);
|
||||
const canonical = 'https://portfolio.example/';
|
||||
const alternate = 'http://www.portfolio.example/';
|
||||
const escapedTitle = 'Luca Pîslaru — System Administrator & Full-Stack Developer';
|
||||
const description = 'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.';
|
||||
const description = 'I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.';
|
||||
const socialImage = `${canonical}assets/project-social-preview.webp`;
|
||||
const projectStillMaxBytes = 400 * 1024;
|
||||
const socialImageFixture = Buffer.alloc(15_068);
|
||||
@@ -27,7 +27,7 @@ const html = `<!doctype html><html><head>
|
||||
<meta name="twitter:title" content="${escapedTitle}" />
|
||||
<meta name="twitter:description" content="${description}" />
|
||||
<meta name="twitter:image" content="${socialImage}" />
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"ProfilePage","name":"Luca Pîslaru","description":"${description}","url":"${canonical}","mainEntity":{"@type":"Person","name":"Luca Pîslaru","jobTitle":"System Administrator & Full-Stack Developer","description":"${description}","url":"${canonical}","email":"mailto:luca.pislaru@gmail.com","homeLocation":{"@type":"Place","address":{"@type":"PostalAddress","addressLocality":"Bucharest","addressCountry":"RO"}},"sameAs":["https://github.com/LucaPisl","https://gitlab.com/luca_pisl","https://linkedin.com/in/luca-pisl"]}}</script>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"ProfilePage","name":"Luca Pîslaru","description":"${description}","url":"${canonical}","mainEntity":{"@type":"Person","name":"Luca Pîslaru","jobTitle":"System Administrator & Full-Stack Developer","description":"${description}","url":"${canonical}","homeLocation":{"@type":"Place","address":{"@type":"PostalAddress","addressLocality":"Bucharest","addressCountry":"RO"}},"sameAs":["https://linkedin.com/in/luca-pisl","https://github.com/LucaPisl","https://gitlab.com/luca_pisl"]}}</script>
|
||||
<script type="module" src="/assets/app.12345678.js"></script></head>
|
||||
<body><main><h1>Luca Pîslaru</h1><p>System Administrator & Full-Stack Developer</p><section><h2>Selected builds</h2><p>Evidence-led project work.</p></section><section><h2>Contact</h2><p>Discuss systems and software work.</p><a href="mailto:hello@example.com">Contact</a></section></main></body></html>`;
|
||||
|
||||
|
||||
2
tests/fixtures/portfolio-content.valid.json
vendored
2
tests/fixtures/portfolio-content.valid.json
vendored
@@ -111,7 +111,7 @@
|
||||
}],
|
||||
"contact": {
|
||||
"introduction": "Open to System Administrator and Full-Stack Developer opportunities.",
|
||||
"email": "hello@example.test",
|
||||
"email": { "localPart": "hello", "domain": "example.test" },
|
||||
"social": [{ "service": "GitHub", "url": "https://github.com/example" }],
|
||||
"cvMessage": "For a current CV or role-specific information, contact me directly."
|
||||
},
|
||||
|
||||
@@ -18,11 +18,13 @@ const contentTypes: Readonly<Record<string, string>> = {
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".opus": "audio/ogg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".webm": "video/webm",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
|
||||
@@ -133,7 +133,7 @@ export const assertSemanticFaultOracle = async (page: Page): Promise<void> => {
|
||||
await expect(page.locator('main > section')).toHaveCount(8);
|
||||
await expect(page.locator('nav[aria-label="Portfolio sections"] a')).toHaveCount(8);
|
||||
await expect(page.locator('nav a[href="#contact"]')).toBeVisible();
|
||||
const email = page.locator('#contact a[href^="mailto:"]');
|
||||
const email = page.locator('#contact :is(a, summary):text-is("Email Me")');
|
||||
await expect(email).toBeVisible();
|
||||
for (const title of [
|
||||
'Private self-hosted medication tracker',
|
||||
|
||||
@@ -67,9 +67,11 @@ describe('raw prerendered document', () => {
|
||||
expect(document.body.textContent).toContain('Secure self-hosted platform and lifecycle');
|
||||
expect(document.body.textContent).toContain('Private self-hosted medication tracker');
|
||||
expect(document.body.textContent).toContain('Linux');
|
||||
const mailLinks = document.querySelectorAll('a[href="mailto:luca.pislaru@gmail.com"]');
|
||||
expect(mailLinks).toHaveLength(2);
|
||||
expect([...mailLinks].map((link) => link.textContent)).toEqual(['Email Luca', 'Email Luca']);
|
||||
expect(document.querySelectorAll('a[href^="mailto:"]')).toHaveLength(0);
|
||||
expect(html).not.toContain('luca.pislaru@gmail.com');
|
||||
expect(document.querySelector('#start a[href="#contact"]')?.textContent).toBe('Contact Me');
|
||||
expect(document.querySelector('#contact-email-details summary')?.textContent).toBe('Email Me');
|
||||
expect(document.querySelector('.contact-email__address')?.textContent).toBe('luca.pislaru [at] gmail [dot] com');
|
||||
expect(document.querySelector('nav a[href="#infrastructure"]')).not.toBeNull();
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(moduleScript).not.toBeNull();
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('approved public portfolio projection', () => {
|
||||
]);
|
||||
expect(content.projects.selected[1]?.contribution).toMatch(/collaborator/iu);
|
||||
expect(content.projects.selected[1]?.outcome).toMatch(/0\.942 mAP@0\.5.*0\.198/iu);
|
||||
expect(content.projects.selected[0]?.outcome).toContain('not presented as production-ready.');
|
||||
expect(content.projects.selected[0]?.outcome).toContain('further work needed before a wider release.');
|
||||
expect(content.systemsOperations.title).toBe('Professional systems operations');
|
||||
expect(content.infrastructure.title).toBe('Secure self-hosted platform and lifecycle');
|
||||
expect(content.projects.selected).toHaveLength(3);
|
||||
@@ -98,13 +98,14 @@ describe('approved public portfolio projection', () => {
|
||||
.toBe('Private game prototype');
|
||||
expect(content.toolkit.flatMap((group) => group.technologies).map((technology) => technology.name))
|
||||
.toEqual(expect.arrayContaining(['Linux', 'Docker', 'Python', 'Vue.js', 'Java']));
|
||||
expect(content.contact).toEqual(expect.objectContaining({ email: 'luca.pislaru@gmail.com' }));
|
||||
expect(content.contact).toEqual(expect.objectContaining({ email: { localPart: 'luca.pislaru', domain: 'gmail.com' } }));
|
||||
expect(content.contact.social.map((link) => link.url)).toEqual([
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
'https://github.com/LucaPisl',
|
||||
'https://gitlab.com/luca_pisl',
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
]);
|
||||
const publishedReferences = [
|
||||
content.contact.avatarAssetId,
|
||||
...content.projects.selected.flatMap((project) => project.assetIds),
|
||||
...content.projects.additional.flatMap((project) => project.assetIds),
|
||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||
|
||||
@@ -158,32 +158,30 @@ describe('artifact verification', () => {
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a missing hero mailto link',
|
||||
mutate: (html: string) => html.replace('href="mailto:luca.pislaru@gmail.com">Email Luca</a>', 'href="#selected-builds">Email Luca</a>'),
|
||||
name: 'a missing hero contact link',
|
||||
mutate: (html: string) => html.replace('href="#contact">Contact Me</a>', 'href="#selected-builds">Contact Me</a>'),
|
||||
},
|
||||
{
|
||||
name: 'a duplicate mailto link beyond the hero and contact actions',
|
||||
mutate: (html: string) => html.replace('</main>', '<a href="mailto:luca.pislaru@gmail.com">Email Luca</a></main>'),
|
||||
},
|
||||
{
|
||||
name: 'a mailto link with an address that differs from public content',
|
||||
mutate: (html: string) => html.replace('href="mailto:luca.pislaru@gmail.com">Email Luca</a>', 'href="mailto:wrong@example.test">Email Luca</a>'),
|
||||
name: 'a missing native email disclosure',
|
||||
mutate: (html: string) => html.replace('<summary>Email Me</summary>', '<span>Email Me</span>'),
|
||||
},
|
||||
{
|
||||
name: 'a hidden mailto link outside the app root',
|
||||
mutate: (html: string) => html.replace('</body>', '<a hidden href="mailto:luca.pislaru@gmail.com">Email Luca</a></body>'),
|
||||
},
|
||||
{
|
||||
name: 'two mailto links placed beneath contact instead of one per section',
|
||||
mutate: (html: string) => html
|
||||
.replace('href="mailto:luca.pislaru@gmail.com">Email Luca</a>', 'href="#selected-builds">Email Luca</a>')
|
||||
.replace('</footer>', '<a href="mailto:luca.pislaru@gmail.com">Email Luca</a></footer>'),
|
||||
name: 'a plain address hidden in initial page data',
|
||||
mutate: (html: string) => html.replace('</footer>', '<span hidden>luca.pislaru@gmail.com</span></footer>'),
|
||||
},
|
||||
])('rejects $name', async ({ mutate }) => {
|
||||
const { directory, html, inputs, contract } = await writeValidArtifacts();
|
||||
await writeFile(join(directory, 'index.html'), mutate(html));
|
||||
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/mailto/i);
|
||||
await expect(verifyArtifactDirectory(directory, inputs, contract)).rejects.toThrow(/contact|email|mailto/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -62,10 +62,10 @@ describe('semantic portfolio document', () => {
|
||||
'System Administrator & Full-Stack Developer',
|
||||
validInputs.content.identity.valueProposition,
|
||||
validInputs.content.identity.availability,
|
||||
'Email Luca',
|
||||
'Contact Me',
|
||||
'Explore work',
|
||||
]);
|
||||
expect(hero.querySelector('a[href="mailto:hello@example.test"]')?.textContent).toBe('Email Luca');
|
||||
expect(hero.querySelector('a[href="#contact"]')?.textContent).toBe('Contact Me');
|
||||
expect(document.querySelector('[data-featured-operation]')?.textContent).toContain(
|
||||
validInputs.content.infrastructure.featuredOperation.title,
|
||||
);
|
||||
@@ -94,8 +94,10 @@ describe('semantic portfolio document', () => {
|
||||
expect([...navigation.querySelectorAll('a')].map((link) => link.getAttribute('href'))).toEqual([
|
||||
'#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact',
|
||||
]);
|
||||
const email = document.querySelector('a[href="mailto:hello@example.test"]');
|
||||
expect(email?.textContent).toBe('Email Luca');
|
||||
const email = document.querySelector('#contact-email-details');
|
||||
expect(email?.querySelector('summary')?.textContent).toBe('Email Me');
|
||||
expect(email?.textContent).toContain('hello [at] example [dot] test');
|
||||
expect(document.querySelector('a[href^="mailto:"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||
});
|
||||
|
||||
|
||||
@@ -160,6 +160,10 @@ describe('ProjectMedia', () => {
|
||||
|
||||
const video = wrapper.get('video');
|
||||
expect(video.attributes('preload')).toBe('none');
|
||||
expect(video.attributes()).toHaveProperty('loop');
|
||||
expect(video.attributes()).toHaveProperty('muted');
|
||||
expect(video.attributes()).not.toHaveProperty('autoplay');
|
||||
expect(wrapper.get('.video-fallback').attributes('href')).toBe('/assets/project-loop.mp4');
|
||||
expect(video.attributes()).not.toHaveProperty('controls');
|
||||
expect(video.findAll('source')).toHaveLength(0);
|
||||
expect(wrapper.get('button').text()).toBe('Play video');
|
||||
@@ -175,15 +179,16 @@ describe('ProjectMedia', () => {
|
||||
'/assets/project-loop.mp4',
|
||||
]);
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledOnce();
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('attaches inside 300 CSS pixels when automatic media is allowed and detaches offscreen', async () => {
|
||||
it('attaches in the viewport when automatic media is allowed and detaches offscreen', async () => {
|
||||
const wrapper = mountMedia({
|
||||
kind: 'video', assetId: 'project-loop', posterAssetId: 'project-loop-poster',
|
||||
alt: 'Gameplay excerpt.', caption: 'Gameplay.', longDescription: 'A gameplay loop.',
|
||||
}, true);
|
||||
const observer = IntersectionObserverFake.instances[0]!;
|
||||
expect(observer.options?.rootMargin).toBe('300px 0px 300px 0px');
|
||||
expect(observer.options?.rootMargin).toBe('0px');
|
||||
|
||||
observer.emit(true);
|
||||
await nextTick();
|
||||
@@ -238,6 +243,8 @@ describe('ProjectMediaGallery', () => {
|
||||
expect(mediaProjects.map(({ id }) => id)).toEqual([
|
||||
'private-medication-tracker',
|
||||
'opinion-dynamics-simulation',
|
||||
'private-game-prototype',
|
||||
'private-healthcare-platform',
|
||||
'currency-converter',
|
||||
]);
|
||||
|
||||
@@ -247,7 +254,7 @@ describe('ProjectMediaGallery', () => {
|
||||
}));
|
||||
const images = wrappers.flatMap((wrapper) => wrapper.findAll('img'));
|
||||
|
||||
expect(images).toHaveLength(6);
|
||||
expect(images).toHaveLength(7);
|
||||
expect(images.every((image) => image.attributes('loading') === 'lazy')).toBe(true);
|
||||
expect(images.every((image) => image.attributes('decoding') === 'async')).toBe(true);
|
||||
expect(images.every((image) => Number(image.attributes('width')) > 0 && Number(image.attributes('height')) > 0)).toBe(true);
|
||||
@@ -345,7 +352,7 @@ describe('media content schema', () => {
|
||||
additional: [{ id: 'extra', title: 'Extra', visibility: 'public', maturity: 'learning', summary: 'S.', problem: 'P.', contribution: 'C.', technicalDecisions: ['D.'], outcome: 'O.', assetIds: [], media: [] }],
|
||||
},
|
||||
toolkit: [{ title: 'Tools', technologies: [{ name: 'Linux' }] }],
|
||||
contact: { introduction: 'Open to roles.', email: 'hello@example.test', social: [], cvMessage: 'Contact directly.' },
|
||||
contact: { introduction: 'Open to roles.', email: { localPart: 'hello', domain: 'example.test' }, social: [], cvMessage: 'Contact directly.' },
|
||||
metadata: { title: 'Title.', description: 'Description.' },
|
||||
sceneDescription: 'Scene.',
|
||||
}));
|
||||
|
||||
@@ -9,7 +9,7 @@ const socialLabels = content.contact.social.map((link) => link.service);
|
||||
|
||||
describe('official brand icon registry', () => {
|
||||
it('resolves every toolkit technology with an honest official mark', () => {
|
||||
for (const label of toolkitLabels.filter(brandIconAvailable)) {
|
||||
for (const label of toolkitLabels) {
|
||||
const icon = brandIconFor(label);
|
||||
expect(icon.title).toBeTruthy();
|
||||
expect(icon.hex).toMatch(/^[0-9A-F]{6}$/u);
|
||||
@@ -31,8 +31,9 @@ describe('official brand icon registry', () => {
|
||||
'Git', 'Python', 'TypeScript/JavaScript', 'Vue.js', 'Flutter/Dart', 'Fastify',
|
||||
'C#/.NET', 'Headless WordPress', 'Java', 'PostgreSQL', 'Drift/SQLite',
|
||||
'Drizzle ORM', 'PyTorch', 'Ultralytics/YOLO', 'OpenCV', 'GitHub', 'GitLab', 'LinkedIn',
|
||||
'DNS', 'TLS', 'Fail2Ban', 'SQL',
|
||||
]);
|
||||
expect(['DNS', 'TLS', 'Fail2Ban', 'Btrfs', 'rsync', 'SQL', 'SPSS', 'Microsoft Excel', 'SoccerNet']
|
||||
expect(['Btrfs', 'rsync', 'SPSS', 'Microsoft Excel', 'SoccerNet']
|
||||
.every((label) => !brandIconAvailable(label))).toBe(true);
|
||||
expect(() => brandIconFor('Unlisted technology')).toThrow(/No official brand icon/u);
|
||||
});
|
||||
|
||||
@@ -163,21 +163,21 @@ describe("semantic section components", () => {
|
||||
expect(tile.findAll('[aria-label="Linux"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders a generic toolkit concept as visible text without inventing an icon", () => {
|
||||
it("renders a toolkit concept with a decorative vector symbol", () => {
|
||||
const wrapper = mount(ToolkitGrid, {
|
||||
props: { groups: [{ title: "Systems", technologies: [{ name: "DNS" }] }] },
|
||||
});
|
||||
const tile = wrapper.get('[data-testid="toolkit-technology"]');
|
||||
|
||||
expect(tile.text()).toBe("DNS");
|
||||
expect(tile.find("svg").exists()).toBe(false);
|
||||
expect(tile.get("span").classes()).toContain("toolkit-item__label--text-only");
|
||||
expect(tile.find("svg").exists()).toBe(true);
|
||||
expect(tile.get("svg").attributes('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it("renders a visible mailto contact and safe verified social links", () => {
|
||||
it("keeps email behind an interaction and renders safe social links", () => {
|
||||
const contact: ContactContent = {
|
||||
introduction: "Open to System Administrator and Full-Stack Developer opportunities.",
|
||||
email: "hello@example.test",
|
||||
email: { localPart: "hello", domain: "example.test" },
|
||||
cvMessage:
|
||||
"For a current CV or role-specific information, contact me directly.",
|
||||
social: [{ service: "GitHub", url: "https://github.com/example" }],
|
||||
@@ -188,9 +188,8 @@ describe("semantic section components", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get('a[href="mailto:hello@example.test"]').text()).toBe(
|
||||
"Email Luca",
|
||||
);
|
||||
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false);
|
||||
expect(wrapper.get('summary').text()).toBe('Email Me');
|
||||
expect(wrapper.text()).toContain(contact.introduction);
|
||||
const social = wrapper.get('a[href="https://github.com/example"]');
|
||||
expect(social.text()).toBe("GitHub");
|
||||
|
||||
52
tests/unit/contact-interaction.test.ts
Normal file
52
tests/unit/contact-interaction.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import ContactDestination from '@/components/ContactDestination.vue';
|
||||
import { parsePortfolioContent } from '@/content/schemas';
|
||||
import content from '@/../content/public/portfolio-content.json';
|
||||
|
||||
const contact = parsePortfolioContent(content).contact;
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('contact disclosure', () => {
|
||||
it('assembles a mail link only after pointer or keyboard interaction', async () => {
|
||||
const wrapper = mount(ContactDestination, { props: { contact } });
|
||||
await nextTick();
|
||||
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false);
|
||||
expect(wrapper.get('.contact-email__preview').text()).toBe('');
|
||||
await wrapper.get('.contact-email__action').trigger('focusin');
|
||||
expect(wrapper.get('a.contact-link').attributes('href')).toBe('mailto:luca.pislaru@gmail.com');
|
||||
expect(wrapper.get('a.contact-link').text()).toBe('Email Me');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('reveals a copyable address through the native disclosure', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } });
|
||||
const wrapper = mount(ContactDestination, { props: { contact } });
|
||||
await nextTick();
|
||||
const details = wrapper.get('details');
|
||||
(details.element as HTMLDetailsElement).open = true;
|
||||
await details.trigger('toggle');
|
||||
expect(wrapper.get('summary').text()).toBe('Hide email');
|
||||
expect(wrapper.get('.contact-email__address').text()).toBe('luca.pislaru@gmail.com');
|
||||
await wrapper.get('.contact-email__copy').trigger('click');
|
||||
expect(writeText).toHaveBeenCalledWith('luca.pislaru@gmail.com');
|
||||
expect(wrapper.get('[role="status"]').text()).toBe('Copied');
|
||||
await wrapper.get('.contact-email').trigger('keydown', { key: 'Escape' });
|
||||
expect((details.element as HTMLDetailsElement).open).toBe(false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the address selectable when clipboard permission is unavailable', async () => {
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Unavailable')) } });
|
||||
const wrapper = mount(ContactDestination, { props: { contact } });
|
||||
await nextTick();
|
||||
(wrapper.get('details').element as HTMLDetailsElement).open = true;
|
||||
await wrapper.get('details').trigger('toggle');
|
||||
await wrapper.get('.contact-email__copy').trigger('click');
|
||||
expect(wrapper.get('[role="status"]').text()).toBe('Select the address to copy it.');
|
||||
expect(wrapper.get('.contact-email__address').text()).toContain('@');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe('safe metadata', () => {
|
||||
|
||||
expect(metadata.title).toBe('Luca Pîslaru — System Administrator & Full-Stack Developer');
|
||||
expect(metadata.description).toBe(
|
||||
'Portfolio of Luca Pîslaru, a Bucharest-based System Administrator and Full-Stack Developer working across Linux infrastructure and end-to-end software delivery.',
|
||||
'I’m Luca Pîslaru, a System Administrator & Full-Stack Developer based in Bucharest. Explore my work in Linux infrastructure and end-to-end application development.',
|
||||
);
|
||||
expect(metadata.canonicalUrl).toBe('https://portfolio.example.test/');
|
||||
const previewAsset = manifest.assets.find(({ id }) => id === content.metadata.socialPreviewAssetId)!;
|
||||
@@ -59,7 +59,6 @@ describe('safe metadata', () => {
|
||||
jobTitle: 'System Administrator & Full-Stack Developer',
|
||||
description: metadata.description,
|
||||
url: 'https://portfolio.example.test/',
|
||||
email: 'mailto:luca.pislaru@gmail.com',
|
||||
homeLocation: {
|
||||
'@type': 'Place',
|
||||
address: {
|
||||
@@ -69,14 +68,14 @@ describe('safe metadata', () => {
|
||||
},
|
||||
},
|
||||
sameAs: [
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
'https://github.com/LucaPisl',
|
||||
'https://gitlab.com/luca_pisl',
|
||||
'https://linkedin.com/in/luca-pisl',
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(metadata.jsonLd)).not.toMatch(
|
||||
/streetAddress|postalCode|telephone|interactionStatistic|"image"/iu,
|
||||
/streetAddress|postalCode|telephone|interactionStatistic|"image"|"email"/iu,
|
||||
);
|
||||
const expectedHash = createHash('sha256').update(metadata.serializedJsonLd).digest('base64');
|
||||
expect(metadata.jsonLdCspHash).toBe(`sha256-${expectedHash}`);
|
||||
|
||||
Reference in New Issue
Block a user