diff --git a/src/components/ui/acilis-sahnesi.tsx b/src/components/ui/acilis-sahnesi.tsx
new file mode 100644
index 0000000..34e91c5
--- /dev/null
+++ b/src/components/ui/acilis-sahnesi.tsx
@@ -0,0 +1,308 @@
+"use client";
+
+// Alt sayfa açılış sahnesi (DESIGN.md §4.19 açılış koreografisi, §6 hareket):
+// renkli kareler önce ortada logonun üç karesi gibi belirir, sonra içerik
+// gelir. İki mod:
+// - Kartlı (akademik-ayrinti.tsx, gösterge kareleri §4.11): `[data-kare]`
+// kareler logodan kendi yerine ve boyuna büyür, içleri satır satır
+// bulanıklıktan netleşir, ardından `[data-sonra]` bloklar.
+// - Kartsız (program-ayrinti.tsx, §4.19 "kart yok"): `[data-kare]` yok,
+// `[data-logo]` kabındaki üç `[data-logo-kare]` belirir, kısa durur, sonra
+// sönerek kaybolur (scale 0.6 + opacity 0; Bilal, 7 Eki 2026) ve
+// `[data-sonra]` bloklar netleşerek gelir.
+// "Hareketi azalt"ta logo ve büyüme yok, yalnız 0,25 s solma (§6).
+
+import { useLayoutEffect, useRef, type ReactNode } from "react";
+import { animate, useReducedMotion } from "motion/react";
+
+// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda
+// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı.
+// Marka rengi; iki temada da sabit kalır.
+const LOGO = {
+ sol: { x: 27, y: 56, boyut: 57, renk: "#3b82f6" },
+ "sag-ust": { x: 91, y: 29, boyut: 56, renk: "#fdc7a7" },
+ "sag-alt": { x: 91, y: 95, boyut: 56, renk: "#ff5a00" },
+} as const;
+const LOGO_RX = 13;
+const SLOT_SIRASI = ["sol", "sag-ust", "sag-alt"] as const;
+type Slot = (typeof SLOT_SIRASI)[number];
+
+// Saniye. Kare: belir (logo) → bekle → yerine büyü; içerik kare oturunca.
+const BASLANGIC = 0.1;
+const SLOT_ADIMI = 0.08;
+const KARE_SURE = 1.15;
+const KARE_ZAMANLARI = [0, 0.24, 0.52, 1];
+const ICERIK_BASI = 0.95;
+const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const;
+const YERLESME = [0.32, 0.72, 0, 1] as const;
+
+// Kartsız mod: logo karesi belir → dur → sön. Belirme ve duruş kartlı
+// modla aynı anlara denk gelir (KARE_SURE·0.24, ·0.52); sönme 0,4 s.
+const LOGO_SURE = KARE_SURE * KARE_ZAMANLARI[2] + 0.4;
+const LOGO_ZAMANLARI = [
+ 0,
+ (KARE_SURE * KARE_ZAMANLARI[1]) / LOGO_SURE,
+ (KARE_SURE * KARE_ZAMANLARI[2]) / LOGO_SURE,
+ 1,
+];
+
+/** Kartsız modun logo kareleri, belirme sırasıyla (`data-logo-kare` değerleri). */
+export const LOGO_SLOTLARI = SLOT_SIRASI;
+
+/**
+ * Kartsız modun logo karesi stili: logodaki yer ve renk, kabın boyuna ölçekli
+ * (`data-logo` kabı `relative mx-auto h-16 w-16` → 64/180). Kullanım:
+ * ``.
+ * Kareler yalnız sahne sırasında görünür; sahne bitince kap sıfır boya iner,
+ * "hareketi azalt"ta hiç gösterilmez.
+ */
+export function logoKareStili(slot: Slot, kapBoyu = 64) {
+ const olcek = kapBoyu / 180;
+ return {
+ left: LOGO[slot].x * olcek,
+ top: LOGO[slot].y * olcek,
+ width: LOGO[slot].boyut * olcek,
+ height: LOGO[slot].boyut * olcek,
+ borderRadius: LOGO_RX * olcek,
+ backgroundColor: LOGO[slot].renk,
+ };
+}
+
+/**
+ * Çekmece açılınca oynayan sahne.
+ *
+ * Sunucu işaretlemesiyle sözleşme (universite-icerik.tsx, AkademikGostergeler):
+ * `data-sahne` kareleri saran `relative` kap, `data-kare="sol|sag-ust|sag-alt"`
+ * karenin logodaki yeri (aynı yeri paylaşan kareler üst üste başlar, sonra
+ * ayrışır), `data-sonra` karelerden sonra gelen bloklar. Kartsız sözleşme:
+ * `data-logo` kabı (üç `data-logo-kare`, logoKareStili) + `data-sonra` bloklar.
+ *
+ * Kareler yalnız transform ile oynar (yerleşim ölçülür, bozulmaz); içerik o
+ * sırada görünmez olduğu için kare ölçeklenirken yazı ezilmez.
+ *
+ * `hazir`: false iken sahne oynamaz (iskelet görünür kalır), true olunca
+ * oynar — veri bekleyen çekmece için. Varsayılan true (içerik sunucudan
+ * hazır gelen çekmece).
+ */
+export function AcilisSahnesi({
+ children,
+ hazir = true,
+ className = "mt-5",
+}: {
+ children: ReactNode;
+ hazir?: boolean;
+ className?: string;
+}) {
+ const kapRef = useRef(null);
+ const azHareket = useReducedMotion();
+
+ useLayoutEffect(() => {
+ const kap = kapRef.current;
+ // hazir false → true tek geçiş: sahne o geçişte bir kez oynar (ref
+ // tutulmaz; Strict Mode'un çift effect'i temizlik sonrası yeniden kurar).
+ if (!kap || !hazir) return;
+ const sahne = kap.querySelector("[data-sahne]");
+ const logo = kap.querySelector("[data-logo]");
+ const kareler = Array.from(
+ kap.querySelectorAll("[data-kare]"),
+ );
+ const logoKareleri = Array.from(
+ kap.querySelectorAll("[data-logo-kare]"),
+ );
+ const sonra = Array.from(kap.querySelectorAll("[data-sonra]"));
+ const kontroller: { stop: () => void }[] = [];
+ const sayaclar: number[] = [];
+ const dokunulan: HTMLElement[] = [];
+
+ const kartli = !!sahne && kareler.length > 0;
+ const kartsiz = !kartli && !!logo && logoKareleri.length > 0;
+
+ if (azHareket || (!kartli && !kartsiz)) {
+ // Logo kabı sahnesiz anlamsız: yer kaplamasın.
+ if (logo) {
+ logo.style.display = "none";
+ dokunulan.push(logo);
+ }
+ kontroller.push(
+ animate(kap, { opacity: [0, 1] }, { duration: 0.25, ease: "easeOut" }),
+ );
+ return () => {
+ kontroller.forEach((k) => k.stop());
+ for (const el of dokunulan) el.style.display = "";
+ };
+ }
+
+ // Bulanıklıktan netleşerek, hafifçe aşağıdan gelen satır.
+ const netlestir = (el: HTMLElement, gecikme: number) => {
+ el.style.opacity = "0";
+ dokunulan.push(el);
+ const k = animate(
+ el,
+ {
+ opacity: [0, 1],
+ filter: ["blur(10px)", "blur(0px)"],
+ y: [10, 0],
+ },
+ { duration: 0.5, delay: gecikme, ease: GUCLU_CIKIS },
+ );
+ k.then(() => {
+ el.style.filter = "";
+ });
+ kontroller.push(k);
+ };
+
+ const temizle = () => {
+ kontroller.forEach((k) => k.stop());
+ sayaclar.forEach((s) => window.clearTimeout(s));
+ for (const el of dokunulan) {
+ el.style.opacity = "";
+ el.style.filter = "";
+ el.style.transform = "";
+ el.style.transition = "";
+ el.style.backgroundColor = "";
+ el.style.borderRadius = "";
+ el.style.height = "";
+ el.style.display = "";
+ }
+ };
+
+ if (kartsiz) {
+ // Kartsız: logo kareleri belirir, durur, söner; kap sönerken sıfır boya
+ // iner ki altında boşluk kalmasın; içerik ICERIK_BASI'ndan itibaren gelir.
+ let sonKare = 0;
+ logoKareleri.forEach((el) => {
+ const slot = (el.dataset.logoKare ?? "sol") as Slot;
+ const gecikme = BASLANGIC + SLOT_SIRASI.indexOf(slot) * SLOT_ADIMI;
+ sonKare = Math.max(sonKare, gecikme);
+ el.style.opacity = "0";
+ dokunulan.push(el);
+ kontroller.push(
+ animate(
+ el,
+ {
+ opacity: [0, 1, 1, 0],
+ scale: [0.4, 1, 1, 0.6],
+ },
+ {
+ duration: LOGO_SURE,
+ delay: gecikme,
+ times: LOGO_ZAMANLARI,
+ ease: [GUCLU_CIKIS, "linear", YERLESME],
+ },
+ ),
+ );
+ });
+ const sonmeBasi = sonKare + LOGO_SURE * LOGO_ZAMANLARI[2];
+ dokunulan.push(logo!);
+ const kapat = animate(
+ logo!,
+ { height: [logo!.offsetHeight, 0] },
+ { duration: 0.4, delay: sonmeBasi, ease: YERLESME },
+ );
+ kapat.then(() => {
+ logo!.style.display = "none";
+ });
+ kontroller.push(kapat);
+
+ sonra.forEach((el, i) => netlestir(el, ICERIK_BASI + i * 0.08));
+ return temizle;
+ }
+
+ // Kartlı: logo, kare alanının ortasında; 180'lik viewBox'ın ekrandaki boyu.
+ const W = sahne!.offsetWidth;
+ const H = sahne!.offsetHeight;
+ const logoBoy = Math.min(W * 0.5, 200);
+ const olcek = logoBoy / 180;
+ const logoX = (W - logoBoy) / 2;
+ const logoY = (H - logoBoy) / 2;
+
+ let sonKare = 0;
+ kareler.forEach((el, i) => {
+ const slot = LOGO[el.dataset.kare as keyof typeof LOGO]
+ ? el.dataset.kare!
+ : "sol";
+ const logoKare = LOGO[slot as keyof typeof LOGO];
+ // offset*: transform'dan etkilenmez (çekmece o sırada kayıyor).
+ let sol = 0;
+ let ust = 0;
+ for (
+ let d: HTMLElement | null = el;
+ d && d !== sahne;
+ d = d.offsetParent as HTMLElement | null
+ ) {
+ sol += d.offsetLeft;
+ ust += d.offsetTop;
+ }
+ const w = el.offsetWidth;
+ const h = el.offsetHeight;
+ const boy = logoKare.boyut * olcek;
+ const sx = boy / w;
+ const sy = boy / h;
+ const dx = logoX + logoKare.x * olcek + boy / 2 - (sol + w / 2);
+ const dy = logoY + logoKare.y * olcek + boy / 2 - (ust + h / 2);
+ // Ölçek köşeyi de küçültür; logodaki köşe oranı için ters ölçekli elips.
+ const rx = (LOGO_RX * olcek) / sx;
+ const ry = (LOGO_RX * olcek) / sy;
+ const logoKose = `${rx}px / ${ry}px`;
+ const kose = getComputedStyle(el).borderTopLeftRadius;
+ const gecikme =
+ BASLANGIC +
+ SLOT_SIRASI.indexOf(slot as Slot) * SLOT_ADIMI;
+ sonKare = Math.max(sonKare, gecikme);
+
+ el.style.opacity = "0";
+ el.style.backgroundColor = logoKare.renk;
+ dokunulan.push(el);
+ const k = animate(
+ el,
+ {
+ opacity: [0, 1, 1, 1],
+ x: [dx, dx, dx, 0],
+ y: [dy, dy, dy, 0],
+ scaleX: [sx * 0.4, sx, sx, 1],
+ scaleY: [sy * 0.4, sy, sy, 1],
+ borderRadius: [logoKose, logoKose, logoKose, `${kose} / ${kose}`],
+ },
+ {
+ duration: KARE_SURE,
+ delay: gecikme,
+ times: KARE_ZAMANLARI,
+ ease: [GUCLU_CIKIS, "linear", YERLESME],
+ },
+ );
+ k.then(() => {
+ el.style.borderRadius = "";
+ });
+ kontroller.push(k);
+ // Yerine büyürken logo renginden karenin kendi rengine (sınıftaki renk).
+ sayaclar.push(
+ window.setTimeout(
+ () => {
+ el.style.transition = "background-color 450ms ease-out";
+ el.style.backgroundColor = "";
+ },
+ (gecikme + KARE_SURE * KARE_ZAMANLARI[2]) * 1000,
+ ),
+ );
+
+ Array.from(el.children).forEach((satir, j) =>
+ netlestir(
+ satir as HTMLElement,
+ gecikme + ICERIK_BASI + i * 0.06 + j * 0.05,
+ ),
+ );
+ });
+
+ sonra.forEach((el, i) =>
+ netlestir(el, sonKare + ICERIK_BASI + 0.3 + i * 0.08),
+ );
+
+ return temizle;
+ }, [azHareket, hazir]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/features/katalog/components/akademik-ayrinti.tsx b/src/features/katalog/components/akademik-ayrinti.tsx
index 1230dbf..1d0092f 100644
--- a/src/features/katalog/components/akademik-ayrinti.tsx
+++ b/src/features/katalog/components/akademik-ayrinti.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useLayoutEffect, useRef, type ReactNode } from "react";
-import { animate, useReducedMotion } from "motion/react";
+import type { ReactNode } from "react";
import { ChevronRight, GraduationCap, X } from "lucide-react";
import {
AltSayfa,
@@ -12,6 +11,7 @@ import {
AltSayfaBaslik,
AltSayfaTetik,
} from "@/components/ui/alt-sayfa";
+import { AcilisSahnesi } from "@/components/ui/acilis-sahnesi";
/**
* Üniversite ve bölüm sayfasında özet kartlarının altındaki ayrıntı satırı
@@ -21,7 +21,7 @@ import {
* önceden söyler. Ayrıntı alttan açılan çekmecede; çekmece ve kapatma
* çarpısı cta-sira-form.tsx'teki çekmeceyle birebir aynı sınıf setinde.
* Kartlar sunucuda üretilip `children` olarak gelir; açılış koreografisini
- * AcilisSahnesi oynatır.
+ * AcilisSahnesi (`src/components/ui/acilis-sahnesi.tsx`, kartlı mod) oynatır.
*/
export function AkademikAyrinti({
baglam,
@@ -82,189 +82,3 @@ export function AkademikAyrinti({
);
}
-
-// Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda
-// mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı.
-const LOGO = {
- sol: { x: 27, y: 56, boyut: 57, renk: "#3b82f6" },
- "sag-ust": { x: 91, y: 29, boyut: 56, renk: "#fdc7a7" },
- "sag-alt": { x: 91, y: 95, boyut: 56, renk: "#ff5a00" },
-} as const;
-const LOGO_RX = 13;
-const SLOT_SIRASI = ["sol", "sag-ust", "sag-alt"] as const;
-
-// Saniye. Kare: belir (logo) → bekle → yerine büyü; içerik kare oturunca.
-const BASLANGIC = 0.1;
-const SLOT_ADIMI = 0.08;
-const KARE_SURE = 1.15;
-const KARE_ZAMANLARI = [0, 0.24, 0.52, 1];
-const ICERIK_BASI = 0.95;
-const GUCLU_CIKIS = [0.23, 1, 0.32, 1] as const;
-const YERLESME = [0.32, 0.72, 0, 1] as const;
-
-/**
- * Çekmece açılınca oynayan sahne: renkli kareler önce ortada logonun üç
- * karesi gibi belirir, sonra kendi yerlerine ve boylarına büyür (renk logo
- * renginden kendi rengine döner), en son içleri satır satır bulanıklıktan
- * netleşerek dolar; ardından alttaki not, buton ve kaynaklar gelir.
- *
- * Sunucu işaretlemesiyle sözleşme (universite-icerik.tsx, AkademikGostergeler):
- * `data-sahne` kareleri saran `relative` kap, `data-kare="sol|sag-ust|sag-alt"`
- * karenin logodaki yeri (aynı yeri paylaşan kareler üst üste başlar, sonra
- * ayrışır), `data-sonra` karelerden sonra gelen bloklar.
- *
- * Kareler yalnız transform ile oynar (yerleşim ölçülür, bozulmaz); içerik o
- * sırada görünmez olduğu için kare ölçeklenirken yazı ezilmez. "Hareketi
- * azalt"ta logo ve büyüme yok, yalnız kısa belirme (DESIGN.md §6).
- */
-function AcilisSahnesi({ children }: { children: ReactNode }) {
- const kapRef = useRef(null);
- const azHareket = useReducedMotion();
-
- useLayoutEffect(() => {
- const kap = kapRef.current;
- if (!kap) return;
- const sahne = kap.querySelector("[data-sahne]");
- const kareler = Array.from(
- kap.querySelectorAll("[data-kare]"),
- );
- const sonra = Array.from(kap.querySelectorAll("[data-sonra]"));
- const kontroller: { stop: () => void }[] = [];
- const sayaclar: number[] = [];
- const dokunulan: HTMLElement[] = [];
-
- if (azHareket || !sahne || kareler.length === 0) {
- kontroller.push(
- animate(kap, { opacity: [0, 1] }, { duration: 0.25, ease: "easeOut" }),
- );
- return () => kontroller.forEach((k) => k.stop());
- }
-
- // Bulanıklıktan netleşerek, hafifçe aşağıdan gelen satır.
- const netlestir = (el: HTMLElement, gecikme: number) => {
- el.style.opacity = "0";
- dokunulan.push(el);
- const k = animate(
- el,
- {
- opacity: [0, 1],
- filter: ["blur(10px)", "blur(0px)"],
- y: [10, 0],
- },
- { duration: 0.5, delay: gecikme, ease: GUCLU_CIKIS },
- );
- k.then(() => {
- el.style.filter = "";
- });
- kontroller.push(k);
- };
-
- // Logo, kare alanının ortasında; 180'lik viewBox'ın ekrandaki boyu.
- const W = sahne.offsetWidth;
- const H = sahne.offsetHeight;
- const logoBoy = Math.min(W * 0.5, 200);
- const olcek = logoBoy / 180;
- const logoX = (W - logoBoy) / 2;
- const logoY = (H - logoBoy) / 2;
-
- let sonKare = 0;
- kareler.forEach((el, i) => {
- const slot = LOGO[el.dataset.kare as keyof typeof LOGO]
- ? el.dataset.kare!
- : "sol";
- const logo = LOGO[slot as keyof typeof LOGO];
- // offset*: transform'dan etkilenmez (çekmece o sırada kayıyor).
- let sol = 0;
- let ust = 0;
- for (
- let d: HTMLElement | null = el;
- d && d !== sahne;
- d = d.offsetParent as HTMLElement | null
- ) {
- sol += d.offsetLeft;
- ust += d.offsetTop;
- }
- const w = el.offsetWidth;
- const h = el.offsetHeight;
- const boy = logo.boyut * olcek;
- const sx = boy / w;
- const sy = boy / h;
- const dx = logoX + logo.x * olcek + boy / 2 - (sol + w / 2);
- const dy = logoY + logo.y * olcek + boy / 2 - (ust + h / 2);
- // Ölçek köşeyi de küçültür; logodaki köşe oranı için ters ölçekli elips.
- const rx = (LOGO_RX * olcek) / sx;
- const ry = (LOGO_RX * olcek) / sy;
- const logoKose = `${rx}px / ${ry}px`;
- const kose = getComputedStyle(el).borderTopLeftRadius;
- const gecikme =
- BASLANGIC +
- SLOT_SIRASI.indexOf(slot as (typeof SLOT_SIRASI)[number]) * SLOT_ADIMI;
- sonKare = Math.max(sonKare, gecikme);
-
- el.style.opacity = "0";
- el.style.backgroundColor = logo.renk;
- dokunulan.push(el);
- const k = animate(
- el,
- {
- opacity: [0, 1, 1, 1],
- x: [dx, dx, dx, 0],
- y: [dy, dy, dy, 0],
- scaleX: [sx * 0.4, sx, sx, 1],
- scaleY: [sy * 0.4, sy, sy, 1],
- borderRadius: [logoKose, logoKose, logoKose, `${kose} / ${kose}`],
- },
- {
- duration: KARE_SURE,
- delay: gecikme,
- times: KARE_ZAMANLARI,
- ease: [GUCLU_CIKIS, "linear", YERLESME],
- },
- );
- k.then(() => {
- el.style.borderRadius = "";
- });
- kontroller.push(k);
- // Yerine büyürken logo renginden karenin kendi rengine (sınıftaki renk).
- sayaclar.push(
- window.setTimeout(
- () => {
- el.style.transition = "background-color 450ms ease-out";
- el.style.backgroundColor = "";
- },
- (gecikme + KARE_SURE * KARE_ZAMANLARI[2]) * 1000,
- ),
- );
-
- Array.from(el.children).forEach((satir, j) =>
- netlestir(
- satir as HTMLElement,
- gecikme + ICERIK_BASI + i * 0.06 + j * 0.05,
- ),
- );
- });
-
- sonra.forEach((el, i) =>
- netlestir(el, sonKare + ICERIK_BASI + 0.3 + i * 0.08),
- );
-
- return () => {
- kontroller.forEach((k) => k.stop());
- sayaclar.forEach((s) => window.clearTimeout(s));
- for (const el of dokunulan) {
- el.style.opacity = "";
- el.style.filter = "";
- el.style.transform = "";
- el.style.transition = "";
- el.style.backgroundColor = "";
- el.style.borderRadius = "";
- }
- };
- }, [azHareket]);
-
- return (
-
- {children}
-
- );
-}