v1-beta-tema #9

Merged
bilal merged 19 commits from v1-beta-tema into main 2026-10-09 02:30:44 +03:00
7 changed files with 76 additions and 159 deletions
Showing only changes of commit 6022319174 - Show all commits

View File

@@ -134,12 +134,12 @@ Seçici `src/components/tema-secici.tsx` (footer'da), sağlayıcı `src/componen
`.dark` bloğu. `.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 **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 tema header'daki logodan **yumuşak bir halka** gibi açılır: kenarı keskin değil, geniş ve
(§1, `PixelMark`/`PixelDivider`) hareketli hâli. Kareler sırası gelince yumuşakça belirir, tüylü (ışık yayılır gibi), 1100 ms, yumuşak kalkıp yumuşak durur. Aynı anda logonun kareleri
dalganın ön yüzü dağınık bir piksel kenarıdır; 900 ms (`--kt-tema-sure`). View Transitions bir etap yürüyüp yerine oturur; geçişi logo başlatmış olur. View Transitions + radyal degrade
+ tuvalden üretilen kare ızgarası maskesi: `src/lib/tema-gecisi.ts`, `globals.css` maskesi: `src/lib/tema-gecisi.ts`, `globals.css` `.kt-tema-gecis`; hareket azaltılmışsa düz
`.kt-tema-gecis`; hareket azaltılmışsa düz değişir. Logo biçimli maske ve düz crossfade değişir. Denenip bırakılanlar: logo biçimli maske ("logo içinde logo"), düz crossfade, piksel
denendi, bu seçildi (Bilal işi tasarıma bıraktı, 9 Eki 2026). ı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 `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 altında `Kbd` ile durur, başka yere kopyalanmaz. Header'daki Beta rozeti `/surum-notlari`na
bağlantıdır. bağlantıdır.

View File

@@ -478,20 +478,21 @@
} }
/* Tema geçişi (DESIGN.md §2.3, lib/tema-gecisi.ts): yeni tema header'daki /* 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 logodan yumuşak bir halka gibi açılır. Eski görüntü yerinde durur; yeni
hareketli hâli. Eski görüntü yerinde durur; yeni görüntü, JS'in kare kare görüntü radyal degrade maskesiyle üstünde belirir. Maskenin kenarı keskin
bastığı kare ızgarası maskesiyle (--kt-tema-maske, küçük bir tuvalden değil: yarıçapın %55'ine kadar tam opak, oradan kenara doğru tüy gibi
üretilir; --kt-tema-en/boy ızgaranın ekrandaki ölçüsü) üstünde açılır. söner (--kt-tema-cekirdek; JS'teki CEKIRDEK ile aynı olmalı) — ışık
`pixelated`: küçük maske büyütülürken kareler bulanmaz, keskin kalır. yayılır gibi, sert çizgi yok. Yarıçap (--kt-tema-r) 0 → --kt-tema-son
Sürenin tek kaynağı --kt-tema-sure (JS buradan okur). kt-tema-omur değeri yumuşak kalkıp yumuşak durur. Merkez ve son yarıçap JS'ten gelir.
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. */ Hareket azaltılmışsa JS geçişi hiç başlatmaz, tema düz değişir. */
:root { @property --kt-tema-r {
--kt-tema-sure: 900ms; syntax: "<length>";
inherits: true;
initial-value: 0px;
} }
.kt-tema-gecis { .kt-tema-gecis {
view-transition-name: root; view-transition-name: root;
--kt-tema-cekirdek: 0.55;
} }
.kt-tema-gecis::view-transition-old(root), .kt-tema-gecis::view-transition-old(root),
.kt-tema-gecis::view-transition-new(root) { .kt-tema-gecis::view-transition-new(root) {
@@ -503,18 +504,18 @@
} }
.kt-tema-gecis::view-transition-new(root) { .kt-tema-gecis::view-transition-new(root) {
z-index: 2; z-index: 2;
mask-image: var(--kt-tema-maske); mask-image: radial-gradient(
mask-repeat: no-repeat; circle at var(--kt-tema-x) var(--kt-tema-y),
mask-position: 0 0; #000 calc(var(--kt-tema-r) * var(--kt-tema-cekirdek)),
mask-size: var(--kt-tema-en) var(--kt-tema-boy); transparent var(--kt-tema-r)
image-rendering: pixelated; );
animation: kt-tema-omur calc(var(--kt-tema-sure) + 80ms) linear both; animation: kt-tema-halka 1100ms cubic-bezier(0.45, 0, 0.2, 1) both;
} }
@keyframes kt-tema-omur { @keyframes kt-tema-halka {
from { from {
opacity: 1; --kt-tema-r: 0px;
} }
to { to {
opacity: 1; --kt-tema-r: var(--kt-tema-son);
} }
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { TEMA_GECIS_OLAYI } from "@/lib/tema-gecisi";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı. // public/kolay-tercih-mark.svg'nin inline, animasyonlu karşılığı.
@@ -276,9 +277,24 @@ export function AnimasyonluMark({
const cik = () => kontrolRef.current?.ayarla(null, true); const cik = () => kontrolRef.current?.ayarla(null, true);
hoverHedefi.addEventListener("mouseenter", gir); hoverHedefi.addEventListener("mouseenter", gir);
hoverHedefi.addEventListener("mouseleave", cik); 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 () => { return () => {
hoverHedefi.removeEventListener("mouseenter", gir); hoverHedefi.removeEventListener("mouseenter", gir);
hoverHedefi.removeEventListener("mouseleave", cik); hoverHedefi.removeEventListener("mouseleave", cik);
window.removeEventListener(TEMA_GECIS_OLAYI, temaGecti);
window.clearTimeout(temaZamanlayici);
}; };
}, [oynat, sadeceHover, varyant]); }, [oynat, sadeceHover, varyant]);

