feat(ui): Arc bileşenleri, landing bölümleri ve bildirim yığını

Sonner/vaul yerine kendi bildirim yığını ve alt sayfa; çip grubu, kart
destesi, kaydırma eylemi, yazı dönüşümü, bölme tutamacı; landing'e program
duvarı, süzülen haplar ve veri akışı; yaylar tek dosyada (lib/hareket.ts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-07 17:12:53 +03:00
parent bc920f6111
commit 3c995402c6
38 changed files with 5474 additions and 697 deletions

View File

@@ -0,0 +1,245 @@
"use client";
// Program duvarı (DESIGN.md §4.29): "24 tercih, binlerce ihtimal" cümlesinin
// resmi. Küçük bir pencerede piksel kareler satır satır akar — her kare bir
// program; aralarında tam 24 turuncu kare yerinde durur: senin listen.
// İmleç yaklaştığı satırları hızlandırır. Veri taşıyan pikseldir (§1.2);
// anlamı altındaki cümle söyler, renk tek başına değil (§2.2).
//
// Esin: React Bits "Squares Terminal" (yanıp sönen kare ızgarası, imlece
// tepki veren satırlar). Kod bize ait: koyu CRT ekranı, parlama ve tüp eğimi
// alınmadı; açık zemin, hero'nun ızgara ölçüleri (hücre 10, nokta 7.4, köşe 2)
// ve doku opaklıkları kullanılır. Tek <canvas>, yalnız ekrandayken ~30 fps;
// hareketi azalt'ta durağan tek kare.
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
const HUCRE = 10;
const NOKTA = 7.4;
const BOSLUK = (HUCRE - NOKTA) / 2;
// Hero ile aynı doku paleti
const OPAKLIKLAR = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const TERCIH = 24;
/** İmlecin satırları etkilediği dikey uzaklık (px) ve en çok ek hız (hücre/sn). */
const ETKI = 46;
const EK_HIZ = 9;
// Deterministik sözde-rastgele: sunucuda yok ama her ziyarette aynı desen
const karma = (x: number, y: number) => {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return n - Math.floor(n);
};
export function ProgramDuvari({
etiket,
className,
}: {
/** Pencere çubuğundaki kısa yazı, ör. "21.000+ program". */
etiket: string;
className?: string;
}) {
const kapRef = useRef<HTMLDivElement>(null);
const tuvalRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const kap = kapRef.current;
const tuval = tuvalRef.current;
const ctx = tuval?.getContext("2d");
if (!kap || !tuval || !ctx) return;
const azalt = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let en = 0;
let boy = 0;
let sutun = 0;
let satir = 0;
let mavi = "";
let turuncu = "";
/** Satır başına kayma (hücre) ve imleç kaynaklı ek hız. */
let kayma: number[] = [];
let ekHiz: number[] = [];
let tercihler: { c: number; r: number }[] = [];
let imlecY: number | null = null;
let gorunur = false;
let kare = 0;
let onceki = 0;
let basladi = 0;
const renkleriOku = () => {
// Tema değişince palet token'ları değişir (§2.3); renk CSS'ten okunur
mavi = getComputedStyle(tuval).color;
turuncu =
getComputedStyle(document.documentElement).getPropertyValue("--color-orange-500").trim() ||
"#f97316";
};
const olc = () => {
const kutu = kap.getBoundingClientRect();
const oran = Math.min(window.devicePixelRatio || 1, 2);
en = Math.round(kutu.width);
boy = Math.round(kutu.height);
tuval.width = en * oran;
tuval.height = boy * oran;
ctx.setTransform(oran, 0, 0, oran, 0, 0);
sutun = Math.ceil(en / HUCRE) + 1;
satir = Math.floor(boy / HUCRE);
kayma = Array.from({ length: satir }, (_, r) => kayma[r] ?? karma(r, 3) * 40);
ekHiz = Array.from({ length: satir }, () => 0);
// 24 tercih: kenarlardan bir hücre içeride, birbirine değmeyen sabit kareler
tercihler = [];
const dolu = new Set<string>();
for (let i = 0; tercihler.length < TERCIH && i < 4000; i++) {
const c = 1 + Math.floor(karma(i, 17) * Math.max(1, sutun - 3));
const r = 1 + Math.floor(karma(i, 29) * Math.max(1, satir - 2));
const yakin = [-1, 0, 1].some((dx) =>
[-1, 0, 1].some((dy) => dolu.has(`${c + dx}:${r + dy}`)),
);
if (yakin) continue;
dolu.add(`${c}:${r}`);
tercihler.push({ c, r });
}
};
const kareCiz = (x: number, y: number) => {
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(x, y, NOKTA, NOKTA, 2);
else ctx.rect(x, y, NOKTA, NOKTA);
ctx.fill();
};
const ciz = (t: number) => {
ctx.clearRect(0, 0, en, boy);
const ustBosluk = (boy - satir * HUCRE) / 2;
ctx.fillStyle = mavi;
for (let r = 0; r < satir; r++) {
const tam = Math.floor(kayma[r]);
const kesir = kayma[r] - tam;
// Tek satırlar sola, çift satırlar sağa akar
const yon = r % 2 === 0 ? 1 : -1;
for (let c = -1; c < sutun; c++) {
const kimlik = c - yon * tam;
const h = karma(kimlik, r);
// Karelerin üçte biri boş: duvar değil doku
if (h < 0.34) continue;
const taban = OPAKLIKLAR[Math.abs(kimlik * 7 + r * 11) % OPAKLIKLAR.length];
// Yavaş nefes: her kare kendi fazında hafifçe parlar ve söner
const nefes = 0.6 + 0.4 * Math.sin(t * 0.9 + h * 6.283);
ctx.globalAlpha = taban * nefes;
kareCiz((c + yon * kesir) * HUCRE + BOSLUK, ustBosluk + r * HUCRE + BOSLUK);
}
}
// Tercihler: sırayla belirir (110 ms arayla), sonra yerinde kalır
ctx.fillStyle = turuncu;
const gecen = azalt ? Infinity : t - basladi;
tercihler.forEach(({ c, r }, i) => {
const yas = gecen - 0.4 - i * 0.11;
if (yas <= 0) return;
const giris = Math.min(1, yas / 0.3);
const x = c * HUCRE + BOSLUK;
const y = ustBosluk + r * HUCRE + BOSLUK;
// Altındaki akan kareyi sil: turuncu kare tek başına okunur
ctx.globalAlpha = 1;
ctx.clearRect(c * HUCRE, ustBosluk + r * HUCRE, HUCRE, HUCRE);
ctx.globalAlpha = 0.9 * giris;
kareCiz(x, y);
});
ctx.globalAlpha = 1;
};
const adim = (simdi: number) => {
kare = requestAnimationFrame(adim);
// ~30 fps yeter: kareler yavaş akar
if (simdi - onceki < 32) return;
const dt = Math.min(0.1, (simdi - onceki) / 1000);
onceki = simdi;
const ustBosluk = (boy - satir * HUCRE) / 2;
for (let r = 0; r < satir; r++) {
const merkez = ustBosluk + r * HUCRE + HUCRE / 2;
const yakinlik =
imlecY == null ? 0 : Math.max(0, 1 - Math.abs(merkez - imlecY) / ETKI);
// Ek hız hedefe yumuşak yaklaşır; imleç çekilince yavaşça söner
ekHiz[r] += (yakinlik * yakinlik * EK_HIZ - ekHiz[r]) * Math.min(1, dt * 5);
kayma[r] += (0.5 + karma(r, 7) * 1.3 + ekHiz[r]) * dt;
}
ciz(simdi / 1000);
};
const baslat = () => {
if (kare || azalt || !gorunur) return;
onceki = performance.now();
if (!basladi) basladi = onceki / 1000;
kare = requestAnimationFrame(adim);
};
const durdur = () => {
cancelAnimationFrame(kare);
kare = 0;
};
renkleriOku();
olc();
ciz(0);
const boyut = new ResizeObserver(() => {
olc();
ciz(performance.now() / 1000);
});
boyut.observe(kap);
const gorunum = new IntersectionObserver(([kayit]) => {
gorunur = kayit.isIntersecting;
if (gorunur) baslat();
else durdur();
});
gorunum.observe(kap);
const tema = new MutationObserver(() => {
renkleriOku();
ciz(performance.now() / 1000);
});
tema.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const imlec = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const kutu = kap.getBoundingClientRect();
const icinde =
e.clientX >= kutu.left &&
e.clientX <= kutu.right &&
e.clientY >= kutu.top - ETKI &&
e.clientY <= kutu.bottom + ETKI;
imlecY = icinde ? e.clientY - kutu.top : null;
};
window.addEventListener("pointermove", imlec, { passive: true });
return () => {
durdur();
boyut.disconnect();
gorunum.disconnect();
tema.disconnect();
window.removeEventListener("pointermove", imlec);
};
}, []);
return (
<figure className={cn("mx-auto w-full max-w-3xl", className)}>
{/* Mini pencere: aramadaki tarayıcı penceresiyle aynı dil (§4.20) */}
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-card shadow-sm">
<div className="flex h-9 items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-3.5">
<span className="size-2 rounded-full bg-slate-300" aria-hidden />
<span className="size-2 rounded-full bg-slate-300 opacity-65" aria-hidden />
<span className="size-2 rounded-full bg-slate-300 opacity-35" aria-hidden />
<span className="ml-2 text-xs font-medium text-slate-500">{etiket}</span>
</div>
<div ref={kapRef} className="h-36 sm:h-44">
<canvas
ref={tuvalRef}
role="img"
aria-label="Akan piksel kareler: her kare bir program. Aralarında 24 turuncu kare senin tercih listen."
className="block size-full text-primary"
/>
</div>
</div>
<figcaption className="mt-3 flex items-center justify-center gap-2 text-center text-sm text-slate-500">
<span className="size-2.5 shrink-0 rounded-[3px] bg-orange-500" aria-hidden />
Her kare bir program; turuncular senin 24 tercihin.
</figcaption>
</figure>
);
}