Compare commits

...
Author SHA1 Message Date
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
ivanch 376a9aa5e4 Merge pull request 'feat/new-background' (#2) from feat/new-background into main
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 14s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 3s
Reviewed-on: #2
2026-07-30 00:22:38 +00:00
ivanch 269b306b87 adjustments
Homepage Build and Deploy (Test) / Build Homepage Image (pull_request) Successful in 1m27s
Homepage Build and Deploy (Test) / Deploy Homepage (pull_request) Successful in 10s
2026-07-21 20:52:16 -03:00
ivanch 632b807437 new background 2026-07-21 20:52:08 -03:00
ivanch b330959cc5 ci: switch workflows to runner-slim-amd64 (new pipeline runner label)
Homepage Build and Deploy (Test) / Build Homepage Image (push) Successful in 31s
Homepage Build and Deploy (Test) / Deploy Homepage (push) Successful in 9s
2026-07-13 22:49:52 -03:00
12 changed files with 735 additions and 96 deletions

No files matched your search

+2 -2
View File
@@ -11,7 +11,7 @@ env:
jobs:
build:
name: Build Homepage Image
runs-on: ubuntu-amd64
runs-on: runner-slim-amd64
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
steps:
@@ -33,7 +33,7 @@ jobs:
deploy:
name: Deploy Homepage
runs-on: ubuntu-amd64
runs-on: runner-slim-amd64
needs: build
steps:
- name: Check out repository
+2 -2
View File
@@ -15,7 +15,7 @@ env:
jobs:
build:
name: Build Homepage Image
runs-on: ubuntu-amd64
runs-on: runner-slim-amd64
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
steps:
@@ -39,7 +39,7 @@ jobs:
deploy:
name: Deploy Homepage
runs-on: ubuntu-amd64
runs-on: runner-slim-amd64
needs: build
steps:
- name: Check out repository
+4 -4
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,8 +15,8 @@
<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=20261011-2">
<link rel="stylesheet" href="css/matrix.css?v=20261011-2">
<link rel="stylesheet" href="css/button.css">
<link rel="stylesheet" href="css/sections/error.css">
@@ -45,7 +45,7 @@
</section>
</main>
<script src="js/matrix.js"></script>
<script src="js/flowfield.js?v=20261011-2"></script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
<h1 align="center">
Hey there, I'm José 👋
</h1>
<p align="center"> .NET Core | DevOps | Self-hosted Enthusiast </p>
I design and automate resilient systems ensuring rapid delivery and rock‑solid reliability.
Passionate about building robust code, streamlining CI/CD, orchestrating Kubernetes, and contributing to open-source.
---
<p align="center" style="margin-top: 25px;">
<img src="https://img.shields.io/badge/.NET_Core-512BD4?logo=.net&logoColor=white" alt=".NET Core"/>
<img src="https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB" alt="React"/>
<img src="https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white" alt="Python"/>
<img src="https://img.shields.io/badge/HTML5-E34F26?logo=html5&logoColor=white" alt="HTML5"/>
<img src="https://img.shields.io/badge/CSS3-1572B6?logo=css3&logoColor=white" alt="CSS3"/>
<br>
<img src="https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL"/>
<img src="https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white" alt="MySQL"/>
<img src="https://img.shields.io/badge/Redis-DC382D?logo=redis&logoColor=white" alt="Redis"/>
<br>
<img src="https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=black" alt="Linux"/>
<img src="https://img.shields.io/badge/Bash-4EAA25?logo=gnubash&logoColor=white" alt="Bash"/>
<img src="https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white" alt="Docker"/>
<img src="https://img.shields.io/badge/Kubernetes-326CE5?logo=kubernetes&logoColor=white" alt="Kubernetes"/>
<img src="https://img.shields.io/badge/Terraform-623CE4?logo=terraform&logoColor=white" alt="Terraform"/>
<img src="https://img.shields.io/badge/GitHub_Actions-2088FF?logo=github-actions&logoColor=white" alt="GitHub Actions"/>
</p>
---
<h3 align="center"> 🚀 Featured Projects </h3>
- 🔍 **[OpenCand](https://github.com/ivanch/opencand)** - [opencand.ivanch.me](https://opencand.ivanch.me)
Simple Brazilian candidate data viewer and searcher, made with .NET Core and React
- 📃 **[Vision Start](https://github.com/ivanch/vision-start)**
A glassmorphism-looking like, modern and customizable Chrome startpage
- ⚡️ **[Haven](https://github.com/ivanch/haven)** - *personal homelab*
Bare-metal Kubernetes cluster using k3s, some other Docker containers, and some sorcery
- 🎶 **[Kasbot](https://github.com/ivanch/kasbot)**
Discord bot inspired by a Brazilian DJ
<!--
---
<h3 align="center"> 🌐 Contacts and socials </h3>
<p align="center">
<a href="https://www.linkedin.com/in/joseivanch/">
<img src="https://img.shields.io/badge/LinkedIn-0A66C2?logo=linkedin&logoColor=white" alt="LinkedIn"/>
</a>
<a href="https://last.fm/user/ivanch">
<img src="https://img.shields.io/badge/Last.fm-D51007?logo=lastdotfm&logoColor=white" alt="Last.fm"/>
</a>
<a href="https://git.ivanch.me/ivanch">
<img src="https://img.shields.io/badge/Gitea-007300?logo=gitea&logoColor=white" alt="Gitea"/>
</a>
<a href="mailto:jose.henrique.ivan@gmail.com">
<img src="https://img.shields.io/badge/Email-333333?logo=gmail&logoColor=white" alt="Email"/>
</a>
<a href="https://blog.ivanch.me/">
<img src="https://img.shields.io/badge/Blog-9aa500?logo=blogger&logoColor=white" alt="Blog"/>
</a>
</p> -->
+23 -31
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;
@@ -65,13 +64,15 @@ img {
}
.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 +115,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 +159,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));
}
}
+31 -3
View File
@@ -14,8 +14,36 @@
filter: brightness(0.7) saturate(1.05);
}
@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;
}
.main-container .glass {
will-change: auto;
}
/* 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;
}
}
+22 -2
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);
+8 -8
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,14 +14,14 @@
<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=20261011-2">
<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=20261011-2">
<link rel="stylesheet" href="css/button.css">
<!-- Remix Icon CDN for social icons -->
@@ -149,7 +149,7 @@
</div>
</main>
<script src="js/matrix.js"></script>
<script src="js/flowfield.js?v=20261011-2"></script>
<script src="js/social.js"></script>
</body>
</html>
+534
View File
@@ -0,0 +1,534 @@
/**
* FlowFieldBackground
*
* Minimal generative backdrop: hundreds of hair-thin particles ride a
* domain-warped simplex flow field and leave short silk-like trails.
* The cursor bends the current into a slow vortex (nearby strokes pick up
* a faint glow); clicking / tapping emits a thin ripple that shoves
* particles aside.
*
* 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.
*/
/* Deterministic PRNG so the noise permutation is stable within a page load. */
function mulberry32(seed) {
return function () {
seed |= 0;
seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* Compact seeded 2D simplex noise (Gustavson-style), output in ~[-1, 1]. */
class SimplexNoise {
constructor(seed) {
const rand = mulberry32(seed);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
// Fisher-Yates shuffle for the permutation table.
for (let i = 255; i > 0; i--) {
const j = (rand() * (i + 1)) | 0;
const tmp = p[i];
p[i] = p[j];
p[j] = tmp;
}
this.perm = new Uint8Array(512);
this.permMod12 = new Uint8Array(512);
for (let i = 0; i < 512; i++) {
this.perm[i] = p[i & 255];
this.permMod12[i] = this.perm[i] % 12;
}
}
noise2D(xin, yin) {
const F2 = SimplexNoise.F2;
const G2 = SimplexNoise.G2;
const GRAD3 = SimplexNoise.GRAD3;
let n0 = 0, n1 = 0, n2 = 0;
const s = (xin + yin) * F2;
const i = Math.floor(xin + s);
const j = Math.floor(yin + s);
const t = (i + j) * G2;
const x0 = xin - (i - t);
const y0 = yin - (j - t);
// Corner offsets depend on which half of the simplex we landed in.
let i1, j1;
if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; }
const x1 = x0 - i1 + G2;
const y1 = y0 - j1 + G2;
const x2 = x0 - 1 + 2 * G2;
const y2 = y0 - 1 + 2 * G2;
const ii = i & 255;
const jj = j & 255;
let t0 = 0.5 - x0 * x0 - y0 * y0;
if (t0 >= 0) {
const g = GRAD3[this.permMod12[ii + this.perm[jj]]];
t0 *= t0;
n0 = t0 * t0 * (g[0] * x0 + g[1] * y0);
}
let t1 = 0.5 - x1 * x1 - y1 * y1;
if (t1 >= 0) {
const g = GRAD3[this.permMod12[ii + i1 + this.perm[jj + j1]]];
t1 *= t1;
n1 = t1 * t1 * (g[0] * x1 + g[1] * y1);
}
let t2 = 0.5 - x2 * x2 - y2 * y2;
if (t2 >= 0) {
const g = GRAD3[this.permMod12[ii + 1 + this.perm[jj + 1]]];
t2 *= t2;
n2 = t2 * t2 * (g[0] * x2 + g[1] * y2);
}
// Empirical factor scaling the sum into roughly [-1, 1].
return 70 * (n0 + n1 + n2);
}
}
SimplexNoise.F2 = 0.5 * (Math.sqrt(3) - 1);
SimplexNoise.G2 = (3 - Math.sqrt(3)) / 6;
SimplexNoise.GRAD3 = [
[1, 1], [-1, 1], [1, -1], [-1, -1],
[1, 0], [-1, 0], [1, 0], [-1, 0],
[0, 1], [0, -1], [0, 1], [0, -1]
];
class FlowFieldBackground {
constructor() {
// Legacy element id kept from the previous background; the canvas
// and its CSS (css/matrix.css) are reused on purpose.
this.canvas = document.getElementById('matrixCanvas');
this.ctx = this.canvas.getContext('2d');
this.noise = new SimplexNoise((Math.random() * 0xffffffff) | 0);
this.particles = [];
this.sparks = [];
this.mouse = { x: 0, y: 0, active: false };
this.rafId = null;
this.resizeRaf = null;
this.events = new AbortController();
this.width = 0;
this.height = 0;
this.dpr = 1;
// Random phase so each visit opens on a different current.
this.time = Math.random() * 4000;
this.config = {
baseSpeed: 0.75,
maxSpeed: 2.8, // velocity cap so streaks remain calm
steer: 0.055, // how fast particles align with the field
fieldScale: 0.0015, // noise zoom: small value = large, calm features
warpStrength: 160, // px of domain warp, keeps the flow organic
winding: 2.4, // field angle range multiplier (x PI)
timeDrift: 0.00028, // slow evolution of the whole field
fadeAlpha: 0.038, // trail persistence (lower = longer trails)
mouseRadius: 180,
mouseSwirl: 0.5, // tangential push around the cursor
mousePull: 0.06, // whisper of inward pull -> orbiting feel
glowBoost: 0.25, // extra alpha for particles near the cursor
sparkCount: 10, // subtle micro-sparks per click
life: { min: 280, max: 920 } // frames before a particle respawns
};
// Rich site palette with cyan, indigo, violet, and soft luminous accents
this.palette = [
{ weight: 0.55, rgb: '206, 224, 255', alpha: 0.13 }, // Soft cyan-white
{ weight: 0.18, rgb: '79, 195, 247', alpha: 0.24 }, // Electric cyan
{ weight: 0.14, rgb: '102, 126, 234', alpha: 0.16 }, // Deep indigo
{ weight: 0.13, rgb: '146, 103, 197', alpha: 0.16 } // Soft violet
];
this.init();
}
init() {
this.bindEvents();
this.handleResize();
// 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, signal });
window.addEventListener('pointerdown', (e) => {
this.spawnClickBurst(e.clientX, e.clientY);
}, { 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 {
this.start();
}
}, { signal });
window.addEventListener('resize', () => {
if (this.resizeRaf) cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = requestAnimationFrame(() => {
this.resizeRaf = null;
this.handleResize();
});
}, { signal });
}
start() {
if (this.rafId) return;
this.animate();
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
}
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;
// Mobile browsers fire resize on scroll when the URL bar collapses;
// only react to real geometry changes to avoid canvas flicker.
if (this.width && w === this.width && Math.abs(h - this.height) < 150) return;
this.width = w;
this.height = h;
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
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);
// Density scales with area, clamped for both phones and ultrawides.
const count = Math.round(Math.min(Math.max((w * h) / 2600, 160), 650));
this.particles = [];
for (let i = 0; i < count; i++) {
this.particles.push(this.createParticle());
}
// 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;
}
return this.palette[0];
}
createParticle() {
const tone = this.pickTone();
const { min, max } = this.config.life;
// 3 Depth layers: 0 (background ambient), 1 (main stream), 2 (foreground highlight)
const randDepth = Math.random();
let depth = 1;
let speedMult = 1.0;
let widthMult = 1.0;
let alphaMult = 1.0;
if (randDepth < 0.25) {
depth = 0;
speedMult = 0.65;
widthMult = 0.6;
alphaMult = 0.65;
} else if (randDepth > 0.78) {
depth = 2;
speedMult = 1.35;
widthMult = 1.35;
alphaMult = 1.25;
}
return {
depth,
x: Math.random() * this.width,
y: Math.random() * this.height,
px: 0,
py: 0,
vx: 0,
vy: 0,
speed: this.config.baseSpeed * speedMult * (0.75 + Math.random() * 0.5),
width: (0.7 + Math.random() * 0.5) * widthMult,
rgb: tone.rgb,
baseAlpha: tone.alpha * (0.75 + Math.random() * 0.5) * alphaMult,
alpha: 0,
glow: 0,
life: Math.floor(Math.random() * (min * 0.5)),
maxLife: min + Math.random() * (max - min),
fresh: true // no previous point yet -> nothing to draw
};
}
respawn(p) {
p.x = Math.random() * this.width;
p.y = Math.random() * this.height;
p.vx = 0;
p.vy = 0;
p.life = 0;
p.fresh = true;
}
spawnClickBurst(x, y) {
// Emit a subtle, delicate burst of hair-thin micro-sparks on click
const count = this.config.sparkCount;
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();
this.sparks.push({
x,
y,
px: x,
py: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
rgb: tone.rgb,
size: 0.6 + Math.random() * 0.8,
alpha: 0.6 + Math.random() * 0.25,
life: 0,
maxLife: 25 + Math.random() * 25
});
}
}
updateParticles() {
const { fieldScale, warpStrength, winding, steer, maxSpeed } = this.config;
const t = this.time;
const m = this.mouse;
const radius = this.config.mouseRadius;
const radiusSq = radius * radius;
const fadeIn = 35;
const fadeOut = 55;
for (const p of this.particles) {
// Smooth life envelope (fade-in on spawn, fade-out near maxLife)
let lifeEnvelope = 1;
if (p.life < fadeIn) {
lifeEnvelope = p.life / fadeIn;
} else if (p.life > p.maxLife - fadeOut) {
lifeEnvelope = Math.max(0, (p.maxLife - p.life) / fadeOut);
}
p.alpha = p.baseAlpha * lifeEnvelope;
// Domain-warped flow angle: the warp layer bends the main field
// so the current curls organically instead of drifting straight.
const warp = this.noise.noise2D(
p.x * fieldScale * 0.4 + t * 0.5,
p.y * fieldScale * 0.4 - t * 0.3
);
const angle = this.noise.noise2D(
(p.x + warp * warpStrength) * fieldScale + t,
(p.y - warp * warpStrength) * fieldScale - t * 0.7
) * Math.PI * winding;
p.vx += (Math.cos(angle) * p.speed - p.vx) * steer;
p.vy += (Math.sin(angle) * p.speed - p.vy) * steer;
// Cursor vortex: tangential swirl + slight inward pull.
let proximity = 0;
if (m.active) {
const dx = p.x - m.x;
const dy = p.y - m.y;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq && distSq > 0.5) {
const dist = Math.sqrt(distSq);
proximity = 1 - dist / radius;
const falloff = proximity * proximity;
p.vx += (-dy / dist) * falloff * this.config.mouseSwirl
+ (-dx / dist) * falloff * this.config.mousePull;
p.vy += (dx / dist) * falloff * this.config.mouseSwirl
+ (-dy / dist) * falloff * this.config.mousePull;
}
}
// Smooth the glow so the highlight eases in and out.
p.glow += (proximity - p.glow) * 0.12;
const speedSq = p.vx * p.vx + p.vy * p.vy;
if (speedSq > maxSpeed * maxSpeed) {
const scale = maxSpeed / Math.sqrt(speedSq);
p.vx *= scale;
p.vy *= scale;
}
p.px = p.x;
p.py = p.y;
p.x += p.vx;
p.y += p.vy;
// Wrap at the edges; mark fresh so no cross-screen streak is drawn.
if (p.x < 0 || p.x > this.width || p.y < 0 || p.y > this.height) {
p.x = (p.x + this.width) % this.width;
p.y = (p.y + this.height) % this.height;
p.fresh = true;
}
if (++p.life > p.maxLife) {
this.respawn(p);
}
}
}
updateSparks() {
const drag = 0.91;
const { fieldScale } = this.config;
const t = this.time;
for (let i = this.sparks.length - 1; i >= 0; i--) {
const s = this.sparks[i];
s.px = s.x;
s.py = s.y;
// Gently blend spark velocity with noise field as it slows down
const angle = this.noise.noise2D(s.x * fieldScale + t, s.y * fieldScale - t) * Math.PI * 2;
s.vx = s.vx * drag + Math.cos(angle) * 0.15;
s.vy = s.vy * drag + Math.sin(angle) * 0.15;
s.x += s.vx;
s.y += s.vy;
s.life++;
if (s.life >= s.maxLife) {
this.sparks.splice(i, 1);
}
}
}
fade() {
// Erase a fraction of the previous frame towards transparency so
// the CSS backdrop behind the canvas keeps showing through.
this.ctx.globalCompositeOperation = 'destination-out';
this.ctx.fillStyle = `rgba(0, 0, 0, ${this.config.fadeAlpha})`;
this.ctx.fillRect(0, 0, this.width, this.height);
}
drawParticles() {
const ctx = this.ctx;
// Additive blending gives a soft glow where currents overlap.
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
for (const p of this.particles) {
if (p.fresh) {
p.fresh = false;
continue;
}
const alpha = Math.min(1, p.alpha + p.glow * this.config.glowBoost);
if (alpha <= 0.005) 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();
// Draw 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)})`;
ctx.beginPath();
ctx.arc(p.x, p.y, p.width * 0.75, 0, Math.PI * 2);
ctx.fill();
}
}
}
drawSparks() {
if (!this.sparks.length) return;
const ctx = this.ctx;
ctx.globalCompositeOperation = 'lighter';
for (const s of this.sparks) {
const lifeRatio = 1 - s.life / s.maxLife;
const alpha = (s.alpha * lifeRatio * lifeRatio).toFixed(3);
if (alpha <= 0) continue;
ctx.strokeStyle = `rgba(${s.rgb}, ${alpha})`;
ctx.lineWidth = s.size * lifeRatio;
ctx.beginPath();
ctx.moveTo(s.px, s.py);
ctx.lineTo(s.x, s.y);
ctx.stroke();
}
}
step(withFade) {
if (withFade) this.fade();
this.updateSparks();
this.updateParticles();
this.drawParticles();
this.drawSparks();
this.time += this.config.timeDrift;
}
warmup(steps) {
for (let i = 0; i < steps; i++) {
this.step(false);
}
}
animate() {
this.step(true);
this.rafId = requestAnimationFrame(() => this.animate());
}
}
document.addEventListener('DOMContentLoaded', () => {
// 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();
});