diff --git a/assets/styles.css b/assets/styles.css
index 1b2d190..6ff1205 100644
--- a/assets/styles.css
+++ b/assets/styles.css
@@ -27,6 +27,7 @@ body {
padding-bottom: 0;
margin-top: 0px;
position: relative;
+ z-index: 1;
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
@@ -79,6 +80,8 @@ body {
align-items: baseline;
justify-content: space-between;
max-width: 550px;
+ position: relative;
+ z-index: 1;
}
.index-header .now {
@@ -393,6 +396,226 @@ body {
opacity: 1;
}
+/* === VINE ASCII ANIMATION === */
+#vine-container {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: calc(100vw - 640px);
+ height: 100vh;
+ z-index: 0;
+ overflow: hidden;
+}
+
+#vine-container:hover {
+ cursor: crosshair;
+}
+
+.vine-char {
+ cursor: pointer;
+}
+
+.vine-char:hover {
+ color: #111 !important;
+}
+
+.vine-flower {
+ cursor: grab;
+}
+
+.vine-flower:active {
+ cursor: grabbing;
+}
+
+.vine-flower:hover {
+ filter: brightness(1.4);
+}
+
+@media (max-width: 1000px) {
+ #vine-container {
+ display: none;
+ }
+}
+
+@keyframes flowerFall {
+ 0% {
+ transform: translateY(0) rotate(0deg);
+ opacity: 0.95;
+ }
+ 100% {
+ transform: translateY(100px) rotate(60deg);
+ opacity: 0;
+ }
+}
+
+@keyframes leafFall {
+ 0% {
+ transform: translateY(0) rotate(0deg) scale(1);
+ opacity: 0.6;
+ }
+ 100% {
+ transform: translateY(80px) rotate(90deg) scale(0.6);
+ opacity: 0;
+ }
+}
+
+@keyframes flowerPulse {
+ 0%, 100% {
+ transform: scale(1);
+ opacity: 0.9;
+ }
+ 50% {
+ transform: scale(1.15);
+ opacity: 1;
+ }
+}
+
+.falling-flower {
+ animation: flowerFall 2.5s ease-in forwards;
+}
+
+.falling-leaf {
+ animation: leafFall 2s ease-in forwards;
+}
+
+.vine-flower {
+ animation: flowerPulse 2.5s ease-in-out infinite;
+}
+
+#vine-help-btn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 22px;
+ height: 22px;
+ border: 1px solid #111;
+ background: transparent;
+ font-family: 'IM Fell English', serif;
+ font-size: 14px;
+ line-height: 1;
+ color: #111;
+ cursor: pointer;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ transition: background 0.2s, color 0.2s;
+}
+
+#vine-help-btn:hover {
+ background: #111;
+ color: #fff;
+}
+
+#vine-flower-counter {
+ position: absolute;
+ top: 36px;
+ right: 10px;
+ font-family: 'IM Fell English', serif;
+ font-size: 13px;
+ color: hsl(180, 60%, 70%);
+ text-align: center;
+ width: 22px;
+ line-height: 1;
+ z-index: 10;
+ pointer-events: none;
+}
+
+#vine-help-panel {
+ position: absolute;
+ top: 40px;
+ right: 10px;
+ width: 260px;
+ background: #fff;
+ border: 1px solid #111;
+ padding: 0.8rem 1rem;
+ z-index: 10;
+ font-family: 'IM Fell English', serif;
+ font-size: 14px;
+ line-height: 1.5;
+ color: #111;
+ cursor: default;
+}
+
+#vine-help-panel .close {
+ position: absolute;
+ top: 2px;
+ right: 6px;
+ font-size: 18px;
+ cursor: pointer;
+ line-height: 1;
+ padding: 0;
+ background: none;
+ border: none;
+ color: #111;
+ font-family: 'IM Fell English', serif;
+}
+
+#vine-help-panel .close:hover {
+ opacity: 0.6;
+}
+
+#vine-help-panel h3 {
+ margin: 0 0 0.6rem 0;
+ font-size: 15px;
+ font-weight: normal;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 0.3rem;
+}
+
+#vine-help-panel ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+#vine-help-panel li {
+ margin-bottom: 0.4rem;
+ padding-left: 0.8rem;
+ position: relative;
+}
+
+#vine-help-panel li::before {
+ content: '—';
+ position: absolute;
+ left: 0;
+ opacity: 0.4;
+}
+
+#vine-help-panel li kbd {
+ font-family: 'Courier New', monospace;
+ font-size: 11px;
+ border: 1px solid #ddd;
+ padding: 0 3px;
+ background: #f9f9f9;
+ margin-right: 2px;
+}
+
+/* === SWIRL (page d'accueil) === */
+#swirl-container {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 100vw;
+ height: 100vh;
+ pointer-events: none;
+ z-index: 0;
+ overflow: hidden;
+}
+
+.swirl-char {
+ display: inline-block;
+ will-change: transform, opacity;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ #vine-container,
+ #swirl-container {
+ display: none;
+ }
+}
+
/* === DETAILS / SUMMARY === */
summary {
cursor: pointer;
diff --git a/assets/swirl.js b/assets/swirl.js
new file mode 100644
index 0000000..95d1e31
--- /dev/null
+++ b/assets/swirl.js
@@ -0,0 +1,124 @@
+(function () {
+ const CHARS = ['~', '-', '_', '·', '°', '*', 'o', '+', '×', '=', '≈', '∿', '◦'];
+
+ function pick(arr) {
+ return arr[Math.floor(Math.random() * arr.length)];
+ }
+
+ class Swirl {
+ constructor(container) {
+ this.container = container;
+ this.cx = container.offsetWidth / 2 || window.innerWidth / 2;
+ this.cy = container.offsetHeight / 2 || window.innerHeight / 2;
+ this.elements = [];
+ this.frameCount = 0;
+ this.angle = 0;
+ this.radius = 2;
+ this.hue = Math.random() * 360;
+ this.hueSpeed = 0.25;
+ this.angleStep = 0.18;
+ this.radiusStep = 0.55;
+ this.globalRotation = 0;
+
+ this.loop = this.loop.bind(this);
+ requestAnimationFrame(this.loop);
+
+ window.addEventListener('resize', () => {
+ this.cx = container.offsetWidth / 2 || window.innerWidth / 2;
+ this.cy = container.offsetHeight / 2 || window.innerHeight / 2;
+ });
+ }
+
+ createChar(x, y, hue, scale) {
+ const color = `hsl(${hue}, 60%, 68%)`;
+ const el = document.createElement('span');
+ el.textContent = pick(CHARS);
+ el.classList.add('swirl-char');
+ el.style.cssText =
+ `position:absolute;` +
+ `left:${x}px;top:${y}px;` +
+ `font-family:"Courier New",Courier,monospace;` +
+ `font-size:15px;line-height:1;` +
+ `color:${color};` +
+ `pointer-events:none;user-select:none;` +
+ `opacity:0.85;`;
+ el.style.transform = `scale(${scale}) rotate(${Math.random() * 360}deg)`;
+ el.style.display = 'inline-block';
+
+ this.container.appendChild(el);
+ this.elements.push({
+ el,
+ born: this.frameCount,
+ maxAge: 900 + Math.floor(Math.random() * 400),
+ });
+ }
+
+ ageElements() {
+ if (this.frameCount % 12 !== 0) return;
+ const now = this.frameCount;
+ let pruneCount = 0;
+ const maxPrune = 12;
+ for (let i = 0; i < this.elements.length && pruneCount < maxPrune; i++) {
+ const item = this.elements[i];
+ const age = now - item.born;
+ if (age >= item.maxAge) {
+ item.el.remove();
+ this.elements.splice(i, 1);
+ i--;
+ pruneCount++;
+ } else if (age > item.maxAge - 150) {
+ const fade = 1 - (age - (item.maxAge - 150)) / 150;
+ item.el.style.opacity = Math.max(0, fade * 0.85);
+ }
+ }
+ }
+
+ update() {
+ this.frameCount++;
+
+ // Rotation globale du tourbillon
+ this.globalRotation += 0.002;
+
+ // Évolution de la couleur
+ this.hue = (this.hue + this.hueSpeed) % 360;
+
+ // Ajouter un nouveau caractère à la spirale
+ if (this.frameCount % 2 === 0) {
+ this.angle += this.angleStep;
+ // L'espacement augmente avec le rayon (spirale qui s'écarte)
+ const dynamicStep = this.radiusStep + (this.radius / 100) * 0.18;
+ this.radius += dynamicStep;
+
+ const effectiveAngle = this.angle + this.globalRotation;
+ const x = this.cx + Math.cos(effectiveAngle) * this.radius;
+ const y = this.cy + Math.sin(effectiveAngle) * this.radius * 0.6;
+
+ // Scale qui grandit avec le rayon
+ const scale = 0.6 + (this.radius / 600) * 0.6;
+
+ this.createChar(x, y, this.hue, scale);
+ }
+
+ // Quand le rayon dépasse l'écran, on recommence plus petit
+ const maxRadius = Math.max(this.cx, this.cy) + 80;
+ if (this.radius > maxRadius) {
+ this.radius = 8 + Math.random() * 12;
+ this.hue = (this.hue + 60 + Math.random() * 120) % 360;
+ }
+
+ this.ageElements();
+ }
+
+ loop() {
+ this.update();
+ requestAnimationFrame(this.loop);
+ }
+ }
+
+ document.addEventListener('DOMContentLoaded', () => {
+ const container = document.getElementById('swirl-container');
+ if (container) {
+ new Swirl(container);
+ }
+ });
+})();
diff --git a/assets/vine.js b/assets/vine.js
new file mode 100644
index 0000000..6c926f7
--- /dev/null
+++ b/assets/vine.js
@@ -0,0 +1,539 @@
+(function () {
+ const CHARS = {
+ down: ['|', '¦', '¡', 'l', 'I', '1'],
+ downRight: ['\\', '╲'],
+ downLeft: ['/', '╱'],
+ leaf: ['.', 'o', '*', "'", '"', '·', '°', '`', '¨', '+'],
+ flower: ['✿', '❀', '✾', '❁', '⚘'],
+ debris: ['°', '·', '¨', '`', ','],
+ };
+
+ function pick(arr) {
+ return arr[Math.floor(Math.random() * arr.length)];
+ }
+
+ function charForAngle(angle) {
+ const deg = angle * 180 / Math.PI;
+ if (deg >= 60 && deg <= 120) return pick(CHARS.down);
+ if (deg < 60) return pick(CHARS.downRight);
+ return pick(CHARS.downLeft);
+ }
+
+ class Vine {
+ constructor(container) {
+ this.container = container;
+ this.width = container.offsetWidth || window.innerWidth;
+ this.height = container.offsetHeight || window.innerHeight;
+ this.tips = [];
+ this.elements = [];
+ this.flowers = [];
+ this.frameCount = 0;
+ this.minStep = 24;
+ this.flowerCount = 0;
+
+ this.startVine();
+ this.startVine();
+ this.startVine();
+ this.startVine();
+
+ this.createHelpUI();
+
+ this.loop = this.loop.bind(this);
+ requestAnimationFrame(this.loop);
+
+ window.addEventListener('resize', () => {
+ this.width = container.offsetWidth || window.innerWidth;
+ this.height = container.offsetHeight || window.innerHeight;
+ });
+
+ // Interactions
+ this.container.addEventListener('click', (e) => this.handleClick(e));
+ this.container.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
+ this.container.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ this.handleRightClick(e);
+ });
+ }
+
+ startVine() {
+ if (this.tips.length >= 12) return;
+ this.tips.push({
+ x: 40 + Math.random() * Math.max(0, this.width - 80),
+ y: -5,
+ angle: Math.PI / 2 + (Math.random() - 0.5) * 0.25,
+ hue: Math.random() * 360,
+ hueSpeed: (Math.random() - 0.5) * 0.3,
+ generation: 0,
+ age: 0,
+ alive: true,
+ nextBranch: 140 + Math.random() * 140,
+ speed: 0.7 + Math.random() * 0.3,
+ distSinceLastChar: 0,
+ });
+ }
+
+ createHelpUI() {
+ const btn = document.createElement('button');
+ btn.id = 'vine-help-btn';
+ btn.textContent = '?';
+ this.container.appendChild(btn);
+
+ const panel = document.createElement('div');
+ panel.id = 'vine-help-panel';
+ panel.style.display = 'none';
+
+ const close = document.createElement('button');
+ close.className = 'close';
+ close.innerHTML = '×';
+ close.setAttribute('aria-label', 'fermer');
+ panel.appendChild(close);
+
+ const title = document.createElement('h3');
+ title.innerHTML = this.animText('jardinage');
+ panel.appendChild(title);
+
+ const items = [
+ { k: 'clic', a: 'sur tige', d: 'coupe la liane' },
+ { k: 'double-clic', a: 'sur tige', d: 'plante une fleur' },
+ { k: 'clic droit', a: 'sur tige', d: 'plante une fleur' },
+ { k: 'clic', a: 'sur fleur', d: "l'attrape" },
+ { k: 'clic', a: 'sur fond', d: 'dévie la racine' },
+ ];
+
+ const ul = document.createElement('ul');
+ items.forEach((it) => {
+ const li = document.createElement('li');
+ li.innerHTML =
+ '' + it.k + ' ' +
+ it.a + ' → ' + it.d;
+ ul.appendChild(li);
+ });
+ panel.appendChild(ul);
+
+ this.container.appendChild(panel);
+
+ const counter = document.createElement('div');
+ counter.id = 'vine-flower-counter';
+ counter.textContent = '0';
+ this.counterEl = counter;
+ this.container.appendChild(counter);
+
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+ });
+
+ close.addEventListener('click', (e) => {
+ e.stopPropagation();
+ panel.style.display = 'none';
+ });
+
+ panel.addEventListener('click', (e) => e.stopPropagation());
+ }
+
+ animText(text) {
+ return text.split('').map((c, i) => {
+ if (c === ' ') return ' ';
+ const delayColor = (i * 0.3).toFixed(1);
+ const delayLevite = (Math.random() * 4).toFixed(2);
+ const duree = (3 + Math.random() * 3).toFixed(2);
+ return `${c}`;
+ }).join('');
+ }
+
+ // ===== INTERACTIONS =====
+
+ handleClick(e) {
+ const target = e.target;
+
+ // 1. Clic sur une fleur vivante -> attraper
+ if (target.classList.contains('vine-flower') && !target.classList.contains('falling-flower')) {
+ this.catchFlower(target);
+ return;
+ }
+
+ // 2. Clic sur caractère de tige (pas feuille) -> couper
+ if (target.classList.contains('vine-char') && !target.classList.contains('vine-leaf')) {
+ this.cutVine(parseFloat(target.style.left), parseFloat(target.style.top));
+ return;
+ }
+
+ // 3. Clic sur fond vide -> dévier la racine la plus proche
+ if (target === this.container) {
+ this.redirectVine(e.offsetX, e.offsetY);
+ }
+ }
+
+ handleDoubleClick(e) {
+ const target = e.target;
+ // Double-clic sur caractère de tige -> planter une fleur
+ if (target.classList.contains('vine-char')) {
+ const x = parseFloat(target.style.left);
+ const y = parseFloat(target.style.top);
+ this.plantFlower(x, y);
+ }
+ }
+
+ handleRightClick(e) {
+ const target = e.target;
+ // Clic droit sur caractère de tige -> planter une fleur
+ if (target.classList.contains('vine-char')) {
+ const x = parseFloat(target.style.left);
+ const y = parseFloat(target.style.top);
+ this.plantFlower(x, y);
+ }
+ }
+
+ findClosestTip(x, y, onlyRoots = false) {
+ let closest = null;
+ let closestDist = Infinity;
+ for (const tip of this.tips) {
+ if (onlyRoots && tip.generation > 0) continue;
+ const dx = tip.x - x;
+ const dy = tip.y - y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ if (dist < closestDist) {
+ closestDist = dist;
+ closest = tip;
+ }
+ }
+ return closest;
+ }
+
+ cutVine(clickX, clickY) {
+ const tip = this.findClosestTip(clickX, clickY);
+ if (!tip) return;
+
+ // Effet de débris visuels
+ for (let i = 0; i < 4; i++) {
+ this.createDebris(clickX + (Math.random() - 0.5) * 20, clickY + (Math.random() - 0.5) * 20, tip.hue);
+ }
+
+ // Créer deux nouvelles lianes qui divergent
+ const hue = tip.hue;
+ const hueSpeed = tip.hueSpeed;
+
+ this.tips.push({
+ x: clickX,
+ y: clickY,
+ angle: Math.PI / 2 - 0.3 - Math.random() * 0.4,
+ hue,
+ hueSpeed: hueSpeed * (0.8 + Math.random() * 0.4),
+ generation: 0,
+ age: 0,
+ alive: true,
+ nextBranch: 60 + Math.random() * 80,
+ speed: 0.9 + Math.random() * 0.4,
+ distSinceLastChar: 0,
+ });
+
+ this.tips.push({
+ x: clickX,
+ y: clickY,
+ angle: Math.PI / 2 + 0.3 + Math.random() * 0.4,
+ hue,
+ hueSpeed: hueSpeed * (0.8 + Math.random() * 0.4),
+ generation: 0,
+ age: 0,
+ alive: true,
+ nextBranch: 60 + Math.random() * 80,
+ speed: 0.9 + Math.random() * 0.4,
+ distSinceLastChar: 0,
+ });
+
+ // Tuer l'ancienne liane
+ tip.alive = false;
+ }
+
+ plantFlower(x, y) {
+ const tip = this.findClosestTip(x, y);
+ const hue = tip ? tip.hue : Math.random() * 360;
+ this.createFlower(x, y, hue);
+ }
+
+ catchFlower(el) {
+ const idx = this.flowers.findIndex((f) => f.el === el);
+ if (idx === -1) return;
+ const flower = this.flowers[idx];
+ if (flower.dying) return;
+
+ flower.dying = true;
+ flower.el.classList.remove('vine-flower');
+ flower.el.classList.add('falling-flower');
+ flower.el.style.animationDuration = '0.6s';
+ flower.el.style.animationTimingFunction = 'ease-out';
+
+ setTimeout(() => {
+ if (flower.el.parentNode) flower.el.remove();
+ }, 700);
+ this.flowers.splice(idx, 1);
+
+ this.flowerCount++;
+ if (this.counterEl) {
+ this.counterEl.textContent = this.flowerCount;
+ }
+ }
+
+ redirectVine(clickX, clickY) {
+ const root = this.findClosestTip(clickX, clickY, true);
+ if (!root) return;
+
+ const dx = clickX - root.x;
+ const dy = clickY - root.y;
+ let targetAngle = Math.atan2(Math.abs(dy), dx);
+ targetAngle = Math.max(0.3, Math.min(Math.PI - 0.3, targetAngle));
+
+ // Transition douce : on s'approche progressivement de l'angle cible
+ const diff = targetAngle - root.angle;
+ root.angle += diff * 0.4;
+ root.speed = Math.min(root.speed * 1.3, 2.5);
+ }
+
+ createDebris(x, y, hue) {
+ const color = this.getColor(hue, y, false);
+ const el = document.createElement('span');
+ el.textContent = pick(CHARS.debris);
+ el.style.cssText =
+ `position:absolute;left:${x}px;top:${y}px;` +
+ `font-family:"Courier New",Courier,monospace;` +
+ `font-size:12px;line-height:1;color:${color};` +
+ `pointer-events:none;user-select:none;` +
+ `opacity:0.8;`;
+ el.style.display = 'inline-block';
+ this.container.appendChild(el);
+ this.elements.push({
+ el,
+ born: this.frameCount,
+ maxAge: 60 + Math.floor(Math.random() * 40),
+ });
+ }
+
+ // ===== LOGIQUE EXISTANTE =====
+
+ getColor(hue, y, isFlower) {
+ const drifted = (hue + y * 0.04 + Math.random() * 6) % 360;
+ const sat = isFlower ? 68 : 58;
+ const lit = isFlower ? 74 : 68;
+ return `hsl(${drifted}, ${sat}%, ${lit}%)`;
+ }
+
+ createChar(x, y, angle, hue, isLeaf) {
+ const color = this.getColor(hue, y, false);
+ const el = document.createElement('span');
+ el.textContent = isLeaf ? pick(CHARS.leaf) : charForAngle(angle);
+ el.classList.add('vine-char');
+ if (isLeaf) el.classList.add('vine-leaf');
+ el.style.cssText =
+ `position:absolute;left:${x}px;top:${y}px;` +
+ `font-family:"Courier New",Courier,monospace;` +
+ `font-size:17px;line-height:1;color:${color};` +
+ `text-shadow:0 0 3px currentColor;` +
+ `pointer-events:auto;user-select:none;` +
+ `opacity:${isLeaf ? 0.55 : 0.85};`;
+
+ const scale = 0.9 + Math.random() * 0.12;
+ const rot = (Math.random() - 0.5) * 5;
+ el.style.transform = `scale(${scale}) rotate(${rot}deg)`;
+ el.style.display = 'inline-block';
+
+ this.container.appendChild(el);
+ const lifeRatio = Math.min(1, y / Math.max(1, this.height));
+ const maxAge = Math.floor(1800 - lifeRatio * 1000 + Math.random() * 300);
+ this.elements.push({
+ el,
+ born: this.frameCount,
+ maxAge,
+ });
+ }
+
+ createFlower(x, y, hue) {
+ if (this.flowers.length >= 8) return;
+ const color = this.getColor(hue, y, true);
+ const el = document.createElement('span');
+ el.textContent = pick(CHARS.flower);
+ el.classList.add('vine-flower');
+ el.style.cssText =
+ `position:absolute;left:${x}px;top:${y}px;` +
+ `font-family:"Courier New",Courier,monospace;` +
+ `font-size:18px;line-height:1;color:${color};` +
+ `text-shadow:0 0 5px currentColor;` +
+ `pointer-events:auto;user-select:none;` +
+ `opacity:0.9;display:inline-block;`;
+
+ this.container.appendChild(el);
+ this.flowers.push({
+ el,
+ x, y,
+ life: 30 + Math.random() * 40,
+ dying: false,
+ });
+ }
+
+ updateFlowers() {
+ for (let i = this.flowers.length - 1; i >= 0; i--) {
+ const f = this.flowers[i];
+ if (f.dying) continue;
+ f.life -= 1;
+ if (f.life <= 0) {
+ f.dying = true;
+ f.el.classList.remove('vine-flower');
+ f.el.classList.add('falling-flower');
+ setTimeout(() => {
+ if (f.el.parentNode) f.el.remove();
+ }, 2600);
+ this.flowers.splice(i, 1);
+ }
+ }
+ }
+
+ ageElements() {
+ if (this.frameCount % 10 !== 0) return;
+ const now = this.frameCount;
+ let pruneCount = 0;
+ const maxPrune = 18;
+ for (let i = 0; i < this.elements.length && pruneCount < maxPrune; i++) {
+ const item = this.elements[i];
+ const age = now - item.born;
+ if (age >= item.maxAge) {
+ if (item.el.classList.contains('vine-leaf') && !item.falling) {
+ item.falling = true;
+ item.el.classList.add('falling-leaf');
+ setTimeout(() => {
+ if (item.el.parentNode) item.el.remove();
+ }, 2100);
+ } else {
+ item.el.remove();
+ }
+ this.elements.splice(i, 1);
+ i--;
+ pruneCount++;
+ } else if (age > item.maxAge - 120 && !item.falling) {
+ const fade = 1 - (age - (item.maxAge - 120)) / 120;
+ item.el.style.opacity = Math.max(0, fade * 0.85);
+ }
+ }
+ }
+
+ update() {
+ this.frameCount++;
+ if (this.frameCount % 3 !== 0) return;
+
+ this.updateFlowers();
+ this.ageElements();
+
+ const newTips = [];
+
+ for (let i = 0; i < this.tips.length; i++) {
+ const tip = this.tips[i];
+ tip.age++;
+
+ tip.hue = (tip.hue + tip.hueSpeed) % 360;
+ if (tip.hue < 0) tip.hue += 360;
+
+ tip.angle += (Math.random() - 0.5) * 0.025;
+ if (tip.angle < 0.3) tip.angle = 0.3;
+ if (tip.angle > Math.PI - 0.3) tip.angle = Math.PI - 0.3;
+
+ const dx = Math.cos(tip.angle);
+ const dy = Math.sin(tip.angle);
+
+ const step = tip.speed * 1.6;
+ tip.x += dx * step;
+ tip.y += dy * step;
+ tip.distSinceLastChar += step;
+
+ if (tip.distSinceLastChar >= this.minStep) {
+ tip.distSinceLastChar = 0;
+ this.createChar(tip.x, tip.y, tip.angle, tip.hue, false);
+ }
+
+ if (tip.generation > 0 && Math.random() < 0.015) {
+ const lx = tip.x + (Math.random() - 0.5) * 14;
+ const ly = tip.y + (Math.random() - 0.5) * 14;
+ this.createChar(lx, ly, tip.angle, tip.hue, true);
+ }
+
+ if (
+ tip.age >= tip.nextBranch &&
+ tip.generation < 2
+ ) {
+ tip.nextBranch = tip.age + 140 + Math.random() * 160;
+ const side = Math.random() > 0.5 ? 1 : -1;
+ const spread = 0.35 + Math.random() * 0.55;
+ newTips.push({
+ x: tip.x,
+ y: tip.y,
+ angle: tip.angle + side * spread,
+ hue: tip.hue,
+ hueSpeed: tip.hueSpeed,
+ generation: tip.generation + 1,
+ age: 0,
+ alive: true,
+ nextBranch: 120 + Math.random() * 140,
+ speed: tip.speed * (0.75 + Math.random() * 0.15),
+ distSinceLastChar: 0,
+ });
+
+ this.createFlower(
+ tip.x + (Math.random() - 0.5) * 8,
+ tip.y + (Math.random() - 0.5) * 8,
+ tip.hue
+ );
+ }
+
+ if (tip.generation >= 1 && Math.random() < 0.008) {
+ this.createFlower(
+ tip.x + (Math.random() - 0.5) * 6,
+ tip.y + (Math.random() - 0.5) * 6,
+ tip.hue
+ );
+ }
+
+ // Branches : meurent quand sortent
+ if (tip.generation > 0) {
+ if (
+ tip.y > this.height + 15 ||
+ tip.x < -20 ||
+ tip.x > this.width + 20
+ ) {
+ tip.alive = false;
+ }
+ }
+ // Racines : respawn immédiat quand touchent le bas
+ else if (tip.y > this.height + 15) {
+ tip.y = -5;
+ tip.x = 40 + Math.random() * Math.max(0, this.width - 80);
+ tip.angle = Math.PI / 2 + (Math.random() - 0.5) * 0.25;
+ tip.hue = Math.random() * 360;
+ tip.hueSpeed = (Math.random() - 0.5) * 0.3;
+ tip.age = 0;
+ tip.nextBranch = 140 + Math.random() * 140;
+ tip.distSinceLastChar = 0;
+ // Chance de faire apparaître une nouvelle racine (max 6 racines)
+ if (this.tips.filter((t) => t.generation === 0).length < 6 && Math.random() < 0.35) {
+ this.startVine();
+ }
+ }
+ }
+
+ this.tips = this.tips.filter((t) => t.alive).concat(newTips);
+
+ // Respawn de secours si jamais il y a trop peu de tips
+ if (this.tips.filter((t) => t.generation === 0).length < 3 && Math.random() < 0.5) {
+ this.startVine();
+ }
+ }
+
+ loop() {
+ this.update();
+ requestAnimationFrame(this.loop);
+ }
+ }
+
+ document.addEventListener('DOMContentLoaded', () => {
+ const container = document.getElementById('vine-container');
+ if (container) {
+ new Vine(container);
+ }
+ });
+})();
diff --git a/blog/entries/march2026/bonjour.html b/blog/entries/march2026/bonjour.html
index 62ab0b1..8ab0fa3 100644
--- a/blog/entries/march2026/bonjour.html
+++ b/blog/entries/march2026/bonjour.html
@@ -40,6 +40,8 @@
+