diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index c3a7f1a..f4c3941 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -133,11 +133,13 @@ Seçici `src/components/tema-secici.tsx` (footer'da), sağlayıcı `src/componen (`next-themes`, ``). Renklerin tek kaynağı `src/app/globals.css` içindeki `.dark` bloğu. -**Geçiş ve kısayol (7 Eki 2026, 9 Eki'de sadeleşti):** tema değişimi anlık değildir; -tüm sayfa tek parça, 640 ms ease-in-out crossfade ile yeni temaya geçer (View -Transitions: `src/lib/tema-gecisi.ts`, `globals.css` `.kt-tema-gecis`; hareket -azaltılmışsa düz değişir). Logo maskeli yayılma denendi ve kaldırıldı (Bilal'in -kararı, 9 Eki 2026): mursela-nextjs'teki sade crossfade yeter. +**Geçiş ve kısayol (7 Eki 2026; son hâli 9 Eki):** tema değişimi anlık değildir; yeni +tema header'daki logodan başlar ve ekrana **piksel piksel** yayılır — piksel imzasının +(§1, `PixelMark`/`PixelDivider`) hareketli hâli. Kareler sırası gelince yumuşakça belirir, +dalganın ön yüzü dağınık bir piksel kenarıdır; 900 ms (`--kt-tema-sure`). View Transitions ++ tuvalden üretilen kare ızgarası maskesi: `src/lib/tema-gecisi.ts`, `globals.css` +`.kt-tema-gecis`; hareket azaltılmışsa düz değişir. Logo biçimli maske ve düz crossfade +denendi, bu seçildi (Bilal işi tasarıma bıraktı, 9 Eki 2026). `D` tuşu açık ↔ koyu geçişi yapar (`src/components/tema-kisayolu.tsx`); ipucu seçicinin altında `Kbd` ile durur, başka yere kopyalanmaz. Header'daki Beta rozeti `/surum-notlari`na bağlantıdır. diff --git a/src/app/globals.css b/src/app/globals.css index 7bde5d3..2f8968c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -477,47 +477,43 @@ background: var(--color-slate-700); } -/* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): tema değişirken tüm - sayfa tek parça crossfade yapar — eski görüntü solar, yeni belirir. - mursela-nextjs'teki tema geçişiyle aynı: 640 ms, ease-in-out. Sınıf - (`kt-tema-gecis`) geçiş süresince 'de durur; stil yalnız tema - değişimine uygulanır. Hareket azaltılmışsa JS geçişi hiç başlatmaz, - tema düz değişir. */ +/* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): yeni tema header'daki + logodan başlar ve ekrana piksel piksel yayılır — sitenin piksel imzasının + hareketli hâli. Eski görüntü yerinde durur; yeni görüntü, JS'in kare kare + bastığı kare ızgarası maskesiyle (--kt-tema-maske, küçük bir tuvalden + üretilir; --kt-tema-en/boy ızgaranın ekrandaki ölçüsü) üstünde açılır. + `pixelated`: küçük maske büyütülürken kareler bulanmaz, keskin kalır. + Sürenin tek kaynağı --kt-tema-sure (JS buradan okur). kt-tema-omur değeri + değişmeyen ama gerçek bir animasyondur: view transition, üzerindeki + animasyon bitene kadar kalkmaz; maskeyi JS sürerken ömrü bu tutar. + Hareket azaltılmışsa JS geçişi hiç başlatmaz, tema düz değişir. */ +:root { + --kt-tema-sure: 900ms; +} .kt-tema-gecis { view-transition-name: root; - --kt-tema-sure: 640ms; - --kt-tema-egri: cubic-bezier(0.6, 0, 0.2, 1); } .kt-tema-gecis::view-transition-old(root), .kt-tema-gecis::view-transition-new(root) { + animation: none; mix-blend-mode: normal; - will-change: opacity; - backface-visibility: hidden; -} -.kt-tema-gecis::view-transition-group(root) { - animation-duration: var(--kt-tema-sure); - animation-timing-function: var(--kt-tema-egri); } .kt-tema-gecis::view-transition-old(root) { z-index: 1; - animation: kt-tema-cikis var(--kt-tema-sure) var(--kt-tema-egri) both; } .kt-tema-gecis::view-transition-new(root) { - z-index: 0; - animation: kt-tema-giris var(--kt-tema-sure) var(--kt-tema-egri) both; + z-index: 2; + mask-image: var(--kt-tema-maske); + mask-repeat: no-repeat; + mask-position: 0 0; + mask-size: var(--kt-tema-en) var(--kt-tema-boy); + image-rendering: pixelated; + animation: kt-tema-omur calc(var(--kt-tema-sure) + 80ms) linear both; } -@keyframes kt-tema-cikis { +@keyframes kt-tema-omur { from { opacity: 1; } - to { - opacity: 0; - } -} -@keyframes kt-tema-giris { - from { - opacity: 0; - } to { opacity: 1; } diff --git a/src/components/tema-kisayolu.tsx b/src/components/tema-kisayolu.tsx index d11382f..523bbcd 100644 --- a/src/components/tema-kisayolu.tsx +++ b/src/components/tema-kisayolu.tsx @@ -16,7 +16,7 @@ function yaziAlanindaMi(hedef: EventTarget | null) { * Klavye kısayolu: `D` açık ↔ koyu temayı değiştirir (ipucu footer'daki * TemaSecici'nin altında, Kbd ile). Değiştirici tuşla (⌘/Ctrl/Alt) ya da yazı * alanındayken çalışmaz; "sistem" seçiliyken o anki çözümlenmiş temanın - * tersine, açık bir seçime geçer. Geçiş sayfa geneli crossfade ile oynar. + * tersine, açık bir seçime geçer. Geçiş logodan piksel piksel yayılır. */ export function TemaKisayolu() { const { resolvedTheme, setTheme } = useTheme(); diff --git a/src/components/tema-saglayici.tsx b/src/components/tema-saglayici.tsx index fbdc1eb..8e8da73 100644 --- a/src/components/tema-saglayici.tsx +++ b/src/components/tema-saglayici.tsx @@ -27,7 +27,7 @@ function AdresCubuguRengi() { * globals.css'teki `.dark` token eşlemesinden gelir. Varsayılan açık tema; * kullanıcı footer'daki TemaSecici ile koyu ya da "sistem"e geçer, seçim * tarayıcıda saklanır; `D` tuşu açık ↔ koyu geçişi yapar (TemaKisayolu). - * Tema değişimi sayfa geneli crossfade ile oynar (lib/tema-gecisi.ts). + * Tema değişimi logodan piksel piksel yayılır (lib/tema-gecisi.ts). */ export function TemaSaglayici({ children }: { children: React.ReactNode }) { return ( diff --git a/src/components/tema-secici.tsx b/src/components/tema-secici.tsx index 290f2ad..f94cca8 100644 --- a/src/components/tema-secici.tsx +++ b/src/components/tema-secici.tsx @@ -18,8 +18,8 @@ const aboneOlma = () => () => {}; /** * Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca * tarayıcıda bilinir; sunucu HTML'inde hiçbir seçenek işaretli gelmez, - * hydration sonrası işaretlenir (yerleşim oynamaz). Seçim sayfa geneli crossfade - * ile uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur. + * hydration sonrası işaretlenir (yerleşim oynamaz). Seçim logodan piksel piksel + * yayılarak uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur. */ export function TemaSecici({ className }: { className?: string }) { const { theme, setTheme } = useTheme(); diff --git a/src/lib/tema-gecisi.ts b/src/lib/tema-gecisi.ts index fc2c42d..e95c58e 100644 --- a/src/lib/tema-gecisi.ts +++ b/src/lib/tema-gecisi.ts @@ -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 '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 '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; }); }