feat(tema): tema geçişi logodan piksel piksel yayılır

Sitenin piksel imzasının hareketli hâli: yeni tema header'daki logodan
başlar, kare ızgarası hâlinde dağınık bir ön yüzle ekrana yayılır; her kare
sırası gelince yumuşakça belirir (900 ms, --kt-tema-sure). View Transitions
+ küçük tuvalden üretilen, önbelleklenen maske kareleri; pixelated ile keskin.
Reduced-motion ve desteksiz tarayıcıda düz geçiş. DESIGN §2.3 güncel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-09 00:22:42 +03:00
parent a51724fdcc
commit 83dca068f8
6 changed files with 174 additions and 43 deletions

View File

@@ -1,16 +1,106 @@
// Tema geçişi (DESIGN.md §2.3): açık ↔ koyu değişimi ekranı bir anda
// çevirmez; tüm sayfa tek parça, yumuşak bir crossfade ile yeni temaya geçer
// (View Transitions API; süre ve eğri globals.css `.kt-tema-gecis`
// bloğunda, mursela-nextjs'teki tema geçişiyle aynı: 640 ms ease-in-out).
// Tarayıcı View Transitions bilmiyorsa ya da kullanıcı hareketi azaltmışsa
// tema düz değişir.
// çevirmez; yeni tema header'daki KolayTercih işaretinden başlar ve ekrana
// PİKSEL PİKSEL yayılır — sitenin piksel imzasının (PixelMark, PixelDivider)
// hareketli hâli. Her kare kendi sırası gelince yumuşakça belirir; dalganın
// ön yüzü düz bir çizgi değil, karelerin rastgele öne-arkaya düştüğü
// dağınık bir kenardır.
//
// Nasıl: View Transitions API eski/yeni görüntü çiftini verir; yeni görüntü
// küçük bir tuvalde üretilen kare ızgarası maskesiyle açılır (her hücre bir
// kare, alfası o karenin ne kadar belirdiği). Kareler önceden üretilir ve
// kare kare maskeye basılır (globals.css `.kt-tema-gecis`). Tarayıcı View
// Transitions bilmiyorsa ya da kullanıcı hareketi azaltmışsa tema düz değişir.
/** Geçiş sırasında <html>'de duran sınıf: crossfade stili yalnız tema
* değişimine uygulanır, başka view transition'lara karışmaz. */
/** Geçiş sırasında <html>'de duran sınıf (globals.css). */
const GECIS_SINIFI = "kt-tema-gecis";
// Maske karesi sayısı: ~30 kare/sn; piksel dilinde akıcı, üretimi ucuz.
const KARE_SAYISI = 28;
// Tuvalde bir hücrenin piksel kenarı: büyütülünce kare kenarı keskin kalsın.
const HUCRE_COZUNURLUK = 4;
// Dalga: karenin sırası = merkeze uzaklık (DALGA) + rastgele pay (SACILMA);
// sırası gelen kare BELIRME kadar sürede 0 → 1 alfaya çıkar (toplam 1).
const DALGA = 0.66;
const SACILMA = 0.19;
const BELIRME = 0.15;
type Maske = { anahtar: string; kareler: string[]; en: number; boy: number };
let onbellek: Maske | null = null;
let suruyor = false;
/** Ekrandaki bir karenin kenarı (px): ekranla ölçeklenir, sınırlar içinde. */
function hucreKenari(en: number, boy: number) {
return Math.max(28, Math.min(56, Math.round(Math.min(en, boy) / 16)));
}
/** easeInOutCubic: dalga yumuşak kalkar, yumuşak biter. */
function yumusat(t: number) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/**
* Maske karelerini üretir: `sutun × satir` hücrelik ızgara, dalga (mx, my)
* hücresinden yayılır. Aynı ızgara ve merkez için sonuç önbellekten gelir —
* ikinci basışta üretim bedeli yoktur, görseller de çözülmüş olur.
*/
function maskeUret(en: number, boy: number, merkezX: number, merkezY: number): Maske | null {
const kenar = hucreKenari(en, boy);
const sutun = Math.ceil(en / kenar);
const satir = Math.ceil(boy / kenar);
const mx = Math.floor(merkezX / kenar);
const my = Math.floor(merkezY / kenar);
const anahtar = `${sutun}x${satir}@${mx},${my}`;
if (onbellek?.anahtar === anahtar) return onbellek;
const tuval = document.createElement("canvas");
tuval.width = sutun * HUCRE_COZUNURLUK;
tuval.height = satir * HUCRE_COZUNURLUK;
const cizim = tuval.getContext("2d");
if (!cizim) return null;
// Her karenin sırası: merkeze uzaklık (en uzak köşeye oranla) + saçılma.
const enUzak = Math.hypot(Math.max(mx, sutun - 1 - mx), Math.max(my, satir - 1 - my)) || 1;
const sira = new Float32Array(sutun * satir);
for (let y = 0; y < satir; y++) {
for (let x = 0; x < sutun; x++) {
const uzaklik = Math.hypot(x - mx, y - my) / enUzak;
sira[y * sutun + x] = uzaklik * DALGA + Math.random() * SACILMA;
}
}
const kareler: string[] = [];
for (let k = 0; k < KARE_SAYISI; k++) {
// İlk kare boş değil: geçiş ilk karede başlamış görünsün; son kare tam dolu.
const t = yumusat((k + 1) / KARE_SAYISI);
cizim.clearRect(0, 0, tuval.width, tuval.height);
for (let y = 0; y < satir; y++) {
for (let x = 0; x < sutun; x++) {
const alfa = (t - sira[y * sutun + x]) / BELIRME;
if (alfa <= 0) continue;
cizim.fillStyle = `rgba(0,0,0,${Math.min(1, alfa)})`;
cizim.fillRect(
x * HUCRE_COZUNURLUK,
y * HUCRE_COZUNURLUK,
HUCRE_COZUNURLUK,
HUCRE_COZUNURLUK,
);
}
}
kareler.push(`url("${tuval.toDataURL("image/png")}")`);
}
onbellek = { anahtar, kareler, en: sutun * kenar, boy: satir * kenar };
return onbellek;
}
/** CSS'teki `--kt-tema-sure` (ms ya da s) — sürenin tek kaynağı globals.css. */
function gecisSuresi(kok: HTMLElement) {
const ham = getComputedStyle(kok).getPropertyValue("--kt-tema-sure").trim();
const sayi = parseFloat(ham);
if (!Number.isFinite(sayi)) return 900;
return ham.endsWith("ms") ? sayi : sayi * 1000;
}
/**
* `uygula` temayı değiştiren çağrıdır (ör. `setTheme("dark")`). Geçiş
* sürerken gelen istek yok sayılır: art arda D'ye basınca ekrandaki görüntü
@@ -29,17 +119,60 @@ export function temayiGecisle(uygula: () => void) {
if (suruyor) return;
const kok = document.documentElement;
// Dalganın merkezi header'daki logo; krom gizli sayfalarda sol üst köşe.
const logo = document
.querySelector('header a[aria-label="KolayTercih"]')
?.getBoundingClientRect();
const gorunur = logo && logo.width > 0 && logo.bottom > 0;
const merkezX = gorunur ? logo.left + logo.height / 2 : 24;
const merkezY = gorunur ? logo.top + logo.height / 2 : 24;
const maske = maskeUret(window.innerWidth, window.innerHeight, merkezX, merkezY);
if (!maske) {
uygula();
return;
}
const sure = gecisSuresi(kok);
kok.style.setProperty("--kt-tema-maske", maske.kareler[0]);
kok.style.setProperty("--kt-tema-en", `${maske.en}px`);
kok.style.setProperty("--kt-tema-boy", `${maske.boy}px`);
kok.classList.add(GECIS_SINIFI);
suruyor = true;
let cerceve = 0;
let baslangic = 0;
let sonKare = 0;
const ilerle = (simdi: number) => {
if (!baslangic) baslangic = simdi;
const kare = Math.min(
KARE_SAYISI - 1,
Math.floor(((simdi - baslangic) / sure) * KARE_SAYISI),
);
if (kare !== sonKare) {
sonKare = kare;
kok.style.setProperty("--kt-tema-maske", maske.kareler[kare]);
}
if (kare < KARE_SAYISI - 1) cerceve = requestAnimationFrame(ilerle);
};
const gecis = document.startViewTransition(uygula);
// Görüntü çifti hazır olunca saat başlar: yakalama süresi dalgadan yemesin.
// Sekme gizliyken / yeniden boyutlanırken tarayıcı geçişi iptal eder:
// `ready` reddedilir, tema yine uygulanmıştır; konsola hata düşmesin.
gecis.ready.catch(() => {});
gecis.ready
.then(() => {
cerceve = requestAnimationFrame(ilerle);
})
.catch(() => {});
gecis.finished
.catch(() => {})
.finally(() => {
cancelAnimationFrame(cerceve);
kok.classList.remove(GECIS_SINIFI);
kok.style.removeProperty("--kt-tema-maske");
kok.style.removeProperty("--kt-tema-en");
kok.style.removeProperty("--kt-tema-boy");
suruyor = false;
});
}