From 60223191746461b33c64d07b266ce616192af757 Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Fri, 9 Oct 2026 00:25:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(tema):=20tema=20ge=C3=A7i=C5=9Fi=20logodan?= =?UTF-8?q?=20yumu=C5=9Fak,=20t=C3=BCyl=C3=BC=20kenarl=C4=B1=20halka=20ola?= =?UTF-8?q?rak=20a=C3=A7=C4=B1l=C4=B1r;=20logo=20bir=20etap=20oynar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piksel ızgarası sert bulundu (Bilal, 9 Eki 2026). Yeni tema radyal degrade maskesiyle açılır: yarıçapın %55'i opak, kalanı tüy; 1100 ms yumuşak eğri. Geçiş başlarken header'daki logonun kareleri bir etap yürüyüp yerine oturur (kt-tema-gecis olayı). Tuval/maske karesi kodu kaldırıldı. DESIGN §2.3 güncel. Co-Authored-By: Claude Opus 5.5 --- docs/tasarim/DESIGN.md | 12 +-- src/app/globals.css | 39 +++---- src/components/animasyonlu-mark.tsx | 16 +++ src/components/tema-kisayolu.tsx | 2 +- src/components/tema-saglayici.tsx | 2 +- src/components/tema-secici.tsx | 4 +- src/lib/tema-gecisi.ts | 160 ++++++---------------------- 7 files changed, 76 insertions(+), 159 deletions(-) diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index f4c3941..5827ef7 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -134,12 +134,12 @@ Seçici `src/components/tema-secici.tsx` (footer'da), sağlayıcı `src/componen `.dark` bloğu. **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). +tema header'daki logodan **yumuşak bir halka** gibi açılır: kenarı keskin değil, geniş ve +tüylü (ışık yayılır gibi), 1100 ms, yumuşak kalkıp yumuşak durur. Aynı anda logonun kareleri +bir etap yürüyüp yerine oturur; geçişi logo başlatmış olur. View Transitions + radyal degrade +maskesi: `src/lib/tema-gecisi.ts`, `globals.css` `.kt-tema-gecis`; hareket azaltılmışsa düz +değişir. Denenip bırakılanlar: logo biçimli maske ("logo içinde logo"), düz crossfade, piksel +ızgarası (sert bulundu). Kural: bu geçişte sert kenar ve ikinci bir şekil yok. `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 2f8968c..9856f46 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -478,20 +478,21 @@ } /* 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. + logodan yumuşak bir halka gibi açılır. Eski görüntü yerinde durur; yeni + görüntü radyal degrade maskesiyle üstünde belirir. Maskenin kenarı keskin + değil: yarıçapın %55'ine kadar tam opak, oradan kenara doğru tüy gibi + söner (--kt-tema-cekirdek; JS'teki CEKIRDEK ile aynı olmalı) — ışık + yayılır gibi, sert çizgi yok. Yarıçap (--kt-tema-r) 0 → --kt-tema-son + yumuşak kalkıp yumuşak durur. Merkez ve son yarıçap JS'ten gelir. Hareket azaltılmışsa JS geçişi hiç başlatmaz, tema düz değişir. */ -:root { - --kt-tema-sure: 900ms; +@property --kt-tema-r { + syntax: ""; + inherits: true; + initial-value: 0px; } .kt-tema-gecis { view-transition-name: root; + --kt-tema-cekirdek: 0.55; } .kt-tema-gecis::view-transition-old(root), .kt-tema-gecis::view-transition-new(root) { @@ -503,18 +504,18 @@ } .kt-tema-gecis::view-transition-new(root) { 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; + mask-image: radial-gradient( + circle at var(--kt-tema-x) var(--kt-tema-y), + #000 calc(var(--kt-tema-r) * var(--kt-tema-cekirdek)), + transparent var(--kt-tema-r) + ); + animation: kt-tema-halka 1100ms cubic-bezier(0.45, 0, 0.2, 1) both; } -@keyframes kt-tema-omur { +@keyframes kt-tema-halka { from { - opacity: 1; + --kt-tema-r: 0px; } to { - opacity: 1; + --kt-tema-r: var(--kt-tema-son); } } diff --git a/src/components/animasyonlu-mark.tsx b/src/components/animasyonlu-mark.tsx index 406e12a..6161fe2 100644 --- a/src/components/animasyonlu-mark.tsx +++ b/src/components/animasyonlu-mark.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; +import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi"; import { cn } from "@/lib/utils"; // public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı. @@ -276,9 +277,24 @@ export function AnimasyonluMark({ const cik = () => kontrolRef.current?.ayarla(null, true); hoverHedefi.addEventListener("mouseenter", gir); hoverHedefi.addEventListener("mouseleave", cik); + // Tema geçişi başlarken (lib/tema-gecisi.ts) kareler bir etap yürür ve + // yerine oturur: geçiş logodan yayıldığı için onu logo başlatmış olur. + // İmleç zaten üzerindeyse hover döngüsü sürer, dokunulmaz. + let temaZamanlayici: number | undefined; + const temaGecti = () => { + if (hoverHedefi.matches(":hover")) return; + gir(); + window.clearTimeout(temaZamanlayici); + temaZamanlayici = window.setTimeout(() => { + if (!hoverHedefi.matches(":hover")) cik(); + }, 1000); + }; + window.addEventListener(TEMA_GECIS_OLAYI, temaGecti); return () => { hoverHedefi.removeEventListener("mouseenter", gir); hoverHedefi.removeEventListener("mouseleave", cik); + window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti); + window.clearTimeout(temaZamanlayici); }; }, [oynat, sadeceHover, varyant]); diff --git a/src/components/tema-kisayolu.tsx b/src/components/tema-kisayolu.tsx index 523bbcd..bcac8b4 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ş logodan piksel piksel yayılır. + * tersine, açık bir seçime geçer. Geçiş logodan yumuşak bir halka gibi 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 8e8da73..aeb4462 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 logodan piksel piksel yayılır (lib/tema-gecisi.ts). + * Tema değişimi logodan yumuşak bir halka gibi 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 f94cca8..bc98272 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 logodan piksel piksel - * yayılarak uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur. + * hydration sonrası işaretlenir (yerleşim oynamaz). Seçim logodan yumuşak bir halka + * gibi 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 e95c58e..7ca82d6 100644 --- a/src/lib/tema-gecisi.ts +++ b/src/lib/tema-gecisi.ts @@ -1,106 +1,29 @@ // Tema geçişi (DESIGN.md §2.3): açık ↔ koyu değişimi ekranı bir anda -// ç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. +// çevirmez; yeni tema header'daki KolayTercih işaretinden yumuşak bir halka +// gibi açılır — kenarı keskin değil, geniş ve tüylü; ışık yayılır gibi. +// Aynı anda logonun kareleri bir tur atar: geçişi logo "başlatmış" olur. +// Markanın yumuşak, oyunsu diline uysun diye sert kenar, piksel ya da ikinci +// bir şekil yok (9 Eki 2026). // // 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. +// radyal degrade maskesiyle açılır. Yarıçapı CSS sürer (globals.css +// `.kt-tema-gecis`), buradan yalnız merkez ve son yarıçap verilir. Tarayıcı +// View Transitions bilmiyorsa ya da hareket azaltılmışsa tema düz değişir. /** 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; +/** Geçiş başlarken pencereye atılan olay; header'daki logo bunu dinleyip + * karelerini bir tur oynatır (animasyonlu-mark.tsx). */ +export const TEMA_GECIS_OLAYI = "kt-tema-gecis"; + +// Maskenin tam opak çekirdeği yarıçapın bu oranında biter, kalanı tüydür +// (globals.css'teki --kt-tema-cekirdek ile aynı olmalı): son yarıçap, çekirdek +// en uzak köşeyi de örtecek kadar büyük seçilir. +const CEKIRDEK = 0.55; -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ü @@ -119,60 +42,37 @@ 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. + // Halkanı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 x = gorunur ? logo.left + logo.height / 2 : 24; + const y = gorunur ? logo.top + logo.height / 2 : 24; + const enUzak = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y), + ); - 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.style.setProperty("--kt-tema-x", `${x}px`); + kok.style.setProperty("--kt-tema-y", `${y}px`); + kok.style.setProperty("--kt-tema-son", `${Math.ceil(enUzak / CEKIRDEK)}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 - .then(() => { - cerceve = requestAnimationFrame(ilerle); - }) + .then(() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI))) .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"); + kok.style.removeProperty("--kt-tema-x"); + kok.style.removeProperty("--kt-tema-y"); + kok.style.removeProperty("--kt-tema-son"); suruyor = false; }); }