feat(v2): 9–10 Ekim çalışma ağacı — deneyim dili 1. dalga, PWA manifest, revizyon niyet modeli, listem/tadımlık bileşen denetimi

9–10 Ekim oturumlarının commit'lenmemiş işi tek parça alındı
(raporlar: docs/ekip-raporlari/2026-10-09, 2026-10-10):
- Landing deneyim dili 1. dalga: sayfa geçişi (ViewTransition), piksel morf/şerit,
  sorun sahneleri, adım akışı, sayan sayı, mobil menü, adım göstergesi, logoya tepki;
  DESIGN.md §11 ve AGENTS.md kuralları, tasarım denetimine yeni kurallar
- Tema: tema ikonu, D kısayolu, tema geçişi
- PWA: manifest.ts ve ikonlar
- Danışman: revizyon niyet modeli (OPENROUTER_NIYET_MODEL), sohbet geçmişi, soru API'si
- /listem ve /sonuc tadımlığı bileşen denetimi düzeltmeleri
- eslint: .venv-tts/ yok sayılır

tsc, eslint ve tasarım denetimi sıfır hata.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-10-11 00:34:29 +03:00
parent da0e9dff7a
commit aec8a2ad76
80 changed files with 4134 additions and 654 deletions

View File

@@ -8,6 +8,7 @@
// yalnız 0,12 s'lik çapraz solma.
import type { Transition, Variants } from "motion/react";
import { YAY } from "@/lib/hareket";
/**
* enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1
@@ -58,8 +59,8 @@ export function adimGecisi(hareketiAzalt: boolean): Transition {
height: { duration: 0 },
}
: {
x: { type: "spring", stiffness: 300, damping: 30 },
height: { type: "spring", stiffness: 300, damping: 30 },
x: YAY.adim,
height: YAY.adim,
opacity: { duration: 0.2 },
};
}

View File

@@ -511,7 +511,11 @@ export const LISTE_REVIZE_PARAMETRELERI = {
adet: { type: "integer", minimum: 1, maximum: 24 },
iller: { type: "array", items: { type: "string" } },
kategoriler: { type: "array", items: { type: "string", enum: [...KATEGORILER] } },
universiteTipi: { type: "string", enum: ["devlet", "vakif"] },
universiteTipi: {
type: "string",
enum: ["devlet", "vakif"],
description: "Yalnız ilk N tercihe özgü tip kısıtı ('ilk 5 devlet olsun, gerisi fark etmez'). 'Vakıf olmasın / sadece devlet' gibi genel istek buraya değil üst düzey universiteTipi'ne yazılır.",
},
},
required: ["adet"],
additionalProperties: false,

View File

@@ -81,6 +81,20 @@ const URETIM_ZAMAN_ASIMI_MS = 60_000;
* Kalanlar arasında yönlendirme OpenRouter'da kalır.
*/
const DISLAMA_SURESI_MS = 6 * 60 * 60_000;
/**
* Düşünme (reasoning) ayarı. Varsayılan "none": görev küçük, hibrit model
* düşünmeye bütçe yemesin. Bazı modeller/uç noktalar "none"ı 400 ile
* reddediyor ("Reasoning is mandatory", ölçüm 10 Eki 2026: glm-5.3-flash,
* gpt-5-nano); model değişiminde AI_REASONING_EFFORT ile "minimal"/"low"
* verilir, "yok" alanı hiç göndermez.
*/
function dusunmeAyari(): { reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" } {
const v = process.env.AI_REASONING_EFFORT?.trim();
if (v === "yok") return {};
if (v === "minimal" || v === "low" || v === "medium" || v === "high") return { reasoning_effort: v };
return { reasoning_effort: "none" };
}
const globalForCagri = globalThis as unknown as {
__aiDislananSaglayicilar?: Map<string, number>;
};
@@ -162,7 +176,7 @@ export async function yapilandirilmisUret<T extends z.ZodType>(opts: {
// Görev küçük (havuzdan seçim + tek cümlelik gerekce) ve şemaya
// uymayan çıktıyı doğrulama döngüsü zaten yakalıyor; hibrit
// modellerde düşünme bütçe/süre yemesin.
reasoning_effort: "none",
...dusunmeAyari(),
messages: [
{ role: "system", content: opts.system },
{ role: "user", content: opts.kullanici },

View File

@@ -36,7 +36,7 @@ import type { RaporParams, RevizyonKurali } from "@/features/rapor/types/rapor";
import { LISTE_REVIZE_PARAMETRELERI, SORU_SOR_PARAMETRELERI, revizyonKuraliKur } from "./araclar";
import { yapilandirilmisUret, type CagriGozlemi } from "./cagri";
import { niyetModeli } from "./client";
import { anketDogrula, anketModelMetni, type Anket } from "./sohbet-meta";
import { anketDogrula, anketModelMetni, type Anket, type AnketCevaplari } from "./sohbet-meta";
export type NiyetSonucu =
/** İstek net: kural doğrulandı, liste koddan kurulacak. */
@@ -95,19 +95,20 @@ const NIYET_TEL_SEMASI: Record<string, unknown> = {
const SISTEM = `Sen KolayTercih'in sunucu tarafı NİYET ÇÖZÜMLEYİCİSİSİN. Adayın (YKS öğrencisi) sohbet mesajını okur, 24'lük tercih listesinde DEĞİŞİKLİK isteyip istemediğine karar verir ve istiyorsa isteği yapısal kurala çevirirsin. Yalnız şemaya uyan JSON döndür; açıklama yazma.
NİYET:
- "revizyon": aday listenin değişmesini istiyor (il, ilgi alanı, devlet/vakıf, dilim dağılımı, "ilk N tercih", bir üniversite/bölümü çıkarma, program ekleme). "Form cevaplarım:" ile başlayan mesaj HER ZAMAN revizyondur.
- "revizyon": aday listenin değişmesini istiyor (il, ilgi alanı, devlet/vakıf, dilim dağılımı, "ilk N tercih", bir üniversite/bölümü çıkarma, program ekleme). "Form cevaplarım:" ile başlayan mesaj HER ZAMAN revizyondur. NE değişeceğini söylemeyen genel istekler de revizyondur ("listemi değiştir", "listemi güncelle", "listemi yeniden oluştur", "yeni liste istiyorum", "listeyi baştan kur", "tercihlerimi güncellemek istiyorum") — bunlarda kural null, form dolu; "diger" DEĞİL.
- "soru": listeyle ya da programlarla ilgili bilgi/soru; değişiklik istemiyor ("1. programın kontenjanı kaç?").
- "diger": sohbet, teşekkür, listeyle ilgisiz konu, not alma isteği.
KURAL (niyet=revizyon ve istek NET ise; aksi hâlde null). Karşılıklar:
- "ilk N tercih X ilinden/ilinde olsun", "ilk N'i X'e göre güncelle" → ilkTercihler {adet: N, iller: [X]}; X sihirbaz illerinde yoksa ayrıca iller.ekle: [X].
- "sadece X" → iller.yalniz; "X de olsun / X ekle" → iller.ekle; "X olmasın" → iller.cikar.
- "sadece devlet / vakıf olmasın" → universiteTipi "devlet"; "sadece vakıf" → "vakif"; "fark etmez" → "farketmez".
- "sadece devlet / vakıf olmasın" → universiteTipi "devlet"; "sadece vakıf" → "vakif"; "fark etmez" → "farketmez". Tip kısıtı aksi açıkça söylenmedikçe TÜM LİSTEYE uygulanır: üst düzey universiteTipi'ne yaz, ilkTercihler.universiteTipi'ne DEĞİL ("ilk 10 İstanbul olsun, sadece devlet" → ilkTercihler {adet: 10, iller: [İstanbul]} + universiteTipi "devlet"). ilkTercihler.universiteTipi yalnız aday "ilk N'i devlet olsun, gerisi fark etmez" gibi açıkça ilk N ile sınırlarsa.
- "daha güvenli / garantili olsun" → iskelet "guvenli-agirlikli"; "daha iddialı / hayal ağırlıklı" → "hayal-agirlikli"; "standart kalsın" → "standart".
- "X üniversitesi / Y bölümü olmasın, çıkar" → cikar.universiteler / cikar.bolumler (ad parçası yeter).
- Program ekleme program kimliği ister; kimlik bilmiyorsan ekle verme, eksik'e "program kimliği" yaz.
- "not": adayın isteğinin bir cümlelik özeti (Türkçe).
- Kurala YALNIZ adayın bu mesajda istediği değişiklikleri yaz; istemediği alanı verme. "farketmez" ve "standart" yalnız aday açıkça "fark etmez / standart kalsın" derse yazılır; uygulanmış kurallar zaten geçerlidir, onları tekrar yazma ya da sıfırlama.
- Bir mesajda birden çok istek varsa HEPSİNİ kurala yaz ("ilk 10 İstanbul olsun, vakıf olmasın" → ilkTercihler + universiteTipi "devlet"). "ilk N tercih X'ten" yalnız X demek DEĞİLDİR: iller.yalniz yazma, gerekirse iller.ekle.
- Kurala YALNIZ adayın bu mesajda istediği değişiklikleri yaz; istemediği alanı verme. "farketmez" ve "standart" yalnız aday açıkça "fark etmez / standart kalsın" derse yazılır; uygulanmış kurallar zaten geçerlidir, onları tekrar yazma ya da sıfırlama. Aday ne değişeceğini söylemediyse kural UYDURMA (ör. "universiteTipi: farketmez" ile boş kural) — kural null, form dolu.
İl adlarını Türkçe ve tam yaz (İstanbul, İzmir, Kahramanmaraş). İlgi alanı adları TAM OLARAK şunlardan: ${KATEGORILER.join(" | ")}.
Form cevabı geldiğinde cevapları forma göre kurala çevir; "standart kalsın", "fark etmez", "yok" gibi cevaplar kural üretmez (o alanı verme); hiç kural çıkmıyorsa kural null, eksik ["istek"].
@@ -125,9 +126,19 @@ export async function revizyonNiyetiCikar(opts: {
params: RaporParams;
/** Mesaj "Form cevaplarım:" ise cevaplanan form (sohbet-meta.anketModelMetni ile dökülür). */
oncekiForm?: Anket | null;
/** Form cevabının yapısal hâli (route anketCevabiDogrula'dan); varsayılan formda model çağrılmadan kurala çevrilir. */
anketCevabi?: AnketCevaplari | null;
gozlem?: (g: CagriGozlemi) => void;
}): Promise<NiyetSonucu> {
const p = opts.params;
// Varsayılan formun cevabı modelsiz okunur (10 Eki 2026): seçenek değerleri
// bizim, karşılıkları belli. Serbest metin il adıysa o da kodla çözülür;
// çözülemeyen cevap (bölüm/üniversite adı gibi) modele düşer.
if (opts.oncekiForm && opts.anketCevabi && varsayilanFormMu(opts.oncekiForm)) {
const kural = varsayilanFormKurali(opts.anketCevabi);
if (kural) return { niyet: "revizyon", kural };
}
const durum = [
`Başarı sırası ${p.sira.toLocaleString("tr-TR")}, puan türü ${PUAN_TURLERI[p.tur]}.`,
`Sihirbaz illeri: ${p.iller?.length ? p.iller.join(", ") : p.il ? p.il : "(seçilmedi — tüm iller)"}.`,
@@ -161,15 +172,24 @@ export async function revizyonNiyetiCikar(opts: {
} catch (err) {
const neden = err instanceof Error ? err.message : String(err);
console.warn("[revizyon-niyeti] çıkarım başarısız:", neden.slice(0, 120));
return { niyet: "belirsiz", neden: "çağrı başarısız" };
return cikarimsiz(opts, "çağrı başarısız");
}
if (!ham) return { niyet: "belirsiz", neden: "şema dışı çıktı" };
if (!ham) return cikarimsiz(opts, "şema dışı çıktı");
if (ham.niyet !== "revizyon") return { niyet: ham.niyet };
if (ham.kural && typeof ham.kural === "object") {
const k = revizyonKuraliKur(ham.kural);
if (k.ok) return { niyet: "revizyon", kural: k.kural };
console.info("[revizyon-niyeti] kural geçersiz, forma düşülüyor:", k.hata.slice(0, 120));
if (k.ok) {
// Etkisiz kural (10 Eki 2026 eval r12: "listemi yeniden oluştur" →
// model `universiteTipi: farketmez` uydurdu, liste "değişmeden" yeniden
// kuruldu): zaten geçerli olanı tekrar eden alanlar atılır; geriye bir
// şey kalmıyorsa istek belirsizdir, forma düşülür.
const etkili = etkisizAlanlariAt(k.kural, p);
if (etkili) return { niyet: "revizyon", kural: tipKuraliniTamamla(opts.mesaj, etkili) };
console.info("[revizyon-niyeti] kural etkisiz (uygulanmışı tekrarlıyor), forma düşülüyor");
} else {
console.info("[revizyon-niyeti] kural geçersiz, forma düşülüyor:", k.hata.slice(0, 120));
}
}
const eksik = (ham.eksik ?? []).map((e) => e.replace(/\s+/g, " ").trim().slice(0, 60)).filter(Boolean).slice(0, 5);
if (ham.form && typeof ham.form === "object") {
@@ -180,6 +200,118 @@ export async function revizyonNiyetiCikar(opts: {
return { niyet: "revizyon", form: varsayilanForm(p), eksik };
}
/**
* Çıkarım yapılamadı (zaman aşımı / sağlayıcı hatası / şema dışı). Düz
* mesajda regex zaten "liste değişsin" dedi: aday "okuyamadım" yerine
* varsayılan formu görür, cevabı modelsiz kurala çevrilir (10 Eki 2026 eval
* r09#2: 20 sn zaman aşımı → "okuyamadım" + iade; QA B3 bimodal gecikme).
* Form cevabında ikinci bir form açılmaz; route "belirsiz"i iade ile karşılar.
*/
function cikarimsiz(opts: { oncekiForm?: Anket | null; params: RaporParams }, neden: string): NiyetSonucu {
if (opts.oncekiForm) return { niyet: "belirsiz", neden };
return { niyet: "revizyon", form: varsayilanForm(opts.params), eksik: ["istek"] };
}
/**
* Uygulanmış kuralları ya da sihirbaz seçimini tekrar eden alanları atar
* (`universiteTipi: farketmez` zaten fark etmezken, `iskelet: standart`
* zaten standartken). Geriye kural kalmazsa null.
*/
function etkisizAlanlariAt(kural: RevizyonKurali, p: RaporParams): RevizyonKurali | null {
const sonuc: RevizyonKurali = { ...kural };
const mevcutTip = p.revizyon?.universiteTipi ?? p.universiteTipi ?? "farketmez";
if (sonuc.universiteTipi === "farketmez" && mevcutTip === "farketmez") delete sonuc.universiteTipi;
const mevcutIskelet = p.revizyon?.iskelet ?? "standart";
if (sonuc.iskelet === "standart" && mevcutIskelet === "standart") delete sonuc.iskelet;
const { not: _not, ...kalan } = sonuc;
void _not;
return Object.keys(kalan).length ? sonuc : null;
}
/**
* Mesajda açıkça yazan üniversite tipi isteği kuralda yoksa kod ekler
* (10 Eki 2026 eval r14: "ilk 10 İstanbul olsun, vakıf olmasın" → model 4/4
* koşuda universiteTipi'ni düşürdü; "not"a yazdı, kurala yazmadı). Yalnız
* tek anlamlı kalıplar; mesajda kalıp yoksa ("ilk N'i devlet yap" gibi
* kısmi istek) modelin yazdığı ilkTercihler.universiteTipi olduğu gibi kalır.
*/
const DEVLET_KALIBI = /vak[ıi]f(?:lar)?(?:\p{L}*)?\s+(?:olmasın|çıksın|çıkar|istemiyorum|kalmasın)|sadece devlet|yaln[ıi]z(?:ca)? devlet|devlet olsun/u;
const VAKIF_KALIBI = /sadece vak[ıi]f|yaln[ıi]z(?:ca)? vak[ıi]f|devlet(?:ler)?\s+(?:olmasın|çıksın|çıkar)/u;
function tipKuraliniTamamla(mesaj: string, kural: RevizyonKurali): RevizyonKurali {
if (kural.universiteTipi) return kural;
// "vakıf olmasın" gibi ayrı bir cümle listenin TAMAMI içindir; model tipi
// yalnız ilkTercihler içine yazdıysa (gemini-3.1-flash-lite, 10 Eki 2026,
// r14 4/4) üst düzeye de taşınır. ilkTercihler'deki kopya zararsızdır
// (zaten aynı tipe kısıtlı havuzdan seçilir).
const m = mesaj.toLocaleLowerCase("tr-TR");
if (DEVLET_KALIBI.test(m)) return { ...kural, universiteTipi: "devlet" };
if (VAKIF_KALIBI.test(m)) return { ...kural, universiteTipi: "vakif" };
return kural;
}
const VARSAYILAN_FORM_BASLIK = "Listeni nasıl değiştirelim?";
const VARSAYILAN_DEGERLER = new Set(["il-ekle", "devlet", "vakif-olmasin", "guvenli", "iddiali"]);
/** Cevaplanan form bizim varsayılan formumuz mu (soru adları + başlık). */
export function varsayilanFormMu(anket: Pick<Anket, "baslik" | "sorular">): boolean {
return (
anket.baslik === VARSAYILAN_FORM_BASLIK &&
anket.sorular.length === 2 &&
anket.sorular[0]?.ad === "degisiklik" &&
anket.sorular[1]?.ad === "adet"
);
}
/**
* Varsayılan formun cevabını modelsiz kurala çevirir. Serbest metin yalnız
* bilinen bir il adıysa çözülür (revizyonKuraliKur il doğrulaması); başka
* serbest metin, çelişen seçim (güvenli + iddialı) ya da il'siz "başka il"
* cevabı null döner → model yolu. Adet 24'ten küçükse il/tip ölçütü yalnız
* "ilk N"e uygulanır; iskelet her zaman listenin tamamınadır.
*/
export function varsayilanFormKurali(cevaplar: AnketCevaplari): RevizyonKurali | null {
const ham = cevaplar.degisiklik;
const secimler = (Array.isArray(ham) ? ham : ham ? [ham] : []).map((s) => s.trim()).filter(Boolean);
if (!secimler.length) return null;
const serbest = secimler.filter((s) => !VARSAYILAN_DEGERLER.has(s));
if (serbest.length > 1) return null;
const secili = new Set(secimler.filter((s) => VARSAYILAN_DEGERLER.has(s)));
if (secili.has("guvenli") && secili.has("iddiali")) return null;
let il: string | undefined;
if (serbest.length === 1) {
const dene = revizyonKuraliKur({ iller: { ekle: [serbest[0]] } });
if (!dene.ok || !dene.kural.iller?.ekle?.[0]) return null;
il = dene.kural.iller.ekle[0];
}
if (secili.has("il-ekle") && !il) return null;
const adetHam = Array.isArray(cevaplar.adet) ? cevaplar.adet[0] : cevaplar.adet;
const adet = adetHam ? Math.round(Number(adetHam)) : Number.NaN;
const kismi = Number.isFinite(adet) && adet >= 1 && adet < 24;
const tip = secili.has("devlet") || secili.has("vakif-olmasin") ? ("devlet" as const) : undefined;
const arg: Record<string, unknown> = {};
if (secili.has("guvenli")) arg.iskelet = "guvenli-agirlikli";
if (secili.has("iddiali")) arg.iskelet = "hayal-agirlikli";
if (kismi && (il || tip)) {
arg.ilkTercihler = { adet, ...(il ? { iller: [il] } : {}), ...(tip ? { universiteTipi: tip } : {}) };
if (il) arg.iller = { ekle: [il] };
} else {
if (il) arg.iller = { ekle: [il] };
if (tip) arg.universiteTipi = tip;
}
const ozet = [
il ? `${il} de olsun` : null,
tip ? "vakıf olmasın" : null,
secili.has("guvenli") ? "daha güvenli" : secili.has("iddiali") ? "daha iddialı" : null,
kismi ? `ilk ${adet} tercih` : null,
].filter(Boolean);
arg.not = `Form cevabı: ${ozet.join(", ")}.`;
const k = revizyonKuraliKur(arg);
return k.ok ? k.kural : null;
}
/**
* Modelin formu gelmez ya da şemaya uymazsa açılan deterministik form:
* ne değişsin (çoklu + serbest) ve kaç tercih etkilensin.
@@ -187,7 +319,7 @@ export async function revizyonNiyetiCikar(opts: {
export function varsayilanForm(p: RaporParams): Omit<Anket, "id"> {
const iller = p.iller?.length ? p.iller : p.il ? [p.il] : [];
return {
baslik: "Listeni nasıl değiştirelim?",
baslik: VARSAYILAN_FORM_BASLIK,
sorular: [
{
ad: "degisiklik",

View File

@@ -64,6 +64,11 @@ const REVIZYON_NIYETI: RegExp[] = [
/(?<!\p{L})(?:olsun|olmasın|kalsın|kalmasın)(?!\p{L})/u,
// "listeye ekle", "X'i çıkar / kaldır"
/(?<!\p{L})(?:ekle|çıkar|kaldır)(?:yelim|elim|alım|ın|in|sene|sana)?(?!\p{L})/u,
// Listeyi BAŞTAN isteyen kalıplar (10 Eki 2026, Bilal: paketli hesapta
// "liste oluşturma" isteği koşmadı): "listemi yeniden oluştur", "yeni liste
// yap/hazırla", "listeyi baştan/sıfırdan kur", "listemi tekrar kur". Yalnız
// "liste" sözcüğüyle birlikte; "oluştur/kur/yap" tek başına çok genel.
/list\p{L}*.*(?<!\p{L})(?:oluştur|hazırla|baştan|sıfırdan|yeniden|tekrar|kur)\p{L}*|(?<!\p{L})(?:oluştur|hazırla|baştan|sıfırdan|yeniden|tekrar)\p{L}*.*list\p{L}*|yeni (?:bir )?liste/u,
];
/**

View File

@@ -12,15 +12,22 @@ export const YAY = {
donusum: { type: "spring", visualDuration: 0.42, bounce: 0 },
/** Parmak bıraktıktan sonraki yerine oturma. Arc "snappy": tek yerde hafif sekme. */
birakma: { type: "spring", visualDuration: 0.26, bounce: 0.12 },
/** Adım paneli kayması ve boyu (sihirbaz, soru formu, profil kapısı); `adim-gecisi.ts`. */
adim: { type: "spring", stiffness: 300, damping: 30 },
} as const;
type Egri = [number, number, number, number];
export const EGRI: { giris: Egri; standart: Egri } = {
// CSS karşılıkları globals.css'te `--egri-*` (aynı sayılar; biri değişirse öbürü de).
export const EGRI: { giris: Egri; standart: Egri; gucluCikis: Egri; yerlesme: Egri } = {
/** Ekrana giren öğe: hızlı başlar, yumuşak durur. */
giris: [0.16, 1, 0.3, 1],
/** Ekrandayken değişen renk/opaklık. */
standart: [0.22, 1, 0.36, 1],
/** Sahne açılışı ve piksel nabzı: belirgin fırlayıp uzun süzülür (açılış sahnesi, dolum). */
gucluCikis: [0.23, 1, 0.32, 1],
/** Yerine oturan/kapanan öğe: geç hızlanır, sert durmaz (logo kabının kapanışı). */
yerlesme: [0.32, 0.72, 0, 1],
};
export const SURE = { anlik: 0.12, hizli: 0.16, cikis: 0.18, standart: 0.24 } as const;

18
src/lib/logo-tepkisi.ts Normal file
View File

@@ -0,0 +1,18 @@
// Logoya tepki (DESIGN.md §4.34): kullanıcının kendi yaptığı bir eylem
// header'daki logoyu kısa bir an başka bir biçime dönüştürür. Bileşenler
// logoya ref ile uzanmaz; buradan pencereye olay atılır, logo dinler
// (animasyonlu-mark.tsx). Tema değişimi ayrı kapıdan gelir (lib/tema-gecisi.ts),
// çünkü önce sayfa geçişinin bitmesini bekler.
//
// - "sira": sıralama girildi/değişti → kürsü
// - "ekle": listeye program eklendi → deste
// - "tamam": liste 24'e doldu → üç kare tek sıra
export type LogoTepkisi = "sira" | "ekle" | "tamam";
export const LOGO_TEPKI_OLAYI = "kt-logo-tepki";
export function logoTepkisi(tur: LogoTepkisi) {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<LogoTepkisi>(LOGO_TEPKI_OLAYI, { detail: tur }));
}

100
src/lib/tema-gecisi.ts Normal file
View File

@@ -0,0 +1,100 @@
// 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 yumuşak bir halka
// gibi açılır — kenarı keskin değil, geniş ve tüylü; ışık yayılır gibi.
// Geçiş kısa ve hafiftir; BİTİNCE logonun kareleri yeni temanın biçimine
// dönüşür (koyuya: ay ve yıldız; açığa: güneş), kısa bir an durur ve
// yeniden logoya toplanır.
// 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ü
// 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 <html>'de duran sınıf (globals.css). */
const GECIS_SINIFI = "kt-tema-gecis";
/** Geçiş bitince pencereye atılan olay; header'daki logo bunu dinleyip
* karelerini güneşe ya da aya dönüştürü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;
/** Sayfa geçişi ile logo dönüşümü arasındaki kısa nefes (ms). */
const LOGO_NEFESI = 80;
let suruyor = false;
/**
* `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ü
* ile tema durumu birbirine girmesin.
*/
export function temayiGecisle(uygula: () => void) {
if (typeof document === "undefined") {
uygula();
return;
}
const azHareket = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (azHareket) {
uygula();
return;
}
if (!document.startViewTransition) {
// Sayfa geçişi yok ama logo yine dönüşür; temanın sınıfı <html>'e
// yazılsın diye kısa bir an beklenir.
uygula();
window.setTimeout(
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
LOGO_NEFESI,
);
return;
}
if (suruyor) return;
const kok = document.documentElement;
// 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 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),
);
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;
const gecis = document.startViewTransition(uygula);
// 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.finished
.catch(() => {})
.finally(() => {
kok.classList.remove(GECIS_SINIFI);
kok.style.removeProperty("--kt-tema-x");
kok.style.removeProperty("--kt-tema-y");
kok.style.removeProperty("--kt-tema-son");
suruyor = false;
// Sıra: önce sayfa geçişi biter, sonra logo dönüşür — ikisi üst üste
// binmez, göz önce temaya sonra logoya gider. Kısa nefes (LOGO_NEFESI):
// geçiş iptal olduysa (sekme gizli vb.) `finished` hemen döner ve
// temanın sınıfı <html>'e henüz yazılmamış olabilir; logo yanlış
// biçime dönüşmesin.
window.setTimeout(
() => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)),
LOGO_NEFESI,
);
});
}