View File

@@ -16,7 +16,7 @@ function yaziAlanindaMi(hedef: EventTarget | null) {
* Klavye kısayolu: `D` açık ↔ koyu temayı değiştirir (ipucu footer'daki * 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ı * 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 * 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() { export function TemaKisayolu() {
const { resolvedTheme, setTheme } = useTheme(); const { resolvedTheme, setTheme } = useTheme();

View File

@@ -27,7 +27,7 @@ function AdresCubuguRengi() {
* globals.css'teki `.dark` token eşlemesinden gelir. Varsayılan açık tema; * 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 * 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). * 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 }) { export function TemaSaglayici({ children }: { children: React.ReactNode }) {
return ( return (

View File

@@ -18,8 +18,8 @@ const aboneOlma = () => () => {};
/** /**
* Tema seçici: açık / koyu / sistem (DESIGN.md §2.3). Seçili tema yalnızca * 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, * 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 * hydration sonrası işaretlenir (yerleşim oynamaz). Seçim logodan yumuşak bir halka
* yayılarak uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur. * gibi yayılarak uygulanır (lib/tema-gecisi.ts); altında `D` kısayolu ipucu durur.
*/ */
export function TemaSecici({ className }: { className?: string }) { export function TemaSecici({ className }: { className?: string }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();

View File

@@ -1,106 +1,29 @@
// Tema geçişi (DESIGN.md §2.3): açık ↔ koyu değişimi ekranı bir anda // 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 // çevirmez; yeni tema header'daki KolayTercih işaretinden yumuşak bir halka
// PİKSEL PİKSEL yayılır — sitenin piksel imzasının (PixelMark, PixelDivider) // gibi açılır — kenarı keskin değil, geniş ve tüylü; ışık yayılır gibi.
// hareketli hâli. Her kare kendi sırası gelince yumuşakça belirir; dalganın // Aynı anda logonun kareleri bir tur atar: geçişi logo "başlatmış" olur.
// ön yüzü düz bir çizgi değil, karelerin rastgele öne-arkaya düştüğü // Markanın yumuşak, oyunsu diline uysun diye sert kenar, piksel ya da ikinci
// dağınık bir kenardır. // bir şekil yok (9 Eki 2026).
// //
// Nasıl: View Transitions API eski/yeni görüntü çiftini verir; yeni görüntü // 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 // radyal degrade maskesiyle açılır. Yarıçapı CSS sürer (globals.css
// kare, alfası o karenin ne kadar belirdiği). Kareler önceden üretilir ve // `.kt-tema-gecis`), buradan yalnız merkez ve son yarıçap verilir. Tarayıcı
// kare kare maskeye basılır (globals.css `.kt-tema-gecis`). Tarayıcı View // View Transitions bilmiyorsa ya da hareket azaltılmışsa tema düz değişir.
// 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 (globals.css). */ /** Geçiş sırasında <html>'de duran sınıf (globals.css). */
const GECIS_SINIFI = "kt-tema-gecis"; const GECIS_SINIFI = "kt-tema-gecis";
// Maske karesi sayısı: ~30 kare/sn; piksel dilinde akıcı, üretimi ucuz. /** Geçiş başlarken pencereye atılan olay; header'daki logo bunu dinleyip
const KARE_SAYISI = 28; * karelerini bir tur oynatır (animasyonlu-mark.tsx). */
// Tuvalde bir hücrenin piksel kenarı: büyütülünce kare kenarı keskin kalsın. export const TEMA_GECIS_OLAYI = "kt-tema-gecis";
const HUCRE_COZUNURLUK = 4;
// Dalga: karenin sırası = merkeze uzaklık (DALGA) + rastgele pay (SACILMA); // Maskenin tam opak çekirdeği yarıçapın bu oranında biter, kalanı tüydür
// sırası gelen kare BELIRME kadar sürede 0 → 1 alfaya çıkar (toplam 1). // (globals.css'teki --kt-tema-cekirdek ile aynı olmalı): son yarıçap, çekirdek
const DALGA = 0.66; // en uzak köşeyi de örtecek kadar büyük seçilir.
const SACILMA = 0.19; const CEKIRDEK = 0.55;
const BELIRME = 0.15;
type Maske = { anahtar: string; kareler: string[]; en: number; boy: number };
let onbellek: Maske | null = null;
let suruyor = false; 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ş * `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ü * 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; if (suruyor) return;
const kok = document.documentElement; 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 const logo = document
.querySelector('header a[aria-label="KolayTercih"]') .querySelector('header a[aria-label="KolayTercih"]')
?.getBoundingClientRect(); ?.getBoundingClientRect();
const gorunur = logo && logo.width > 0 && logo.bottom > 0; const gorunur = logo && logo.width > 0 && logo.bottom > 0;
const merkezX = gorunur ? logo.left + logo.height / 2 : 24; const x = gorunur ? logo.left + logo.height / 2 : 24;
const merkezY = gorunur ? logo.top + 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); kok.style.setProperty("--kt-tema-x", `${x}px`);
if (!maske) { kok.style.setProperty("--kt-tema-y", `${y}px`);
uygula(); kok.style.setProperty("--kt-tema-son", `${Math.ceil(enUzak / CEKIRDEK)}px`);
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); kok.classList.add(GECIS_SINIFI);
suruyor = true; 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); 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: // Sekme gizliyken / yeniden boyutlanırken tarayıcı geçişi iptal eder:
// `ready` reddedilir, tema yine uygulanmıştır; konsola hata düşmesin. // `ready` reddedilir, tema yine uygulanmıştır; konsola hata düşmesin.
gecis.ready gecis.ready
.then(() => { .then(() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)))
cerceve = requestAnimationFrame(ilerle);
})
.catch(() => {}); .catch(() => {});
gecis.finished gecis.finished
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
cancelAnimationFrame(cerceve);
kok.classList.remove(GECIS_SINIFI); kok.classList.remove(GECIS_SINIFI);
kok.style.removeProperty("--kt-tema-maske"); kok.style.removeProperty("--kt-tema-x");
kok.style.removeProperty("--kt-tema-en"); kok.style.removeProperty("--kt-tema-y");
kok.style.removeProperty("--kt-tema-boy"); kok.style.removeProperty("--kt-tema-son");
suruyor = false; suruyor = false;
}); });
} }