Compare commits

..
Author SHA1 Message Date
ivanch c232eee1e8 Merge pull request 'improving website and optimizing readme' (#4) from feat/new-style into main
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 37s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 6s
Reviewed-on: #4
2026-10-13 12:49:11 +00:00
ivanch d5d4ab251b improving wording again
Homepage Build and Deploy (Test) / Build Homepage Image (pull_request) Successful in 48s
Homepage Build and Deploy (Test) / Deploy Homepage (pull_request) Successful in 7s
2026-10-13 09:40:47 -03:00
ivanch 11559d64c2 improving wordings 2026-10-13 09:38:45 -03:00
ivanch 409e0b881f improving readme 2026-10-12 20:55:59 -03:00
ivanch 476651b789 improving fonts / memory usage 2026-10-12 15:45:45 -03:00
ivanch be486a9580 removing old files 2026-10-12 15:45:29 -03:00
ivanch 870afc8b88 improving performance 2026-10-12 15:38:17 -03:00
ivanch 67335fb55b re-styling readme 2026-10-12 15:04:02 -03:00
ivanch e205172fce making it mobile friendly
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 42s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 5s
2026-10-11 16:48:40 -03:00
ivanch 6a93e9ae8d adding readme.md
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 11s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 4s
2026-09-09 22:28:18 -03:00
16 changed files with 419 additions and 470 deletions

No files matched your search

+7 -7
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="The requested page could not be found.">
<meta name="author" content="ivanch">
@@ -15,13 +15,13 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>404 | ivanch</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/matrix.css">
<link rel="stylesheet" href="css/main.css?v=20261012-1">
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/button.css">
<link rel="stylesheet" href="css/sections/error.css">
<!-- Remix Icon CDN for the navigation icon -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<!-- Remix Icon subset (only the icon used on this page) -->
<link rel="stylesheet" href="css/icons.css">
</head>
<body>
@@ -45,7 +45,7 @@
</section>
</main>
<script src="js/flowfield.js"></script>
<script src="js/flowfield.js?v=20261012-1"></script>
</body>
</html>
</html>
+56
View File
@@ -0,0 +1,56 @@
```text
_ _
(_)__ ____ _ _ __ ___| |__
| |\ \ / / _` | '_ \ / __| '_ \
| | \ V / (_| | | | | (__| | | |
|_| \_/ \__,_|_| |_|\___|_| |_|
software, servers, and a little sorcery
```
<p align="center">
<a href="https://ivanch.me"><img alt="Website" src="https://img.shields.io/badge/Website-ivanch.me-4FC3F7?style=for-the-badge&logo=googlechrome&logoColor=white"></a>
<a href="https://github.com/ivanch"><img alt="GitHub" src="https://img.shields.io/badge/GitHub-ivanch-181717?style=for-the-badge&logo=github&logoColor=white"></a>
<a href="https://git.ivanch.me/ivanch"><img alt="Gitea" src="https://img.shields.io/badge/-Gitea-609926?style=for-the-badge&logo=gitea&logoColor=white"></a>
<a href="https://blog.ivanch.me"><img alt="Blog" src="https://img.shields.io/badge/-Blog-667EEA?style=for-the-badge"></a>
<a href="https://www.linkedin.com/in/joseivanch/"><img alt="LinkedIn" src="https://img.shields.io/badge/-LinkedIn-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white"></a>
</p>
---
## `$ whoami`
Hey, I'm **José**, usually found online as **ivanch**.
I build software with .NET, automate the path from commit to deployment,
and give my spare hardware more responsibilities than it probably asked for.
My corner of the internet is part portfolio, part doorway into the homelab.
```text
interests
|-- building robust and maintainable software
|-- making CI/CD do the repetitive work
|-- orchestrating containers with Kubernetes
`-- self-hosting, tinkering, and open source
```
## `$ ls toolbox/`
| Shelf | Tools |
| :--- | :--- |
| **Code** | ![.NET](https://img.shields.io/badge/.NET-512BD4?style=flat-square&logo=dotnet&logoColor=white) ![React](https://img.shields.io/badge/React-20232A?style=flat-square&logo=react&logoColor=61DAFB) ![Python](https://img.shields.io/badge/Python-3776AB?style=flat-square&logo=python&logoColor=white) ![HTML](https://img.shields.io/badge/HTML-E34F26?style=flat-square&logo=html5&logoColor=white) ![CSS](https://img.shields.io/badge/CSS-1572B6?style=flat-square&logo=css3&logoColor=white) |
| **Data** | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?style=flat-square&logo=postgresql&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-4479A1?style=flat-square&logo=mysql&logoColor=white) ![Redis](https://img.shields.io/badge/Redis-DC382D?style=flat-square&logo=redis&logoColor=white) |
| **Infrastructure** | ![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black) ![Bash](https://img.shields.io/badge/Bash-4EAA25?style=flat-square&logo=gnubash&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=flat-square&logo=docker&logoColor=white) ![Kubernetes](https://img.shields.io/badge/Kubernetes-326CE5?style=flat-square&logo=kubernetes&logoColor=white) ![Terraform](https://img.shields.io/badge/Terraform-7B42BC?style=flat-square&logo=terraform&logoColor=white) |
| **Delivery** | ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-2088FF?style=flat-square&logo=githubactions&logoColor=white) ![Gitea Actions](https://img.shields.io/badge/Gitea%20Actions-609926?style=flat-square&logo=gitea&logoColor=white) |
## `$ ls projects/`
A few things I've sent out into the world:
| Project | What it does |
| :--- | :--- |
| **[OpenCand](https://github.com/ivanch/opencand)** | Search and explore Brazilian candidate data with .NET Core and React |
| **[Vision Start](https://github.com/ivanch/vision-start)** | A customizable Chrome startpage with a glassmorphism look |
| **[Kasbot](https://github.com/ivanch/kasbot)** | A Discord bot inspired by a Brazilian DJ |
| **[Candlebright](https://github.com/ivanch/candlebright)** | A game project written in C++ |
| **[Haven](https://github.com/ivanch/haven)** | My personal homelab (now with GitOps!) |
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+35
View File
@@ -0,0 +1,35 @@
/*
* Remix Icon v3.5.0 (subset)
* https://remixicon.com
* Released under the Apache License Version 2.0
*
* Only the icons used by the site are included. The font file is a subset of
* remixicon.woff2 that contains just these glyphs. To add an icon, copy its
* rule from the full remixicon.css and re-subset the font with its codepoint.
*/
@font-face {
font-family: "remixicon";
src: url("../assets/fonts/remixicon-subset.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
[class^="ri-"], [class*=" ri-"] {
font-family: 'remixicon' !important;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.ri-article-line:before { content: "\ea7e"; }
.ri-briefcase-line:before { content: "\eaf5"; }
.ri-building-2-fill:before { content: "\eb08"; }
.ri-git-repository-fill:before { content: "\edc6"; }
.ri-github-fill:before { content: "\edca"; }
.ri-global-line:before { content: "\edcf"; }
.ri-graduation-cap-fill:before { content: "\f332"; }
.ri-home-5-line:before { content: "\ee1f"; }
.ri-linkedin-fill:before { content: "\eeb5"; }
.ri-mail-fill:before { content: "\eef3"; }
.ri-terminal-box-line:before { content: "\f1f6"; }
+23 -32
View File
@@ -16,7 +16,6 @@ body {
font-family: var(--font-family);
background: var(--background-color);
min-height: 100vh;
overflow-x: hidden;
position: relative;
color: var(--text-color);
-webkit-font-smoothing: antialiased;
@@ -60,18 +59,19 @@ img {
border-top: 1px solid var(--glass-border);
border-bottom: 1px solid var(--glass-border);
box-shadow: var(--shadow-lg), var(--inset-shadow);
will-change: box-shadow;
animation: glass-shadow 10s ease-in-out infinite;
}
.main-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
--page-gutter: var(--space-lg);
--page-top: var(--space-xl);
position: relative;
margin: 0 auto;
max-width: 1200px;
width: 100%;
padding: 0 var(--space-lg);
min-height: 100vh;
min-height: 100svh;
padding: var(--page-top) var(--page-gutter) var(--space-xl);
display: flex;
align-items: center;
justify-content: center;
@@ -114,8 +114,8 @@ img {
padding: var(--space-2xl) var(--space-xl);
transition: background var(--dur-med) var(--ease-out-soft),
box-shadow var(--dur-med) var(--ease-out-soft);
min-width: 26rem;
max-width: 26rem;
min-width: 0;
width: 100%;
}
/* ------------------------------------------------------------------ */
@@ -158,45 +158,36 @@ img {
/* ------------------------------------------------------------------ */
/* Mobile responsive layout */
/* Single breakpoint switches the centered card to a stacked column. */
/* Width constraints (set via tokens in section CSS) are overridden */
/* in one place here rather than repeated across multiple sections. */
/* Keep the page in normal flow so short screens can scroll fully. */
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.main-container {
position: relative;
top: auto;
left: auto;
transform: none;
padding: var(--space-xl) var(--space-md);
min-height: 100vh;
display: flex;
--page-gutter: var(--space-md);
align-items: flex-start;
}
.content,
.homelab-section,
.projects-section {
min-width: unset;
max-width: unset;
width: 100%;
}
.content {
padding: var(--space-xl) var(--space-lg);
}
}
@media (max-width: 480px) {
body {
font-size: 14px;
}
.main-container {
padding: var(--space-xl) var(--space-sm);
--page-gutter: var(--space-xs);
--page-top: var(--space-md);
}
.content {
padding: var(--space-lg) var(--space-md);
}
}
/* Leave room for notches and the home indicator in landscape, too. */
@supports (padding: max(0px, env(safe-area-inset-left))) {
.main-container {
padding-top: max(var(--page-top), env(safe-area-inset-top));
padding-left: max(var(--page-gutter), env(safe-area-inset-left));
padding-right: max(var(--page-gutter), env(safe-area-inset-right));
padding-bottom: max(var(--space-xl), env(safe-area-inset-bottom));
}
}
+32 -6
View File
@@ -8,14 +8,40 @@
pointer-events: none;
overflow: hidden;
background: rgba(0, 0, 0, 0.88);
/* Colors are pre-dimmed (x0.7) rather than using `filter: brightness()`,
which would re-filter the whole animated canvas every frame. The
particle colors get the same treatment in js/flowfield.js. */
background-image:
radial-gradient(120% 100% at 50% 0%, rgba(20, 24, 48, 0.55) 0%, rgba(0, 0, 0, 0) 60%),
linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(18, 18, 38, 0.72) 100%);
filter: brightness(0.7) saturate(1.05);
radial-gradient(120% 100% at 50% 0%, rgba(14, 17, 34, 0.55) 0%, rgba(0, 0, 0, 0) 60%),
linear-gradient(135deg, rgba(0, 0, 0, 0.9) 0%, rgba(13, 13, 27, 0.72) 100%);
}
@media (prefers-reduced-motion: reduce) {
.matrix-canvas {
filter: brightness(0.6) saturate(0.9);
/* CSS only on phones/tablets and for reduced motion: no canvas work. */
/* Keep this query in sync with js/flowfield.js. */
@media (max-width: 1024px), (pointer: coarse), (prefers-reduced-motion: reduce) {
body {
background:
radial-gradient(ellipse at 0% 0%, rgba(79, 195, 247, 0.18), transparent 55%),
radial-gradient(ellipse at 100% 65%, rgba(118, 75, 162, 0.22), transparent 60%),
linear-gradient(150deg, #0b1220, #100c1c);
}
.matrix-canvas,
.avatar .glitch-layer {
display: none;
}
.main-container .glass,
.avatar::after,
.homelab-background,
.homelab-background::after,
.homelab-title {
animation: none;
}
/* Avoid blur compositing work while scrolling. */
body .main-container :is(.glass, .content, .projects-section, .homelab-section, .btn) {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
+21 -23
View File
@@ -5,17 +5,18 @@
padding: var(--space-2xl);
width: 100%;
display: grid;
grid-template-columns: 200px 1fr;
grid-template-columns: 180px minmax(0, 1fr);
gap: var(--space-xl);
align-items: center;
animation: fadeInUp 0.8s var(--ease-out-expo);
}
.hero-details {
display: flex;
flex-direction: row;
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
gap: var(--space-sm);
align-items: flex-start;
align-items: stretch;
min-width: 0;
width: 100%;
justify-content: flex-start;
}
@@ -153,7 +154,6 @@
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.hero-details {
flex-direction: column;
gap: var(--space-xl);
}
@@ -166,6 +166,10 @@
width: 100%;
align-items: stretch;
}
.tech-tags {
justify-content: center;
}
}
@media (max-width: 768px) {
@@ -176,8 +180,13 @@
text-align: center;
}
.hero-details {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-lg);
}
.name {
font-size: var(--text-xl);
font-size: var(--text-2xl);
}
.social-links {
@@ -191,35 +200,24 @@
padding: var(--space-lg) var(--space-md);
}
.name {
font-size: var(--text-lg);
}
.social-links {
gap: var(--space-xs);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
}
.social-link {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-xs);
font-size: var(--text-sm);
min-width: 0;
flex: 1;
max-width: calc(50% - 6px);
justify-content: center;
}
.social-link span {
display: none;
.social-link:last-child {
grid-column: 1 / -1;
}
.social-link i {
font-size: 16px;
}
}
@media (max-width: 380px) {
.social-link {
max-width: calc(100% / 2 - 6px);
padding: var(--space-sm);
}
}
+3 -5
View File
@@ -1,5 +1,4 @@
.homelab-section {
margin-top: var(--space-md);
padding: var(--space-xl) var(--space-lg);
background: rgba(20, 20, 30, 0.10);
backdrop-filter: blur(48px);
@@ -7,8 +6,8 @@
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-md), var(--inset-shadow);
border: 1px solid var(--glass-border);
min-width: 26rem;
max-width: 26rem;
min-width: 0;
width: 100%;
align-self: stretch;
display: flex;
flex-direction: column;
@@ -220,12 +219,11 @@
.homelab-buttons {
gap: var(--space-sm);
flex-direction: column;
align-items: center;
align-items: stretch;
}
.homelab-btn {
width: 100%;
max-width: 200px;
justify-content: center;
}
}
+23 -3
View File
@@ -262,6 +262,26 @@
/* ------------------------------------------------------------------ */
/* Responsive */
/* ------------------------------------------------------------------ */
@media (min-width: 769px) and (max-width: 1024px) {
.profile-container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xl);
}
.profile-container .profile-info {
margin-top: 0;
align-items: flex-start;
text-align: left;
}
.profile-container .avatar {
margin: 0;
flex-shrink: 0;
}
}
@media (max-width: 1024px) {
.profile-info {
align-items: center;
@@ -304,7 +324,7 @@
}
.profile-info-item {
font-size: var(--text-xs);
font-size: var(--text-sm);
flex-direction: row;
gap: var(--space-xs);
}
@@ -327,6 +347,6 @@
}
.profile-info-item {
font-size: var(--text-xs);
font-size: var(--text-sm);
}
}
}
+17 -16
View File
@@ -1,5 +1,4 @@
.projects-section {
flex: 0 0 340px;
display: flex;
flex-direction: column;
gap: var(--space-md);
@@ -10,8 +9,8 @@
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-md), var(--inset-shadow);
border-bottom: 1px solid var(--glass-border);
min-width: 380px;
max-width: 440px;
min-width: 0;
width: 100%;
align-self: stretch;
}
@@ -112,13 +111,11 @@
flex-wrap: nowrap;
}
/* Keep long titles from pushing icons to a new line */
/* Let titles wrap while keeping the action links available. */
.project-title > span:first-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
}
.project-icon-link {
@@ -150,12 +147,16 @@
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 1024px) {
.projects-section {
flex: unset;
max-width: 100%;
margin-left: 0;
margin-top: var(--space-xl);
min-width: unset;
.project-icon-link {
width: 44px;
height: 44px;
}
}
@media (pointer: coarse) {
.project-icon-link {
width: 44px;
height: 44px;
}
}
@@ -179,7 +180,7 @@
}
.projects-title {
font-size: var(--text-sm);
font-size: var(--text-md);
}
.project {
@@ -191,7 +192,7 @@
}
.project-desc {
font-size: var(--text-xs);
font-size: var(--text-sm);
}
.project-icons {
@@ -205,7 +206,7 @@
}
.project-title {
font-size: var(--text-xs);
font-size: var(--text-sm);
flex-direction: row;
align-items: center;
gap: var(--space-xs);
+12 -12
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="ivanch's personal portfolio showcasing projects and homelab setup.">
<meta name="author" content="ivanch">
@@ -14,18 +14,18 @@
<link rel="icon" href="/assets/favicon.ico" type="image/x-icon">
<title>ivanch</title>
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/main.css?v=20261012-1">
<link rel="stylesheet" href="css/sections/profile.css">
<link rel="stylesheet" href="css/sections/homelab.css">
<link rel="stylesheet" href="css/sections/projects.css">
<link rel="stylesheet" href="css/sections/hero.css">
<link rel="stylesheet" href="css/sections/profile.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/homelab.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/projects.css?v=20261011-2">
<link rel="stylesheet" href="css/sections/hero.css?v=20261011-2">
<link rel="stylesheet" href="css/matrix.css">
<link rel="stylesheet" href="css/matrix.css?v=20261012-1">
<link rel="stylesheet" href="css/button.css">
<!-- Remix Icon CDN for social icons -->
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<!-- Remix Icon subset (only the icons used on this page) -->
<link rel="stylesheet" href="css/icons.css">
</head>
<body>
<canvas class="matrix-canvas" id="matrixCanvas"></canvas>
@@ -34,7 +34,7 @@
<div class="glass hero-card">
<div class="profile-container">
<div class="avatar">
<img src="/assets/lain.png" alt="Avatar" />
<img src="/assets/lain.jpg" alt="Avatar" width="320" height="320" decoding="async" />
</div>
<div class="profile-info">
<div class="profile-info-item">
@@ -149,7 +149,7 @@
</div>
</main>
<script src="js/flowfield.js"></script>
<script src="js/flowfield.js?v=20261012-1"></script>
<script src="js/social.js"></script>
</body>
</html>
</html>
+190 -43
View File
@@ -10,6 +10,12 @@
* Trails are drawn by erasing the previous frame with a low-alpha
* `destination-out` pass, so the CSS gradient painted behind the canvas
* element keeps showing through instead of being flooded by an opaque fill.
*
* Performance notes: particles are bucketed by (tone, width, alpha) and each
* bucket is stroked as a single path, so a frame issues roughly a third of
* the draw calls of one-per-particle, with no per-frame string allocation.
* The loop is capped at ~60 fps so high-refresh displays don't do 2x the
* work (the simulation is frame-based, so this also keeps speed consistent).
*/
/* Deterministic PRNG so the noise permutation is stable within a page load. */
@@ -116,7 +122,10 @@ class FlowFieldBackground {
this.sparks = [];
this.mouse = { x: 0, y: 0, active: false };
this.rafId = null;
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.resizeRaf = null;
this.lastFrame = 0;
this.frameInterval = 1000 / 60;
this.events = new AbortController();
this.width = 0;
this.height = 0;
@@ -150,54 +159,84 @@ class FlowFieldBackground {
{ weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet
];
this.buildStyleCache();
this.init();
}
buildStyleCache() {
// Colors are dimmed here instead of with a CSS `filter: brightness()`
// on the canvas, which would cost a full-screen filter pass per frame.
// Additive blending is linear, so the result is nearly identical.
const dim = 0.7;
const dimRgb = (rgb) => rgb.split(',').map((c) => Math.round(c * dim)).join(', ');
for (const tone of this.palette) tone.drawRgb = dimRgb(tone.rgb);
const dotRgb = dimRgb('240, 248, 255');
// Alpha and line width are quantized so particles can share a path.
// Steps are small enough that the difference is not visible.
this.alphaStep = 0.02;
this.alphaLevels = Math.round(1 / this.alphaStep) + 1;
this.widthStep = 0.2;
this.widthLevels = 10; // covers the 0.4px - 1.8px particle range
this.strokeStyles = [];
for (const tone of this.palette) {
for (let a = 0; a < this.alphaLevels; a++) {
this.strokeStyles.push(`rgba(${tone.drawRgb}, ${(a * this.alphaStep).toFixed(3)})`);
}
}
this.dotStyles = [];
for (let a = 0; a < this.alphaLevels; a++) {
this.dotStyles.push(`rgba(${dotRgb}, ${(a * this.alphaStep).toFixed(3)})`);
}
const lineBuckets = this.palette.length * this.widthLevels * this.alphaLevels;
this.lineCounts = new Uint16Array(lineBuckets + 1);
this.dotCounts = new Uint16Array(this.alphaLevels + 1);
}
init() {
this.bindEvents();
this.handleResize();
if (this.prefersReducedMotion) {
// Single long-exposure render; no animation loop at all.
this.warmup(180);
return;
}
// Pre-run a short exposure so the first paint is already textured.
this.warmup(60);
this.animate();
}
bindEvents() {
const signal = this.events.signal;
// The canvas has pointer-events: none, so listen on window.
window.addEventListener('pointermove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.mouse.active = true;
}, { passive: true });
}, { passive: true, signal });
window.addEventListener('pointerdown', (e) => {
this.spawnClickBurst(e.clientX, e.clientY);
}, { passive: true });
}, { passive: true, signal });
window.addEventListener('pointerout', () => {
this.mouse.active = false;
});
}, { signal });
// Pause the rAF loop when the tab is hidden to save CPU / battery.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.stop();
} else if (!this.prefersReducedMotion) {
} else {
this.start();
}
});
}, { signal });
let resizeRaf = null;
window.addEventListener('resize', () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => this.handleResize());
});
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = null;
this.handleResize();
});
}, { signal });
}
start() {
@@ -212,6 +251,17 @@ class FlowFieldBackground {
}
}
destroy() {
this.stop();
this.events.abort();
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
this.particles = [];
this.sparks = [];
// Release the high-DPI drawing buffer when switching to CSS only.
this.canvas.width = 1;
this.canvas.height = 1;
}
handleResize() {
const w = window.innerWidth;
const h = window.innerHeight;
@@ -222,7 +272,9 @@ class FlowFieldBackground {
this.width = w;
this.height = h;
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
// The strokes are faint and soft, so a 1x buffer looks nearly the same
// on HiDPI screens while using a quarter of the memory and fill cost.
this.dpr = 1;
this.canvas.width = Math.round(w * this.dpr);
this.canvas.height = Math.round(h * this.dpr);
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
@@ -234,21 +286,28 @@ class FlowFieldBackground {
this.particles.push(this.createParticle());
}
// Scratch buffers for bucketing particles at draw time.
this.lineKeys = new Int16Array(count);
this.lineOrder = new Uint16Array(count);
this.dotKeys = new Int16Array(count);
this.dotOrder = new Uint16Array(count);
// Repaint a short exposure so a resize never flashes an empty canvas.
this.warmup(30);
}
pickTone() {
let roll = Math.random();
for (const tone of this.palette) {
roll -= tone.weight;
if (roll <= 0) return tone;
for (let i = 0; i < this.palette.length; i++) {
roll -= this.palette[i].weight;
if (roll <= 0) return i;
}
return this.palette[0];
return 0;
}
createParticle() {
const tone = this.pickTone();
const toneIndex = this.pickTone();
const tone = this.palette[toneIndex];
const { min, max } = this.config.life;
// 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight)
@@ -280,7 +339,7 @@ class FlowFieldBackground {
vy: 0,
speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5),
width: (0.7 + Math.random() * 0.5) * widthMult,
rgb: tone.rgb,
tone: toneIndex,
baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult,
alpha: 0,
glow: 0,
@@ -305,7 +364,7 @@ class FlowFieldBackground {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 1.0 + Math.random() * 2.2;
const tone = this.pickTone();
const tone = this.palette[this.pickTone()];
this.sparks.push({
x,
y,
@@ -313,7 +372,7 @@ class FlowFieldBackground {
py: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rgb: tone.rgb,
rgb: tone.drawRgb,
size: 0.6 + Math.random() * 0.8,
alpha: 0.6 + Math.random() * 0.25,
life: 0,
@@ -432,34 +491,99 @@ class FlowFieldBackground {
this.ctx.fillRect(0, 0, this.width, this.height);
}
/* Counting sort: groups particle indices in `order` by their bucket key
(keys < 0 are skipped). Afterwards counts[k] is the end offset of
bucket k, and bucket k starts where bucket k - 1 ends. */
sortByKey(keys, n, counts, order) {
counts.fill(0);
for (let i = 0; i < n; i++) {
if (keys[i] >= 0) counts[keys[i] + 1]++;
}
for (let k = 1; k < counts.length; k++) counts[k] += counts[k - 1];
for (let i = 0; i < n; i++) {
const k = keys[i];
if (k >= 0) order[counts[k]++] = i;
}
}
drawParticles() {
const ctx = this.ctx;
// Additive blending gives a soft glow where currents overlap.
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
const particles = this.particles;
const n = particles.length;
const { alphaStep, alphaLevels, widthStep, widthLevels, lineKeys, dotKeys } = this;
const glowBoost = this.config.glowBoost;
for (const p of this.particles) {
for (let i = 0; i < n; i++) {
const p = particles[i];
lineKeys[i] = -1;
dotKeys[i] = -1;
if (p.fresh) {
p.fresh = false;
continue;
}
const alpha = Math.min(1, p.alpha + p.glow * this.config.glowBoost);
if (alpha <= 0.005) continue;
const alpha = Math.min(1, p.alpha + p.glow * glowBoost);
const a = Math.round(alpha / alphaStep);
if (a === 0) continue;
ctx.strokeStyle = `rgba(${p.rgb}, ${alpha.toFixed(3)})`;
ctx.lineWidth = p.width;
ctx.beginPath();
ctx.moveTo(p.px, p.py);
ctx.lineTo(p.x, p.y);
ctx.stroke();
const w = Math.min(widthLevels - 1, Math.round(p.width / widthStep));
lineKeys[i] = (p.tone * widthLevels + w) * alphaLevels + a;
// Draw a subtle bright dot at the head of foreground particles
// A subtle bright dot at the head of foreground particles
if (p.depth === 2 && (p.glow > 0.1 || Math.random() < 0.12)) {
ctx.fillStyle = `rgba(240, 248, 255, ${(alpha * 0.75).toFixed(3)})`;
dotKeys[i] = Math.round((alpha * 0.75) / alphaStep);
}
}
// Additive blending gives a soft glow where currents overlap. It is
// also order-independent, so batching does not change the result.
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
const lineCounts = this.lineCounts;
const lineOrder = this.lineOrder;
this.sortByKey(lineKeys, n, lineCounts, lineOrder);
let start = 0;
for (let k = 0; k < lineCounts.length - 1; k++) {
const end = lineCounts[k];
if (end === start) continue;
const a = k % alphaLevels;
const toneWidth = (k - a) / alphaLevels;
const w = toneWidth % widthLevels;
const tone = (toneWidth - w) / widthLevels;
ctx.strokeStyle = this.strokeStyles[tone * alphaLevels + a];
ctx.lineWidth = w * widthStep;
ctx.beginPath();
for (let j = start; j < end; j++) {
const p = particles[lineOrder[j]];
ctx.moveTo(p.px, p.py);
ctx.lineTo(p.x, p.y);
}
ctx.stroke();
start = end;
}
const dotCounts = this.dotCounts;
const dotOrder = this.dotOrder;
this.sortByKey(dotKeys, n, dotCounts, dotOrder);
start = 0;
for (let a = 0; a < dotCounts.length - 1; a++) {
const end = dotCounts[a];
if (end === start) continue;
if (a > 0) {
ctx.fillStyle = this.dotStyles[a];
ctx.beginPath();
ctx.arc(p.x, p.y, p.width * 0.75, 0, Math.PI * 2);
for (let j = start; j < end; j++) {
const p = particles[dotOrder[j]];
const r = p.width * 0.75;
ctx.moveTo(p.x + r, p.y);
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
}
ctx.fill();
}
start = end;
}
}
@@ -497,12 +621,35 @@ class FlowFieldBackground {
}
}
animate() {
animate(now = performance.now()) {
this.rafId = requestAnimationFrame((t) => this.animate(t));
// Cap at ~60 fps; keep the remainder so 120/144 Hz average out to 60.
const elapsed = now - this.lastFrame;
if (elapsed < this.frameInterval * 0.9) return;
this.lastFrame = now - (elapsed % this.frameInterval);
this.step(true);
this.rafId = requestAnimationFrame(() => this.animate());
}
}
document.addEventListener('DOMContentLoaded', () => {
new FlowFieldBackground();
// Keep this query in sync with the static backdrop in css/matrix.css.
const staticBackground = window.matchMedia(
'(max-width: 1024px), (pointer: coarse), (prefers-reduced-motion: reduce)'
);
let background = null;
function updateBackground() {
if (staticBackground.matches) {
if (background) background.destroy();
background = null;
} else if (!background) {
background = new FlowFieldBackground();
if (document.hidden) background.stop();
}
}
staticBackground.addEventListener('change', updateBackground);
updateBackground();
});
-320
View File
@@ -1,320 +0,0 @@
class MatrixBackground {
constructor() {
this.canvas = document.getElementById('matrixCanvas');
this.ctx = this.canvas.getContext('2d');
this.dots = [];
this.connections = [];
this.mouse = { x: -9999, y: -9999, active: false };
this.rafId = null;
this.isRunning = true;
this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.config = {
dotCount: this.getMobileDotCount(),
dotSpeed: 0.2,
connectionDuration: { min: 2000, max: 6000 },
connectionChance: 0.0002,
maxConnections: this.getMobileMaxConnections(),
dotSize: 3,
mouseRadius: 110,
mouseForce: 0.6,
// Palette synced to the UI accent tokens
colors: {
dotDefault: 'rgba(230, 240, 255, 0.78)',
dotDefaultGlow: 'rgba(180, 220, 255, 0.40)',
dotConnected: 'rgba(120, 190, 245, 0.85)',
dotConnectedGlow: 'rgba(79, 195, 247, 0.55)',
connection: {
start: 'rgba(102, 126, 234, 0.22)',
middle: 'rgba(118, 75, 162, 0.62)',
end: 'rgba(79, 195, 247, 0.22)'
}
}
};
this.init();
}
getMobileDotCount() {
if (window.innerWidth <= 480) return 35;
if (window.innerWidth <= 768) return 45;
if (window.innerWidth >= 2100) return 100;
return 50;
}
getMobileMaxConnections() {
if (window.innerWidth <= 480) return 4;
if (window.innerWidth <= 768) return 6;
return 8;
}
init() {
this.handleResize();
this.bindEvents();
this.animate();
}
bindEvents() {
// Pointer reactivity — push dots away from cursor for a "matrix repel" feel.
// Touch devices are included; the dot pushes happen only while touched.
window.addEventListener('pointermove', (e) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
this.mouse.active = true;
}, { passive: true });
window.addEventListener('pointerout', () => {
this.mouse.active = false;
this.mouse.x = -9999;
this.mouse.y = -9999;
});
// Pause the rAF loop when the tab is hidden to save CPU / battery.
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.stop();
} else {
this.start();
}
});
// Resize is debounced via rAF; disabled on mobile for perf parity with original.
if (window.innerWidth > 768) {
let resizeRaf = null;
window.addEventListener('resize', () => {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(() => this.handleResize());
});
}
}
start() {
if (this.rafId || !this.isRunning) return;
this.animate();
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
handleResize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.config.dotCount = this.getMobileDotCount();
this.config.maxConnections = this.getMobileMaxConnections();
this.createDots();
}
createDots() {
this.dots = [];
for (let i = 0; i < this.config.dotCount; i++) {
this.createDot();
}
}
createDot() {
const startX = Math.random() * (this.canvas.width - 20) + 10;
const startY = Math.random() * (this.canvas.height - 20) + 10;
const angle = Math.random() * Math.PI * 2;
const speed = this.config.dotSpeed * (0.8 + Math.random() * 0.4);
const baseVelocityX = Math.cos(angle) * speed;
const baseVelocityY = Math.sin(angle) * speed;
this.dots.push({
x: startX,
y: startY,
vx: baseVelocityX,
vy: baseVelocityY,
baseVx: baseVelocityX,
baseVy: baseVelocityY,
wanderPhase: Math.random() * Math.PI * 2,
wanderSpeed: 0.01 + Math.random() * 0.01,
opacity: Math.random() * 0.3 + 0.7,
size: this.config.dotSize + Math.random() * 2,
connectionCount: 0
});
}
updateDots() {
const radius = this.config.mouseRadius;
const radiusSq = radius * radius;
const force = this.config.mouseForce;
this.dots.forEach(dot => {
// Mouse repulsion
if (this.mouse.active) {
const dx = dot.x - this.mouse.x;
const dy = dot.y - this.mouse.y;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq && distSq > 0.5) {
const dist = Math.sqrt(distSq);
const strength = (1 - dist / radius) * force;
dot.vx += (dx / dist) * strength;
dot.vy += (dy / dist) * strength;
}
}
dot.x += dot.vx;
dot.y += dot.vy;
// Ease back to a gentle autonomous drift after pointer interaction.
dot.wanderPhase += dot.wanderSpeed;
const wanderX = Math.cos(dot.wanderPhase) * 0.03;
const wanderY = Math.sin(dot.wanderPhase * 0.8) * 0.03;
dot.vx += (dot.baseVx + wanderX - dot.vx) * 0.02;
dot.vy += (dot.baseVy + wanderY - dot.vy) * 0.02;
// Reflect at the canvas edge. Keep the base velocity in sync with
// the bounce so the drift easing does not push the dot back into
// the same wall on the next frames.
const dotRadius = dot.size / 2;
const minX = dotRadius;
const minY = dotRadius;
const maxX = Math.max(minX, this.canvas.width - dotRadius);
const maxY = Math.max(minY, this.canvas.height - dotRadius);
if (dot.x <= minX && dot.vx < 0) {
dot.x = minX + (minX - dot.x);
dot.vx = Math.abs(dot.vx);
dot.baseVx = Math.abs(dot.baseVx);
} else if (dot.x >= maxX && dot.vx > 0) {
dot.x = maxX - (dot.x - maxX);
dot.vx = -Math.abs(dot.vx);
dot.baseVx = -Math.abs(dot.baseVx);
}
if (dot.y <= minY && dot.vy < 0) {
dot.y = minY + (minY - dot.y);
dot.vy = Math.abs(dot.vy);
dot.baseVy = Math.abs(dot.baseVy);
} else if (dot.y >= maxY && dot.vy > 0) {
dot.y = maxY - (dot.y - maxY);
dot.vy = -Math.abs(dot.vy);
dot.baseVy = -Math.abs(dot.baseVy);
}
// A large pointer impulse can overshoot both edges by more than
// one frame, so clamp after reflecting the position.
dot.x = Math.max(minX, Math.min(maxX, dot.x));
dot.y = Math.max(minY, Math.min(maxY, dot.y));
});
}
drawDots() {
this.dots.forEach(dot => {
const isConnected = dot.connectionCount > 0;
this.ctx.beginPath();
this.ctx.arc(dot.x, dot.y, dot.size / 2, 0, Math.PI * 2);
this.ctx.fillStyle = isConnected ? this.config.colors.dotConnected : this.config.colors.dotDefault;
this.ctx.globalAlpha = dot.opacity;
this.ctx.shadowBlur = 6;
this.ctx.shadowColor = isConnected ? this.config.colors.dotConnectedGlow : this.config.colors.dotDefaultGlow;
this.ctx.fill();
});
this.ctx.globalAlpha = 1;
this.ctx.shadowBlur = 0;
}
createConnection(dot1, dot2) {
if (this.connections.length >= this.config.maxConnections) return;
dot1.connectionCount++;
dot2.connectionCount++;
this.connections.push({
dot1: dot1,
dot2: dot2,
startTime: Date.now(),
duration: Math.random() * (this.config.connectionDuration.max - this.config.connectionDuration.min) + this.config.connectionDuration.min
});
}
updateConnections() {
this.connections = this.connections.filter(conn => {
const elapsed = Date.now() - conn.startTime;
if (elapsed > conn.duration) {
conn.dot1.connectionCount--;
conn.dot2.connectionCount--;
return false;
}
return true;
});
}
drawConnections() {
this.connections.forEach(conn => {
const { dot1, dot2, startTime, duration } = conn;
const elapsed = Date.now() - startTime;
const opacity = Math.min(1, elapsed / 500);
const gradient = this.ctx.createLinearGradient(dot1.x, dot1.y, dot2.x, dot2.y);
gradient.addColorStop(0, this.config.colors.connection.start);
gradient.addColorStop(0.5, this.config.colors.connection.middle);
gradient.addColorStop(1, this.config.colors.connection.end);
this.ctx.beginPath();
this.ctx.moveTo(dot1.x, dot1.y);
this.ctx.lineTo(dot2.x, dot2.y);
this.ctx.strokeStyle = gradient;
this.ctx.lineWidth = 2;
this.ctx.globalAlpha = opacity;
const pulse = (Math.sin((elapsed / 2000) * Math.PI * 2) + 1) / 2;
this.ctx.shadowBlur = 4 + pulse * 6;
this.ctx.shadowColor = 'rgba(118, 75, 162, 0.5)';
this.ctx.stroke();
});
this.ctx.globalAlpha = 1;
this.ctx.shadowBlur = 0;
}
tryCreateConnections() {
for (let i = 0; i < this.dots.length; i++) {
for (let j = i + 1; j < this.dots.length; j++) {
if (Math.random() < this.config.connectionChance) {
const dot1 = this.dots[i];
const dot2 = this.dots[j];
const dx = dot2.x - dot1.x;
const dy = dot2.y - dot1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 150 && distance > 30) {
const alreadyConnected = this.connections.some(conn =>
(conn.dot1 === dot1 && conn.dot2 === dot2) ||
(conn.dot1 === dot2 && conn.dot2 === dot1)
);
if (!alreadyConnected) {
this.createConnection(dot1, dot2);
}
}
}
}
}
}
animate() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.prefersReducedMotion) {
this.updateDots();
this.updateConnections();
this.tryCreateConnections();
}
this.drawConnections();
this.drawDots();
this.rafId = requestAnimationFrame(() => this.animate());
}
}
document.addEventListener('DOMContentLoaded', () => {
new MatrixBackground();
});
-3
View File
@@ -1,3 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}