From aec8a2ad7615ec8d08cdbb6c923abb70155bf4ec Mon Sep 17 00:00:00 2001 From: bilalgursen Date: Sun, 11 Oct 2026 00:34:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(v2):=209=E2=80=9310=20Ekim=20=C3=A7al?= =?UTF-8?q?=C4=B1=C5=9Fma=20a=C4=9Fac=C4=B1=20=E2=80=94=20deneyim=20dili?= =?UTF-8?q?=201.=20dalga,=20PWA=20manifest,=20revizyon=20niyet=20modeli,?= =?UTF-8?q?=20listem/tad=C4=B1ml=C4=B1k=20bile=C5=9Fen=20denetimi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .agents/skills/kolaytercih-tasarim/SKILL.md | 59 ++- .agents/skills/kolaytercih-tasarim/evals.md | 27 +- .claude/agents/tasarimci.md | 14 +- .claude/settings.json | 3 +- .gitea/workflows/ci.yaml | 8 + AGENTS.md | 18 + components.json | 3 +- docs/tasarim/DESIGN.md | 421 +++++++++++++++++- eslint.config.mjs | 2 + next.config.ts | 3 + public/pwa-ikon-maskable.svg | 9 + public/pwa-ikon.svg | 8 + scripts/eval/revizyon-arac.ts | 24 +- scripts/tasarim-denetim.ts | 29 ++ src/app/api/soru/route.ts | 5 +- src/app/globals.css | 299 +++++++++++++ src/app/layout.tsx | 10 +- src/app/manifest.ts | 26 ++ src/app/page.tsx | 237 ++++------ src/components/animasyonlu-mark.tsx | 301 ++++++++++++- src/components/mobil-menu.tsx | 304 +++++++++++++ src/components/piksel-morf.tsx | 145 ++++++ src/components/piksel-serit.tsx | 75 ++++ src/components/pixel-decor.tsx | 8 +- src/components/sayfa-gecisi.tsx | 22 + src/components/site-footer.tsx | 39 +- src/components/site-header.tsx | 2 +- src/components/tema-ikonu.tsx | 54 +++ src/components/tema-kisayolu.tsx | 38 ++ src/components/tema-saglayici.tsx | 5 +- src/components/tema-secici.tsx | 57 ++- src/components/ui/acilis-sahnesi.tsx | 89 +++- src/components/ui/adim-gostergesi.tsx | 177 ++++++++ src/components/ui/button.tsx | 5 + src/components/ui/dialog.tsx | 19 +- src/components/ui/secim-haplari.tsx | 3 +- src/components/use-pixel-heat.ts | 39 +- .../katalog/components/katalog-arama.tsx | 95 ++-- .../katalog/components/program-ayrinti.tsx | 27 +- .../kullanici/components/giris-linki.tsx | 8 +- .../kullanici/components/sign-out-button.tsx | 17 +- .../kullanici/components/user-nav.tsx | 25 +- .../liste/components/liste-cekmecesi-lazy.tsx | 6 +- .../liste/components/liste-durumu-satiri.tsx | 11 +- .../liste/components/listem-butonu.tsx | 43 +- .../liste/components/program-ekle-butonu.tsx | 36 +- .../liste/components/program-tablosu.tsx | 20 +- .../components/tercih-profili-kapisi.tsx | 49 +- .../liste/components/ucus-katmani.tsx | 111 +++++ .../pazarlama/components/adim-akisi.tsx | 277 ++++++++++++ .../pazarlama/components/bolum-gecisi.tsx | 61 +++ .../pazarlama/components/hero-baslik.tsx | 4 +- .../components/hero-focus-button.tsx | 6 +- .../components/landing-parcalari.tsx | 25 +- .../pazarlama/components/sayan-sayi.tsx | 61 +++ .../pazarlama/components/sorun-sahneleri.tsx | 328 ++++++++++++++ .../pazarlama/components/suzulen-haplar.tsx | 4 +- .../pazarlama/components/turkey-pixel-map.tsx | 14 +- .../pazarlama/components/veri-akisi.tsx | 36 +- .../rapor/components/liste-uretici.tsx | 23 +- .../rapor/components/listem-bos-cta.tsx | 7 +- .../rapor/components/listem-govde.tsx | 39 +- .../rapor/components/listem-icerik.tsx | 9 +- .../rapor/components/rapor-listesi.tsx | 19 +- .../rapor/components/sohbet-client.tsx | 145 ++++-- .../rapor/components/tadimlik-satiri.tsx | 254 +++++++---- .../components/tercih-degisti-modali.tsx | 36 +- .../sihirbaz/components/cta-sira-form.tsx | 3 + .../sihirbaz/components/hero-form.tsx | 26 +- .../components/sihirbaz-adimlar-lazy.tsx | 12 +- .../sihirbaz/components/sihirbaz-adimlar.tsx | 52 +-- .../components/sihirbaz-cagri-karti.tsx | 3 + src/lib/adim-gecisi.ts | 5 +- src/lib/ai/araclar.ts | 6 +- src/lib/ai/cagri.ts | 16 +- src/lib/ai/revizyon-niyeti.ts | 150 ++++++- src/lib/ai/sohbet-gecmisi.ts | 5 + src/lib/hareket.ts | 9 +- src/lib/logo-tepkisi.ts | 18 + src/lib/tema-gecisi.ts | 100 +++++ 80 files changed, 4134 insertions(+), 654 deletions(-) create mode 100644 public/pwa-ikon-maskable.svg create mode 100644 public/pwa-ikon.svg create mode 100644 src/app/manifest.ts create mode 100644 src/components/mobil-menu.tsx create mode 100644 src/components/piksel-morf.tsx create mode 100644 src/components/piksel-serit.tsx create mode 100644 src/components/sayfa-gecisi.tsx create mode 100644 src/components/tema-ikonu.tsx create mode 100644 src/components/tema-kisayolu.tsx create mode 100644 src/components/ui/adim-gostergesi.tsx create mode 100644 src/features/liste/components/ucus-katmani.tsx create mode 100644 src/features/pazarlama/components/adim-akisi.tsx create mode 100644 src/features/pazarlama/components/bolum-gecisi.tsx create mode 100644 src/features/pazarlama/components/sayan-sayi.tsx create mode 100644 src/features/pazarlama/components/sorun-sahneleri.tsx create mode 100644 src/lib/logo-tepkisi.ts create mode 100644 src/lib/tema-gecisi.ts diff --git a/.agents/skills/kolaytercih-tasarim/SKILL.md b/.agents/skills/kolaytercih-tasarim/SKILL.md index f2585c3..555ee3d 100644 --- a/.agents/skills/kolaytercih-tasarim/SKILL.md +++ b/.agents/skills/kolaytercih-tasarim/SKILL.md @@ -10,8 +10,8 @@ yön zaten belli. Kaynak tek: `docs/tasarim/DESIGN.md`. Bu dosya onu nasıl uygu ## 1. Başlamadan önce (zorunlu) -1. `docs/tasarim/DESIGN.md` dosyasını **tamamını** oku (477 satır). Özellikle §1.1 temel his, §4 tek kaynaklar, §7 yap/yapma ve §9.2 ölç-tahmin-etme. Canlı referans ekran: `/universite/ankara-universitesi`. -2. Dört satırlık brief'i doldur (DESIGN.md §9): amaç · referans ekran · tek kaynak parçalar · durumlar. +1. `docs/tasarim/DESIGN.md` dosyasını **tamamını** oku (uzun bir belge; atlama). Özellikle §1.1 temel his, §4 tek kaynaklar, §6 hareket, §7 yap/yapma, §9.2 ölç-tahmin-etme ve **§11 deneyim ilkeleri**. Canlı referans ekran: `/universite/ankara-universitesi`. +2. Beş satırlık brief'i doldur (DESIGN.md §9.1): amaç · referans ekran · tek kaynak parçalar · durumlar · his. Yanına bir satır ekle: **§11.1'deki hangi vaka bu işe en yakın ve hangi *nedeni* taşıyacağım?** 3. Referans ekranı bul ve aç; kopyalayacağın sınıf dizilerini oradan al: ```bash @@ -20,26 +20,54 @@ grep -rn "bg-orange-500" src --include='*.tsx' -l # turuncu CTA örnek grep -rn "PixelCorner\|PagePixelDivider" src -l --include='*.tsx' # piksel imzası taşıyan ekranlar ``` -Ana sayfa bölüm iskeleti: `src/app/page.tsx` (Problem bölümü, ~satır 268). Alt sayfa başlığı: `src/app/meraklisina/page.tsx`. +Ana sayfa bölüm iskeleti: `src/app/page.tsx` ("Sorun" bölümü; `grep -n "SectionEyebrow" src/app/page.tsx`). Alt sayfa başlığı: `src/app/meraklisina/page.tsx`. ## 2. Yazarken - Kanonik parçaları içe aktar, yeniden çizme: `SectionEyebrow`, `PagePixelDivider`, `PixelCorner` - (`@/components/pixel-decor`); `Button` (`@/components/ui/button`); `Card` (`@/components/ui/card`); - `DialogContent` varsayılan çarpısı (`@/components/ui/dialog`); `ArrowRight`, `XIcon` (`lucide-react`). + (`@/components/pixel-decor`); `Button` (`@/components/ui/button`; turuncu CTA `variant="cta"`, + ikincil `variant="ikincil"`); `Card` (`@/components/ui/card`); `AdimGostergesi` + (`@/components/ui/adim-gostergesi`); `AltSayfa` (`@/components/ui/alt-sayfa`); `SayiRulo` + (`@/components/ui/sayi-rulo`); `DialogContent` varsayılan çarpısı (`@/components/ui/dialog`); + `ArrowRight`, `XIcon` (`lucide-react`). - Sınıf dizilerini DESIGN.md'den **birebir** kopyala; "yaklaşık aynı" yazma. - Her piksel süsüne o sayfaya özgü tek `seed` ver (mevcut seed'ler: `grep -rho "seed={[0-9]*}" src | sort -u`). - Risk rengi kullanıyorsan metin etiketini `RISK_ETIKET` / `DILIM_ETIKET`'ten (`src/lib/risk.ts`) al. - Türkçe tanımlayıcı, çevredeki kodla aynı yorum yoğunluğu (AGENTS.md). +## 2.1 Hareket ve deneyim (DESIGN.md §6, §11) + +Site "web sitesi değil web uygulaması" gibi hissettirir. Yazdığın her parça için: + +- **Her eylemin cevabı var.** Basınca `active:scale-[0.97]`; sonucu olan eylem sonucunu gösterir + (sayı `SayiRulo` ile döner, seçim vurgusu `layoutId` ile kayar, tik `pathLength` ile çizilir). + Koşullu render ile anında yer değiştiren içerik son çaredir. +- **Durumu yansıt.** Sıra (`useTercihProfili`), liste doluluğu (`liste-store`), giriş: ekranın + bunlardan haberi olmalı mı? Olmalıysa göster. +- **Yay/eğri/süre uydurma.** Yalnız `src/lib/hareket.ts`: `YAY.varsayilan | akici | donusum | + birakma | adim`, `EGRI.giris | standart | gucluCikis | yerlesme`, `SURE.*`. Sekme yalnız bir + şey fırlıyorsa (`YAY.birakma`). +- **Sahne tek.** Sıralı, sahnelenmiş belirme sayfanın ilk ekranında bir kez; alt bölümler `Reveal`. + Yüzey açılıyorsa önce imza sonra içerik (`AcilisSahnesi`). +- **Sade karşılığı yaz.** `useReducedMotion` (değişken adı `azalt`) ya da `motion-reduce:`; + hareketi azalt'ta yalnız opaklık. +- **Telefonda gizleme.** Hover'a bağlı her cevabın dokunmatik karşılığı olsun. +- **Dış kaynaktan (Arc, React Bits, Skiper, Motion Primitives) alıyorsan** §11.3'ün yedi adımı: + kod bizim dosyamızda + kaynak/lisans yorumu · yeni bağımlılık yok (`motion` ile yeniden yaz) · + renk token'a · hareket `hareket.ts`'e · biçim bize (hap, `font-heading`, ≥44 px, gölge yok) · + sade karşılık + klavye/aria · lisans notu. + ## 3. Bitirmeden önce ```bash pnpm tasarim:denetim # sıfır hata olmadan iş bitmiş sayılmaz ``` -Uyarıları raporda listele; hataları düzelt. Sonra 375 px kontrol notu yaz: taşma, üst üste binme, -dokunma hedefi < 44 px, uzun üniversite adı. (Tarayıcı açma kuralı AGENTS.md'de; koddan çıkarım yazıyorsan "koddan çıkarım" de.) +Uyarıları raporda listele; hataları düzelt. **Yeni bir parça tasarladıysan ya da bir bölümü +yeniden düzenlediysen ekrana bak** (AGENTS.md "Tarayıcı açma": 375 px + masaüstü ekran görüntüsü; +önce Bilal'in açık sunucusunu ara). Küçük düzeltmede (metin, renk, tek sınıf) 375 px notu yeter: +taşma, üst üste binme, dokunma hedefi < 44 px, uzun üniversite adı — koddan çıkarım yazıyorsan +"koddan çıkarım" de. Sonra DESIGN.md §11.5 deneyim kontrol listesinden geç. ## 4. Örnekler @@ -56,12 +84,18 @@ dokunma hedefi < 44 px, uzun üniversite adı. (Tarayıcı açma kuralı AGENTS. **İstek:** "Paket sayfasına satın alma butonu koy." **Çıktı:** ```tsx - ``` -(Yanlış: başta ``, `bg-gradient-to-r`, `rounded-xl`, "Hemen Al ✨".) +(Yanlış: elle `bg-orange-500 hover:bg-orange-600`, başta ``, `bg-gradient-to-r`, `rounded-xl`, "Hemen Al ✨".) + +**İstek:** "React Bits'teki şu sayaç bileşenini fiyat bölümüne koy." +**Çıktı:** Registry'den kurma. Önce sitede karşılığı var mı bak (`SayiRulo`); varsa onu kullan. +Yoksa kaynağı oku (`curl -s https://reactbits.dev/r/-TS-TW.json`), `src/components/ui/.tsx` +olarak yeniden yaz: başta `// Esin: React Bits "" (MIT + Commons Clause); kod KolayTercih diline çevrildi.`, +GSAP/renk/easing atılır, `YAY.*` + token + `useReducedMotion` dalı girer. **İstek:** "Üniversite sayfasına URAP kartı ekle." **Çıktı:** @@ -79,12 +113,13 @@ dokunma hedefi < 44 px, uzun üniversite adı. (Tarayıcı açma kuralı AGENTS. ## 5. Kısa yasak listesi `Badge` import'u · elle pill span · CTA'da `Sparkles`/emoji · `indigo/purple/gray/zinc` · gradyan metin · -parlama gölgesi · `whileHover scale` · her elemanda fade-up · `dark:` · yüzeyde `bg-white` (yerine `bg-card`, §2.3) · Inter/Roboto/serif · `text-[#hex]` · renk tek başına anlam. +parlama gölgesi · `whileHover scale` · her elemanda fade-up · `dark:` · yüzeyde `bg-white` (yerine `bg-card`, §2.3) · Inter/Roboto/serif · `text-[#hex]` · renk tek başına anlam · +`gsap`/`framer-motion` importu · kullanım yerinde `stiffness:`/`cubic-bezier(` · `Button` üstünde elle `bg-orange-500` · satır içi adım çubuğu · sessiz eylem · telefonda gizlenen deneyim. ## 6. Rapor -İş sonunda: kullanılan kanonik parçalar (liste) · `pnpm tasarim:denetim` çıktısı (hata/uyarı sayısı) · 375 px notu · DESIGN.md ile çelişen bir şey bulduysan dosya:satır ile yaz, kendi başına kuralı değiştirme. +İş sonunda: kullanılan kanonik parçalar (liste) · en yakın §11.1 vakası ve taşıdığın neden · `pnpm tasarim:denetim` çıktısı (hata/uyarı sayısı) · baktıysan hangi genişlikte ne gördüğün, bakmadıysan 375 px notu · §11.5 listesinde işaretleyemediğin madde · DESIGN.md ile çelişen bir şey bulduysan dosya:satır ile yaz, kendi başına kuralı değiştirme. ## 7. Skill'i ölçme -Skill'in işe yarayıp yaramadığı `evals.md` içindeki üç istekle ölçülür (skill kapalı/açık karşılaştırması). Skill metnini ya da DESIGN.md'yi değiştirdikten sonra tekrar koşulur. +Skill'in işe yarayıp yaramadığı `evals.md` içindeki dört istekle ölçülür (skill kapalı/açık karşılaştırması). Skill metnini ya da DESIGN.md'yi değiştirdikten sonra tekrar koşulur. diff --git a/.agents/skills/kolaytercih-tasarim/evals.md b/.agents/skills/kolaytercih-tasarim/evals.md index 8e2f4f6..ec8b515 100644 --- a/.agents/skills/kolaytercih-tasarim/evals.md +++ b/.agents/skills/kolaytercih-tasarim/evals.md @@ -1,7 +1,7 @@ # kolaytercih-tasarim — değerlendirme seti Anthropic'in skill kılavuzu: önce eval, sonra skill. Bu üç istek, skill'in gerçekten -işe yaradığını ölçer. Her biri **iki kez** koşulur: (A) skill kapalı, (B) skill açık. +işe yaradığını ölçer (E4, 9 Eki 2026'da eklendi). Her biri **iki kez** koşulur: (A) skill kapalı, (B) skill açık. Puan = aşağıdaki kontrol listesinden geçen madde sayısı; `pnpm tasarim:denetim` her ikisinde koşulur. Nasıl koşulur: yeni bir oturumda isteği aynen yapıştır; çıktı `src/` altına değil @@ -25,10 +25,10 @@ Kontrol: Kontrol: - [ ] `Button` bileşeni, `rounded-full` (özel radius yok) -- [ ] `bg-orange-500 … hover:bg-orange-600 text-white` +- [ ] `variant="cta"` + `size="lg"`; elle `bg-orange-500 … hover:bg-orange-600` yok - [ ] Metin önce, sonda `` - [ ] Başta ikon yok, `Sparkles` yok, emoji yok, gradyan yok -- [ ] Yükseklik `h-11` ya da `h-12` (44 px dokunma hedefi) +- [ ] Yükseklik sınıfı elle yazılmamış (`lg` = 48 px dokunma hedefi) - [ ] Denetim: 0 hata ## E3 — Bilgi kartı @@ -36,18 +36,31 @@ Kontrol: **İstek:** "Bölüm sayfasına 'Mezun maaşı' kartı ekle; başlık, büyük sayı ve kaynak notu olsun." Kontrol: -- [ ] Kutu `rounded-2xl border border-slate-200 bg-white` (ya da `Card` + `border-slate-200/70 shadow-sm`) +- [ ] Kutu `rounded-2xl border border-slate-200 bg-card` (ya da `Card` + `border-slate-200/70 shadow-sm`); `bg-white` yok - [ ] Sağ üstte `PixelCorner` (`absolute right-4 top-4`) - [ ] Sayı `font-heading … font-bold text-slate-900`; etiket `text-xs font-semibold text-slate-500` - [ ] Nötr yalnızca slate; gölge yalnızca `shadow-sm` - [ ] Kaynak notu için uydurma rakam/atıf yok ("kaynak: —" ya da prop) - [ ] Denetim: 0 hata +## E4 — Dış bileşeni bizim dile çevir + +**İstek:** "React Bits'teki Stepper'ı (https://reactbits.dev/r/Stepper-TS-TW.json) paket satın alma akışına koy." + +Kontrol: +- [ ] Önce sitedeki karşılığı arandı; `AdimGostergesi` bulundu ve o kullanıldı (yeni stepper yazılmadı) +- [ ] `shadcn add` / registry kurulumu yok; `gsap` / `framer-motion` importu yok +- [ ] Sabit hex (`#5227FF`, `bg-green-500`) yok; renkler token +- [ ] Kullanım yerinde `stiffness:` / `cubic-bezier(` yok; `hareket.ts` adları +- [ ] `useReducedMotion` dalı ve `aria-current="step"` var +- [ ] Yeni dosya yazıldıysa başında kaynak + lisans yorumu +- [ ] Denetim: 0 hata + ## Sonuç tablosu -| Tarih | E1 A/B | E2 A/B | E3 A/B | Not | -|---|---|---|---|---| -| | | | | | +| Tarih | E1 A/B | E2 A/B | E3 A/B | E4 A/B | Not | +|---|---|---|---|---|---| +| | | | | | | B sütunu A'dan belirgin yüksek değilse skill'in metni ya da DESIGN.md'nin ilgili bölümü güçlendirilir (Anthropic: "Claude B nerede takıldı → Claude A'ya götür"). diff --git a/.claude/agents/tasarimci.md b/.claude/agents/tasarimci.md index 16c4ebe..e997d80 100644 --- a/.claude/agents/tasarimci.md +++ b/.claude/agents/tasarimci.md @@ -12,10 +12,18 @@ Sen KolayTercih ekibinin **ürün tasarımcısısın**. İşe başlamadan önce - Rozet/eyebrow: `src/components/pixel-decor.tsx` → `SectionEyebrow`. `Badge` ya da elle yazılmış pill yok. - Modal kapatma çarpısı: `src/components/ui/dialog.tsx` → `DialogContent` varsayılanı; özel yerleşimde aynı class seti. -- CTA: metin önce, sonda ``; `Sparkles` yok, başta ikon yok. +- CTA: `Button variant="cta"` (ikincil: `variant="ikincil"`); metin önce, sonda ``; `Sparkles` yok, başta ikon yok, elle `bg-orange-500` yok. +- Çok adımlı akış: `AdimGostergesi` (DESIGN.md §4.35). Telefon menüsü: `MobilMenu` (§4.36). Logoya tepki: `logoTepkisi()` (§4.34). +- Hareket: yay/eğri/süre yalnız `src/lib/hareket.ts`'ten; kullanım yerinde değer uydurulmaz. - Risk dili: kırmızı/sarı/yeşil **her zaman metin etiketiyle**; renk tek başına anlam taşımaz. - Bileşen tabanı shadcn (`src/components/ui`), tipografi ve renk token'ları `src/app/globals.css`. +## Deneyim dili (her işte, önce) + +Bilal'in zevki `docs/tasarim/DESIGN.md` **§11**'de yazılıdır ve her tasarım tanımının ölçüsüdür: site "web sitesi değil web uygulaması" gibi hissettirir. İşe başlarken §11.1'deki referans vakalardan (logo morph'u, hero piksel haritası, çekmecede önce logo sonra içerik, üst çubuk üçlüsü, kayan vurgu/dönen sayı) en yakınını aç ve tanımına "hangi vakanın *nedenini* taşıyor" diye yaz. Her tanım §11.5 kontrol listesiyle biter. Bir ekran statik, sessiz ya da kullanıcının durumundan habersizse bu bir bulgudur — zevk yorumu değil, §11.2 ihlali. + +Dış kaynaklar (Arc `uiarc.dev`, React Bits `reactbits.dev`, Skiper UI `skiper-ui.com`, Motion Primitives `motion-primitives.com`) esin için serbesttir; kaynak kodu registry JSON'undan okunur (`components.json`'da `@uiarc`, `@react-bits`, `@skiper-ui`; ör. `curl -s https://reactbits.dev/r/Stepper-TS-TW.json`). Öneride bileşeni olduğu gibi kurdurma: §11.3'ün yedi adımlık çevirisini tanıma yaz (hangi renk hangi token'a, hangi yay `hareket.ts`'teki hangi ada, hangi bağımlılık `motion` ile nasıl karşılanır, sade karşılığı ne). + ## Beceriler Görsel dilin tek kaynağı `docs/tasarim/DESIGN.md`; giriş kapısı olan `kolaytercih-tasarim` becerisi sana başlangıçta yüklü gelir. Genel tasarım katalogları (`ui-ux-pro-max` vb.) **kullanılmaz**: yön zaten belli, katalog "ortalama site" görünümüne iter. Çakışmada öncelik: `AGENTS.md` / DESIGN.md tek kaynakları > beceri > kendi yorumun. @@ -39,10 +47,10 @@ Her denetimde `pnpm tasarim:denetim` çıktısını rapora koy; makinenin yakala ## Sınırlar -- Tarayıcı/dev sunucusu açma, ekran görüntüsü alma — denetim koddan yapılır. Görsel doğrulamayı Bilal yapar; kontrol etmesi gereken ekranları kısa liste olarak ver. +- Tarayıcı: `AGENTS.md` "Tarayıcı açma" bölümü geçerlidir. Koddan teşhis edilemeyen şeyde (taşma, oran, hiyerarşi, karanlık mod, hareketin hissi) önizlemeyi **açarsın**; yeni bir parça ya da yeniden düzenleme tanımlıyorsan bakmak zorunludur (375 px + masaüstü). Önce Bilal'in açık sunucusuna bak (`lsof -iTCP -sTCP:LISTEN -P -n | grep node`), yoksa aç ve iş bitince kapat; raporda hangi genişlikte ne gördüğünü ve neye bakmadığını yaz. Küçük tutarlılık denetimi koddan yapılır. - `src/` altına dokunmazsın; yalnızca `docs/tasarim/` altına yazarsın. Uygulama `yazilimci`'nin işi. - Marka terimi, logo, renk paleti değişikliği → `[BİLAL]`. -- Zevk yorumu yok: her bulgu ya bir kuralı (AGENTS.md, WCAG, tek kaynak) ya da somut bir kullanıcı sorununu göstermeli. +- Kendi zevk yorumun yok: her bulgu ya bir kuralı (AGENTS.md, DESIGN.md — §11 dahil, WCAG, tek kaynak) ya da somut bir kullanıcı sorununu göstermeli. Bilal'in zevki §11'de kuraldır; ona dayanan bulgu geçerlidir. ## Rapor diff --git a/.claude/settings.json b/.claude/settings.json index e19602d..346266e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,8 @@ { "enabledPlugins": { "superpowers@superpowers-marketplace": false, - "prd-development@pm-skills": false + "prd-development@pm-skills": false, + "typesafe@typesafe-ai": true }, "extraKnownMarketplaces": { "superpowers-marketplace": { diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 434d1fc..41358b5 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -33,6 +33,14 @@ jobs: IYZICO_BASE_URL=${{ secrets.IYZICO_BASE_URL }} OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }} OPENROUTER_MODEL=${{ secrets.OPENROUTER_MODEL }} + # İsteğe bağlı: sohbetten liste revizyonu niyet çıkarımının modeli + # (src/lib/ai/revizyon-niyeti.ts). Secret yoksa boş gelir → rapor + # modeli kullanılır. Ölçüm 10 Eki 2026: google/gemini-3.1-flash-lite + # tek sağlayıcı, p90 1,9 sn, 42/44 kural doğru (deepseek p90 5-15 sn). + OPENROUTER_NIYET_MODEL=${{ secrets.OPENROUTER_NIYET_MODEL }} + # İsteğe bağlı: reasoning_effort ("none" varsayılan; bazı modeller + # "none"ı reddeder → "minimal"/"low", "yok" alanı göndermez). + AI_REASONING_EFFORT=${{ secrets.AI_REASONING_EFFORT }} # İsteğe bağlı: danışman çıktı denetimi (src/lib/ai/jev.ts). Secret # yoksa boş gelir → denetim atlanır, danışman aynen çalışır. TYPESAFE_API_KEY=${{ secrets.TYPESAFE_API_KEY }} diff --git a/AGENTS.md b/AGENTS.md index ecb0528..f42f919 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,11 @@ This version has breaking changes — APIs, conventions, and file structure may **İstisna: `tasarimci` ve UX işi.** Tasarımcı, gerekli gördüğünde önizlemeyi/dev sunucusunu **açabilir**. Bazı tasarım sorunları koddan teşhis edilemez — taşma, üst üste binme, kırılma noktaları, gerçek metin uzunluğuyla oluşan yerleşim, karanlık mod, odak halkası, dokunma hedefi. Bunlarda "koddan çıkarım" yetmez; bakmak gerekir. +**Zorunlu: yeni UI parçası tasarlayan herkes bakar (9 Eki 2026).** Yukarıdaki "açma" varsayılanı küçük düzeltmeler içindir (metin, renk, tek sınıf değişikliği). İş **yeni bir bileşen/bölüm tasarlamak ya da mevcut bir bölümü yeniden düzenlemek** ise — rol ne olursa olsun, ana oturum dahil — sonuç ekranda görülmeden teslim edilmez. 9 Ekim'de sürüm notlarındaki geri bildirim formu bakılmadan teslim edildi; DESIGN.md sınıfları birebir doğruydu ama sonuç kötüydü (kutu içinde kutu, sayfa başlıklarının yanında cılız başlık, soluk küçük buton) ve Bilal uyarmak zorunda kaldı. Sınıfların doğru olması kompozisyonun iyi olduğunu göstermez; oran ve hiyerarşi koddan görünmez. Yapılacaklar: +- Önce Bilal'in açık dev sunucusu var mı bak (`lsof -iTCP -sTCP:LISTEN -P -n | grep node`); varsa onu kullan, yeni sunucu açma ve onunkini kapatma. Yoksa aç, iş bitince kapat. +- **375 px ve masaüstünde** ekran görüntüsü al; ilk hâli beğenmediysen düzelt, tekrar bak. Raporda hangi genişlikte ne gördüğünü ve neye bakmadığını (ör. açık tema) yaz. +- Yazmadan önce parçayı **sayfanın geri kalanıyla** ölç: başlık o sayfadaki kardeş başlıklarla aynı ölçekte olur; kart içine kenarlıklı alan konmaz (kutu içinde kutu) — form için tek yüzey: kenarlıksız `Textarea` + buton aynı yüzeyin içinde (sohbet kutusu kalıbı, örnek `src/features/geri-bildirim/components/geri-bildirim-formu.tsx`); aksiyon butonu kanonik turuncu CTA'dır (`size="lg"`), soluk `h-9` değil. + Açtığında geçerli olanlar: - **Gerekçesini yaz:** neyi koddan göremediğin için açtın. - **Ne gördüğünü yaz**, hangi genişlikte (375 / 768 / 1024 / 1280 px) ne olduğunu tek tek. Ekran görüntüsü alabilirsin. @@ -45,6 +50,15 @@ Push emri geldiğinde önce lokalde `pnpm build` başarıyla geçmeli; geçmeden UI'a dokunan her iş (yeni ekran, bileşen, kart, modal, buton, form; mevcut arayüz değişikliği) önce `docs/tasarim/DESIGN.md` okunarak yapılır; renk, yazı tipi, köşe, gölge, boşluk ve tek kaynak bileşenlerin sınıf dizileri oradan **birebir** alınır, "yaklaşık aynısı" yazılmaz. Aşağıdaki rozet/çarpı/CTA kuralları o belgenin özetidir. Genel tasarım katalogları (`ui-ux-pro-max` gibi "50 stil, 97 palet" kütüphaneleri) bu projede kullanılmaz; yön zaten belli. İş bitmeden `pnpm tasarim:denetim` sıfır hata vermeli (`pnpm lint` bunu da koşar); betik `scripts/tasarim-denetim.ts`, kural eklerken DESIGN.md'deki bölüm numarasına atıf yap. +# Deneyim dili: web sitesi değil web uygulaması (9 Eki 2026) + +Bilal'in zevki `docs/tasarim/DESIGN.md` **§11 "Deneyim ilkeleri ve referans vakalar"**da yazılıdır; "UI şöyle mantıkta olsun" diye tekrar anlatmaz, oradan okunur. Yeni bir ekran, bileşen ya da hareket tasarlayan herkes (ana oturum dahil) işe başlamadan §11.1'deki en yakın vakayı açar, bitince §11.5 listesinden geçirir. Özü: + +- **Referans vakalar** ("harika" denenler): tema değişince sayfadan *sonra* dönüşen logo; hero'daki iri Türkiye piksel haritası ve aşağı dağılan pikseller; çekmecede önce logo sonra içerik; üst çubuktaki Sıralama · Ara · Listem bütünlüğü; kayan vurgu ve dönen sayı. Her birinin *neden* iyi olduğu §11.1'de — kopyalanmaz, aynı neden üretilir. +- **Uygulama gibi davran:** kullanıcının durumu (sıra, liste N/24, tema) her yerde yansır; her eylemin görünür cevabı var; yüzey açılırken önce imza sonra içerik; hareketler sırayla konuşur; sayfa başına tek orkestre giriş; telefonda deneyim gizlenmez. Oyunlaştırma dürüsttür (§10.2, §10.7). +- **Dışarıdan parça** (Arc, React Bits, Skiper UI, Motion Primitives) alınabilir ama **bizim renk ve biçimimize çevrilerek** (§11.3, yedi adım): kod bizim dosyamızda, yeni bağımlılık yok (hareket yalnız `motion`; `gsap`/`framer-motion` yasak), renk token'a, yay/eğri `src/lib/hareket.ts`'e, sade karşılığı ve klavye desteği yazılı, dosya başında kaynak + lisans yorumu. +- **Tek kaynaklar:** turuncu CTA `Button variant="cta"`, ikincil `variant="ikincil"` (elle `bg-orange-500` yazılmaz); çok adımlı akış `AdimGostergesi` (§4.35); telefon menüsü `MobilMenu` (§4.36); logoya tepki `logoTepkisi()` (§4.34); yay/eğri/süre `hareket.ts`. + # Karanlık mod: token'la çalışır, `dark:` yazılmaz Sitede açık / koyu / sistem teması var (DESIGN.md §2.3). Karanlık mod `src/app/globals.css` içindeki `.dark` bloğunda palet token'ları çevrilerek çalışır (slate ölçeği ters döner, renkli açık zeminler koyu tona iner); bileşenlere `dark:` sınıfı yazılmaz. Yeni UI yazarken: yüzey zemini `bg-card`'dır (`bg-white` koyu temada da beyaz kalır — yalnız logo karosu gibi gerçekten beyaz kalması gereken yerde); koyu hap (`bg-slate-900`) yazısını `text-slate-50` ile alır; SVG/satır içi stilde sabit renk yerine `var(--card)`, `var(--color-slate-200)` gibi değişken kullanılır; koyu vurgu yüzeyi (`bg-slate-950`) `kt-koyu-yuzey` sınıfını taşır. `pnpm tasarim:denetim` bunları `yuzey-beyaz`, `koyu-hap-beyaz-yazi` ve `dark-sinif` kurallarıyla yakalar. @@ -109,3 +123,7 @@ Yerine ne yapılır: - Kendi yazdığın bir dosyayı geri almak: dosyayı elle düzenle, `checkout --` kullanma. Kural ihlal edilmişse: ne kaybedildiğini `git reflog` + `git diff ` ile tespit et, **derhal raporla**, kendi başına yeniden oluşturmaya çalışmadan önce kaybın kapsamını yaz. + +# Jev / TypeSafe işlerinde skill zorunlu + +Jev'e (TypeSafe System One modeli) dokunan her iş — yeni bir tipli karar/olasılık çağrısı, mevcut prompt-parse adımını Jev'e taşıma, soru tasarımı, SDK kullanımı — başlamadan önce `typesafe:typesafe-ai` skill'i çağrılır ve skill'in işaret ettiği canlı dökümanlar (`docs.typesafe.ai/llms.txt`) okunur. Plugin proje kapsamında `.claude/settings.json` içinde etkin; yeni makinede önce `claude plugin marketplace add typesafe-ai/skills` gerekir. diff --git a/components.json b/components.json index cb47ab0..718fd80 100644 --- a/components.json +++ b/components.json @@ -24,6 +24,7 @@ "registries": { "@skiper-ui": "https://skiper-ui.com/registry/{name}.json", "@magicui": "https://magicui.design/r/{name}", - "@uiarc": "https://uiarc.dev/r/{name}.json" + "@uiarc": "https://uiarc.dev/r/{name}.json", + "@react-bits": "https://reactbits.dev/r/{name}.json" } } diff --git a/docs/tasarim/DESIGN.md b/docs/tasarim/DESIGN.md index 9cfaeb5..2eed47a 100644 --- a/docs/tasarim/DESIGN.md +++ b/docs/tasarim/DESIGN.md @@ -218,21 +218,29 @@ bir dosyaya (`src/components/…`) çıkarılıp iki yer de oradan kullanır. ### 4.1 Buton — `src/components/ui/button.tsx` - Her buton `rounded-full`; `h-9` varsayılan, `h-11`/`h-12` funnel CTA'ları (44 px dokunma hedefi), `size-9` ikon butonu. -- Odak: `focus-visible:ring-3 focus-visible:ring-ring/50`; basınca `active:translate-y-px`. -- **Turuncu CTA (kanonik):** +- Odak: `focus-visible:ring-3 focus-visible:ring-ring/50`; basınca `active:translate-y-px` + (`cta` ve `ikincil` varyantlarında bunun yerine `active:scale-[0.97]`). +- **Turuncu CTA (kanonik) — `variant="cta"`** (9 Eki 2026'dan itibaren varyanttır; sınıf dizisi + elle yazılmaz, denetim `elle-cta` ile uyarır): ```tsx - ``` - Metin önce, ok sonda. Başta ikon yok. `Sparkles` yok. İstenirse `active:scale-[0.97]`. -- **İkincil gezinme butonu** ("Tüm sıralamayı gör →"): + Varyantın ürettiği: `cursor-pointer bg-orange-500 text-white hover:bg-orange-600 + transition-[background-color,transform] duration-200 active:scale-[0.97]`. Metin önce, ok + sonda. Başta ikon yok. `Sparkles` yok. Kullanım yerinde yalnız yerleşim sınıfı eklenir + (`w-full sm:w-auto`, `mt-6`); yükseklik, renk, geçiş yazılmaz. +- **İkincil gezinme butonu — `variant="ikincil"`** ("Tüm sıralamayı gör →"): ```tsx - ``` + Ürettiği: outline tabanı + `cursor-pointer hover:border-orange-500 hover:bg-orange-50/60 + hover:text-orange-500 transition-[background-color,border-color,color,transform] duration-150 + active:scale-[0.97]`. - Sessiz: `variant="ghost"`; link görünümü: `variant="link"`. ### 4.2 Rozet (bölüm başlığı üstündeki kapsül) — `SectionEyebrow`, `src/components/pixel-decor.tsx` @@ -658,7 +666,7 @@ sihirbaz modalı içindir; yeni bir ayrıntı için `Dialog` açılmaz. açılır ki veri gelince sayfa yalnız büyüsün, konum sıçramasın. "Yıllara göre" yıl tablosu kaldırıldı (API `gecmis` alanı duruyor; backlog). Açılış koreografisi `AcilisSahnesi` kartsız modu (`src/components/ui/acilis-sahnesi.tsx`, `hazir={durum === "hazir"}`): veri - gelince başlığın altında üç logo karesi (`data-logo` kabı, `relative mx-auto h-16 w-16`) + gelince başlığın altında üç logo karesi (`data-logo` kabı, `relative mx-auto size-44`; akademik göstergelerdeki logoyla aynı ölçek) belirir, kısa durur, sönerek kaybolur (scale 0.6 + opacity 0; kap sıfır boya iner), içerik blokları (`data-sonra`: künye, kontenjan türleri, netler, koşullar, istihdam, üniversite, kaynak) 0,05 s arayla bulanıktan netleşir; toplam ≈ 1,2 s (kareler 0,1–0,2 s'de belirir, @@ -669,6 +677,14 @@ sihirbaz modalı içindir; yeni bir ayrıntı için `Dialog` açılmaz. göstergeler çekmecesi (§4.9) aynı bileşenin kartlı modunu kullanır (her açılışta oynar). Gövde kaydırılınca içerik tutamaç ve çarpının altına maskesiz girmesin diye `AltSayfaGovde` üst kenarında 20 px saydam → dolu `mask-image` var (§4.10; tüm çekmeceler). +- **10 Eki 2026 — büyük logo, logo → başlık (morph):** Bilal: "logo küçücük kalmış; logolar + küçülürken başlıktaki yazıya morph olsun." Logo kabı `size-44` (akademik göstergelerdeki + logoyla aynı ölçek). Kareler sönmek yerine küçülerek (scale 0.2) başlık yazısının üç eşit + dilimine uçar ve uçuşun son yarısında solar; başlık (`data-logo-hedef`, `AcilisSahnesi` sözleşmesi) o sırada + bulanıktan netleşir. Başlık veri gelene dek `opacity-0`'dır (yer tutar, sıçrama olmaz); + önbellekli açılışta, hata durumunda ve "hareketi azalt"ta doğrudan görünür. Aynı gün denenen + "üç özet karesi" (taban sırası / puan / kontenjan kartları) beğenilmedi, geri alındı: bu + çekmecede kart yok kuralı duruyor. ### 4.20 Danışman sohbeti — `sohbet-client.tsx`, `arac-pilleri.tsx` - **Soru formu hazırlanırken** (7 Eki 2026): `soru_sor` aracı tamamlanana dek cevap iskeleti değil @@ -884,10 +900,17 @@ kurallarıyla aynı olmalı (`/meraklisina` şemasıyla çelişmez). `font-sans text-base font-semibold text-slate-400`, etiket `text-sm font-semibold text-slate-500`. Çerçeve, zemin, ayırıcı çizgi ve sayının yanında piksel süs **yok**. lg altında tek sütun, her şey ortalı: animasyon → sayılar (sm'de üçü yan yana) → dürüstlük notu. -- **İşaret site başlığındaki logonun animasyonunu oynar** (`AnimasyonluMark`, varsayılan "rota": - kareler sırayla birbirinin yerine süzülür). Başlıkta üzerine gelince, burada ekrandayken - sürekli (`oynat`); ekrandan çıkınca kareler yerine oturur. Nabız + tüm karoyu döndüren eski - hâl (`kt-akis-donus`) yanlıştı ve kaldırıldı (Bilal, 7 Eki 2026). Hareketi azalt'ta durağan. +- **Ortadaki işaret danışmandır: nabız atar, sonra çıktıya dönüşür** (Bilal, 11 Eki 2026: + "logo morph ile ikonlara dönüşsün; belirli süre DanışmanlaKonuş'un animasyonu oynayıp morph + olsun, şu ankini kaldır"). İşaret ≈3 sn DanışmanlaKonuş başlığındaki gibi yerinde nabız atar + (`varyant="nabiz"`: danışman düşünüyor), sonra üç kare girdilerden çıkan şeye dönüşür, ≈1,3 sn + durur ve logoya toplanır (toplam 2,5 sn); nabız yerinden sürer, sıra bir sonraki biçime + geçer. Sıra paketin verdiğidir: **liste** (deste) → **trend** (yükselen üç çubuk) → **sohbet** + (iki balon). Biçimler §4.34'tekilerle aynı dilde, üç kareden ve logonun üç renginden + (`AnimasyonluMark` `bicimler`). Yalnız ekrandayken oynar; ekrandan çıkınca kareler yerine + oturur. Hareketi azalt'ta durağan logo. Eski "rota" (kareler birbirinin yerine süzülür) bu + karodan kaldırıldı; ondan önceki nabız + tüm karoyu döndüren hâl (`kt-akis-donus`) de yanlıştı + (Bilal, 7 Eki 2026). ### 4.29 Program duvarı — `ProgramDuvari`, `src/features/pazarlama/components/program-duvari.tsx` **Şu an sayfada kullanılmıyor:** ana sayfa "Sorun" bölümünden 7 Eki 2026'da çıkarıldı (Bilal: @@ -907,7 +930,9 @@ Ana sayfa "Katalog ücretsiz" bölümü: en çok aranan bölüm ve şehir sayfal (§4.12 kimlik hapı, `GraduationCap` / `MapPin`, hover §2.1; her üçüncü hap bir kademe iri). Bölüm ekrana girince haplar yukarıdan hafif eğik düşüp oturur (tek, hafif sekme — düşen nesne, §6); imleç gezdikçe her hap kendi derinliğinde en çok 18 px süzülür, arkadaki piksel kareler -ters yöne kayar. Haplar akışla dizilir (375 px'te üst üste binmez) ve SSR'da görünür linktir; +ters yöne kayar. Kareler odak dışıdır (`blur-xs`, 4 px — Bilal, 11 Eki 2026): net olan yalnız +haplardır; bulanıklık karenin kendisine yazılır, kaba değil (kayarken yeniden çizilmesin). +Haplar akışla dizilir (375 px'te üst üste binmez) ve SSR'da görünür linktir; yalnız katalogda sayfası olan adlar çizilir. Dokunmatikte süzülme yok; hareketi azalt'ta yalnız belirir. Esin React Bits "Parallax Pills"; kod bizim. @@ -931,6 +956,15 @@ pikseli, `-` silik piksel, `o` turuncu vurgu. katalog kartlarına sahne konmaz (§4.8: özet kartının içinde piksel seri "çok boğdu"). - `ton="dolgun"` aynı dokunun koyu hâlidir (opaklık 0,64–1); okunması gereken şekiller (rakam) için. Sahne `aria-hidden`'dır; anlamı başlık taşır. +- **Ana sayfadaki üç sahne canlıdır** (10 Eki 2026, `sorun-sahneleri.tsx`, ilkel §4.38): resim + aynı ızgaradır, kartın cümlesini hareketle söyler. "24 tercih" → hepsi soğur, sonra **tam 24** + kare tek tek ısınır; her turda başka 24 (hangi 24? — her seferinde başka ihtimal). "Geçen yılın + puanı" → çubuklar bir yuva sola kayar, sağdan başka boyda yeni yıl girer, turuncu tepe hep en + yeni yıldadır. "Danışman lüks" → merdiven basamak basamak kurulur, turuncu tepe tırmanır. + Sunucu HTML'i ve hareketi azalt eski durağan resmi gösterir. **Sahneler sırayla konuşur** + (§11.2-3): modül seviyesindeki tek orkestra aynı anda yalnız birini oynatır (iki başlangıç + arası ≥ 2,4 sn, aynı sahne ≥ 6,6 sn dinlenir). Karta imleç gelince ya da dokununca o sahne + hemen oynar (`data-sahne-karti`). ### 4.32 Adım rakamları — `PixelRakam`, `src/components/pixel-decor.tsx` Ana sayfa "Nasıl çalışır"ın üç adımı **kartsızdır** (Bilal, 7 Eki 2026, §4.31'deki istek): adım @@ -941,6 +975,20 @@ birbirine bağlanır (`h-0.5 rounded-full bg-slate-200`, rakamın ortasında; ve çizgiyle aynı dil). Telefonda adımlar alt alta, çizgisiz. Yapı `ol > li`; rakam `aria-hidden`, başlığın başında `sr-only` "1. adım:". Turuncu numara rozeti, ikon ve kart çerçevesi kaldırıldı. Yeni bir rakam gerekirse ızgarası `RAKAMLAR`'a eklenir (bugün 1–3). +- **Rakam, adımının biçimine dönüşüp geri toplanır** (10 Eki 2026, `adim-akisi.tsx`, ilkel + §4.38; logonun yaptığının aynısı, §4.34): `1` → sıra şeridi (dokuz kare tek sırada, turuncu + olan sensin — `PixelSira` dili), `2` → yıl başına bir çubuk (`VERI_YIL_SAYISI` kadar, en çok + 6; en yenisinin tepesi turuncu), `3` → 6×4 = 24 kare (kapanıştaki piksel listenin dizilimi). + Rakamın pikselleri en yakın biçim hücresine süzülür, artanlar yerinde belirir/söner. Biçim + yeni şekil ya da renk icat etmez: aynı pikseller, aynı mavi doku, tek turuncu vurgu. +- **Sıra:** bölüm ekrana girince `1` biçimine döner (≈1,3 sn), geri toplanırken 1→2 çizgisi + maviyle dolar (§4.35'teki dolum), sonra `2`, çizgi, `3`. Bir adım yalnız kendisi ekrandaysa + ve öncekiler bittiyse oynar: geniş ekranda peş peşe, telefonda her biri görününce. Bir kez + oynar; sonra imleç adımın üstündeyken biçim açık kalır, telefonda dokununca bir kez oynar. + Biçimdeyken ısı fırçası kapalıdır (turuncu kare anlam taşır). Hareketi azalt'ta rakamlar + durağan, çizgiler dolu. +- Sahne 11×7 hücredir (rakam ortada, biçim iki yanına taşar); bu yüzden çizgi + `left-[calc(50%+5.25rem)] w-[calc(100%-8.5rem)]` ile rakamdan biraz açıkta başlar. ### 4.33 Soru formu — `questionnaire.tsx` (shadcn Questionnaire) + `SohbetAnketi`, `sohbet-anketi.tsx` shadcn'in `questionnaire` kayıt öğesi (ilkel `@shadcn/react/questionnaire`) sitenin diline çevrildi: @@ -958,11 +1006,193 @@ Sorular arası geçiş **sihirbaz adım geçişiyle birebir aynı** (7 Eki 2026) kaynak `src/lib/adim-gecisi.ts` (`adimGecisiVaryantlari`, `adimGecisi`); yön index'ten türer. Pasif sorular DOM'da kalır (mutlak/saydam/`inert`) ki FormData toplanmaya devam etsin. +### 4.34 Logo biçim dönüşümü (morph) — `AnimasyonluMark`, `tema-ikonu.tsx`, `src/lib/logo-tepkisi.ts` + +Bilal'in kararı (9 Eki 2026): logonun üç karesi yalnız süs değildir; **bir durumu anlatmak +için kısa bir an başka bir biçime dönüşüp geri toplanabilir.** Bu, markanın yumuşak ve oyunsu +tarafının imza hareketidir; piksel imzası (§1) durağan dokuysa bu onun canlı karşılığıdır. +İlk kullanım tema değişimiydi; aynı gün Bilal "logo durumlara tepki versin" dedi ve dönüşüm +kullanıcının kendi yaptığı üç ana da açıldı. + +**Biçimler** (180'lik logo kutusunda; renkler logonun kendi renkleri, iki temada aynı): + +| Durum | Tetik | Mavi kare | Şeftali kare | Turuncu kare | +|---|---|---|---|---| +| Açık tema: **güneş** | tema değişti | merkezde söner | hale: büyük, yarı saydam daire | güneş: küçük dolu daire | +| Koyu tema: **ay** | tema değişti | yıldız: hilalin iki ucunun ortasında | hilal: sırtı solda | yıldızın yerinde söner | +| **Kürsü** | sıralama girildi (`"sira"`) | orta, en yüksek basamak | sol, kısa basamak | sağ, en kısa basamak | +| **Deste** | listeye program eklendi (`"ekle"`) | altta satır | ortada satır | üstte satır (yeni gelen) | +| **Tamam** | liste 24'e doldu (`"tamam"`) | solda | ortada | sağda — üç eşit kare tek sıraya dizilir | +| **Trend** | veri akışı sahnesi (§4.28) | ortada, orta boy çubuk | solda, kısa çubuk | sağda, en yüksek çubuk (en yeni yıl) | +| **Sohbet** | veri akışı sahnesi (§4.28) | sol üstte balon (danışman) | turuncunun yerinde söner | sağ altta balon (sen) | + +Simetri kasıtlıdır: güneşte mavi, ayda turuncu sahneden çekilir. Hilal, kareyi kesen ikinci +bir şekille değil karenin kendi iç gölgesiyle çizilir (zemin rengine bağlı kalmaz). + +**Koreografi:** tema biçimi toplam 1,7 sn — %30'da biçim tamam, %70'e kadar durur, sonra logoya +döner. Kullanıcı eylemi biçimleri daha kısadır (toplam ≈1,1 sn; aynı duraklar), çünkü eylem +tekrarlanır. Kareler yuvarlanırken yer ve boy değiştirir; sekme yok (§6). Sayfa geçişiyle +**üst üste binmez**: önce tema geçişi biter (600 ms), kısa bir nefes (80 ms), sonra logo +dönüşür. Hover döngüsünden önceliklidir; biçim bitince imleç hâlâ üzerindeyse hover yerinden +sürer. Süren bir biçimin üstüne yenisi gelirse eskisi o anki konumdan devralınır (sıçrama yok). +"Hareketi azalt" açıksa dönüşüm oynamaz. + +**Tetikleme tek kapıdan:** `logoTepkisi("sira" | "ekle" | "tamam")` (`src/lib/logo-tepkisi.ts`) +pencereye olay atar; logo dinler. Bileşenler logoya ref ile uzanmaz. Dokunmatikte logoya +basınca hover döngüsü bir tur oynar (mobil kullanıcı da logonun canlı olduğunu görsün). + +**Durağan hâli:** tema seçicinin ikonları aynı biçimlerdir (`tema-ikonu.tsx`). Logo ile ikon +aynı geometriyi kullanır; biri değişirse öbürü de aynı commit'te değişir. + +**Kurallar:** +- Dönüşüm header'daki logoda oynar; aynı ekranda iki logo birden dönüşmez. +- Bir **durum değişimini** anlatır; dekor olarak döngüye alınmaz, sayfa açılışında oynatılmaz, + kaydırmayla tetiklenmez. +- **Tek istisna: ana sayfa veri akışının ortası** (§4.28; Bilal'in kararı, 11 Eki 2026). Orada + işaret bir sahnedir ve döngüyle oynar: nabız → biçim → logo. Dekor değildir; girdilerin neye + dönüştüğünü çizer (liste, trend, sohbet). Biçim daha uzun durur (2,5 sn; eylem biçimi 1,1 sn), + çünkü kimse bir şeye basmadı, bakan kişi okuyacak. Başka bir yere kopyalanmaz. +- Yalnız **kullanıcının kendi eylemine** ya da onun seçtiği bir ayara bağlanır. Sunucudan gelen + sonuç (liste üretildi, ödeme geçti) logoyu dönüştürmez; onların kendi sahnesi var (§4.19). +- Yeni bir biçim eklenecekse üç kareden ve logonun üç renginden çıkar; dördüncü şekil, + yeni renk, çizgi ikon eklenmez. +- Biçim logonun kendi alanına sığar (x 27–151): sağındaki "KolayTercih" yazısına değmez. + +### 4.35 Adım göstergesi — `AdimGostergesi`, `src/components/ui/adim-gostergesi.tsx` + +Çok adımlı her akışın (sihirbaz, tercih profili kapısı, soru formu) tek göstergesi. Esin React +Bits "Stepper" (Bilal, 9 Eki 2026: "bu stepper mevcut stepperla değişsin"); kod bizim, `motion` +ile, renkler bizim. + +- **Görünüş:** numaralı daireler (`size-9`, telefonda da 36 px) ince bir çizgiyle bağlı; altında + adım adı (`text-xs font-semibold`, sm'den `text-sm`). Bekleyen adım `border-slate-200 bg-card + text-slate-500`; **aktif** adım `bg-primary text-white` ve içinde numara; **tamamlanan** adım + `bg-primary` + kendini çizen beyaz tik. Çizgi `h-0.5 bg-slate-200`, tamamlanan aralıkta + `bg-primary` soldan dolar. Turuncu yok: gösterge bilgidir, aksiyon değil (§2). +- **Hareket:** daire rengi `SURE.standart`; tik `pathLength 0 → 1` (`YAY.donusum`); çizgi dolumu + `scaleX` (`YAY.akici`, `origin-left`). Geri gidince aynı yoldan boşalır (§6). +- **API:** `adimlar: string[]`, `aktif: number` (0 tabanlı), `onAdimSec?: (i) => void`. Yalnız + `onAdimSec` verilirse ve yalnız **tamamlanmış** adımlar tıklanabilir (ileri atlama yok). + Kontrollüdür; içerik geçişi göstergenin işi değildir — o `TransitionPanel` + + `src/lib/adim-gecisi.ts`'te kalır. +- **Erişilebilirlik:** `ol > li`, aktif adımda `aria-current="step"`, `role="status"` sr-only + "Adım 2 / 3: Şehir" (§10.2). Tıklanabilir adım `button`'dur, 44 px dokunma alanı. +- **Dürüstlük (§10.2):** yüzde, sahte ilerleme yok; adım sayısı gerçek adım sayısıdır. +- İskelet (`sihirbaz-adimlar-lazy.tsx`) göstergenin kutusunu birebir çizer; yüklenince yerleşim + sıçramaz. +- Ana sayfa "Nasıl çalışır" rakamları (§4.32) ayrı kalır ama aynı dili konuşur: aradaki çizgi + bölüm ekrana girdikçe aynı mavi dolumla dolar. + +### 4.36 Baloncuk menü (telefon) — `MobilMenu`, `src/components/mobil-menu.tsx` + +Bilal (9 Eki 2026): "telefonda menü sistemimizi bubble-menu gibi yapıp hem alanı küçültüp hem +istediğimizi rahatça kullanıcıya verebiliriz." Esin React Bits "Bubble Menu"; kod bizim (GSAP +yerine `motion`, kabuk Radix `Dialog` ilkeli: odak tuzağı, Esc, odak dönüşü). + +- **Telefonda üst çubuk tek satırdır** (`lg` altı): logo · sıralama hapı (`flex-1`) · menü + baloncuğu. Sıralama hapı dışarıda kalır çünkü sitenin ana eylemi odur; geri kalan her şey + baloncuğun içindedir. İkinci satır yok. +- **Baloncuk:** `size-9 sm:size-12 rounded-full border border-slate-200 bg-card`, içinde iki + çizgi; açılınca çizgiler çarpıya döner (aynı düğme kapatır, §4.3 çarpı ölçüsü). + Listede program varsa baloncuğun köşesinde turuncu sayı rozeti durur (§4.37). +- **Açık hâl:** tam ekran perde (`bg-slate-50/90` + `backdrop-blur`), içinde iri haplar alt + alta: `min-h-14 rounded-full border border-slate-200 bg-card px-6 font-heading text-xl`. + Haplar sırayla "pop" eder (`scale 0 → 1`, `YAY.birakma` — sitede sekmenin serbest olduğu + ikinci yer, çünkü baloncuk fırlar; 35 ms arayla). Kapanış hızlı ve sekmesiz (`SURE.cikis`). + Hap sırası: Ara · Kendi Listem (N/24) · Yapay Zeka Listem · gezinme linkleri · tema seçici · + Giriş yap / Çıkış. **Menüde turuncu buton yok:** menü gezinmedir, aksiyon değil; "Giriş yap" + üst çubuktaki gibi koyu haptır (`bg-slate-900 text-slate-50`), turuncu yalnız rozet sayısında. +- Renkli hover/rotasyon (özgün bileşendeki eğik, gökkuşağı haplar) alınmaz; hap düz durur, + basınca `active:scale-[0.97]`. +- Hareketi azalt: haplar yalnız belirir (opaklık), sıra gecikmesi yok. +- Masaüstünde (`lg+`) menü yoktur; orta ada ve sağ küme olduğu gibi kalır (§1.1). + +### 4.37 Uçuş ve rozet tepkisi — `UcusKatmani`, `src/features/liste/components/ucus-katmani.tsx` + +Listeye program eklemek sitenin en sık tekrarlanan eylemidir ve **sessiz kalmaz** (§11.2 +"her eylemin cevabı var"): + +- `+` düğmesine basınca düğmenin yerinden bir **piksel kare** (10 px, `rounded-[2px]`, + `bg-orange-500`) Listem rozetine (telefonda menü baloncuğuna) uçar: `YAY.akici`, hafif yay + çizen yol, varışta söner. Aynı anda yalnız bir kare uçar; hızlı art arda eklemede öncekiler + tamamlanır, yenisi sıraya girmez (en son eklenen uçar). +- Varışta rozet bir kez nabız atar (`kt-piksel-nabiz`, 0,42 s) ve sayı rulosu döner. +- Düğmede `+` yerine tik **kendini çizer** (`pathLength`, §4.17 çip tiğiyle aynı). +- Dokunmatikte `navigator.vibrate?.(8)`. +- Logo "deste" biçimine döner (§4.34); 24. programda "tamam". +- **Doluluk halkası:** Listem rozetinin çevresinde 24'lük ince halka (`stroke-primary`, + zemin `stroke-slate-200`); liste boşken de (0/24, soluk) görünür ki hedef okunsun. Halka + yay ile dolar (`YAY.akici`). Sayı her zaman yanında yazılıdır; halka tek başına anlam taşımaz. +- §10.3 sürer: bildirim yalnız 1/24 ve 24/24'te çıkar; uçuş bildirim değildir, eylemin + kendi geri bildirimidir. Konfeti, ses yok. +- Hareketi azalt: uçuş yok; rozet sayısı düz değişir, düğme tik olur. + +### 4.38 Piksel morf ve piksel şeridi — `PikselMorf`, `PikselSerit` + +Bilal (10 Eki 2026): "Landing'i deneyim hâline getir; motion graphic'ler daha fazla olmalı; +basit, sade, tutarlı morph'lar olabilir." Ana sayfanın hareketi tek bir fikirden çıkar: +**hero'nun pikselleri canlıdır** — yer değiştirir, ısınır, aşağı iner. Yeni bir hareket bu +ikisinden biriyle kurulur; üçüncü bir dil eklenmez. + +**Piksel morf** (`src/components/piksel-morf.tsx`). Piksel sahnenin (§4.31) canlı hâli: sahne +sabit bir piksel kümesidir, her pikselin kimliği (`id`) vardır ve kareden kareye yeni hücresine +süzülür. Dört ton: `doku` (hero mavisi), `silik` (yarı opaklık), `vurgu` (turuncu, nabız atar), +`gizli` (küçülüp söner, yerinde bekler). İlkel aptaldır: hangi piksel nerede, hangi tonda — +çağıran söyler; zamanlamayı ve görünürlüğü de çağıran yönetir. +- Hareket yalnız CSS geçişidir (`.kt-morf-px`, globals.css): kayma 0,55 sn `--egri-standart` + (sahne `--morf-sure` ile kısaltabilir), belirme/sönme 0,3 sn; **sekme yok** (§6). Turuncu + çabuk ısınır (0,25 sn), yavaş soğur (1,2 sn) — ısı fırçasının asimetrisi. +- Doku pikselleri imleç fırçasına katılır (`usePixelHeat` `canli`: konum her taramada okunur). + Turuncunun **anlam taşıdığı** karede fırça kapatılır (`firca={false}`). +- Piksel sırası kareler arasında sabit kalır (DOM yer değiştirirse geçiş kopar); sahneden + çıkan piksel silinmez, `gizli` olur. +- Sahne **kartın/adımın söylediğini çizer** (§4.31 kuralı sürer): süs olarak döngüye alınmış + morph yok. Aynı ekranda iki sahne aynı anda oynamaz; sırayı çağıran kurar. +- İlk kare sunucu HTML'idir ve tek başına doğru resimdir; hareketi azalt'ta sahne orada kalır. +- Kullanıldığı yerler: ana sayfa "Sorun" sahneleri (§4.31), "Nasıl çalışır" rakamları (§4.32). + +**Piksel şeridi** (`src/components/piksel-serit.tsx`, ana sayfada `bolum-gecisi.tsx`). Bilal +(11 Eki 2026): "En üstteki haritadan aşağıya inen piksel şeridi olsun; scroll'a bağlı, turuncu +olabilir." Aynı gün: "separator'ları kaldır." **Ana sayfada bölümler arasında piksel ayraç +yoktur; yerinde şerit durur.** Hero haritasının serpintisi tek sıraya iner ve her bölüm +arasında yeniden görünür: bölümleri birbirine bağlayan iplik. +- **Kaydırmaya bağlıdır, animasyon değildir:** ekranın okuma çizgisini (yüksekliğin %65'i) + geçen piksel turuncuya ısınır (`data-yandi`), geri kaydırınca yavaş soğur; şeridin ucu + (`data-uc`) tam turuncudur. Şeridi kullanıcının parmağı çizer (`useScroll`, Parallax gibi + 1:1). Zemin pikseli hero mavisidir; turuncu yalnız geçilen kısımdır. +- **Yalnız bölümler arasındaki boşlukta durur**; bölümün içinde, yazının arkasında çizilmez + (§4.31 "zemin değildir"). Tek sütun, hücre 14rem / 12 ≈ 18,7 px (sayfadaki en iri piksel). + Geçiş akışta 2 hücre yer tutar (eski ayracın boyu; bölüm boşluğu §5.1'de), şerit + bunun üstüne ve altına 4'er hücre taşar (telefonda 3'er): geniş ekranda 10, telefonda 8 + hücre. Hero'dan çıkan ilk şerit 6 hücre yukarı uzar ve serpintinin bittiği yerde başlar; + kısa telefonda (yükseklik < 760 px) 3 hücreye iner ki forma değmesin. +- `Parallax strength={26}` katmanındadır (eski ayraçla aynı derinlik, §6). +- Denenip bırakılanlar (11 Eki 2026): ayracın "dağınık düşüp toplanma" girişi ("şu anki + separator animasyonunu kaldır") ve şeridin ayraç kümesinin içinden geçtiği hâl (artı + işareti gibi duruyordu). Şerit tek başına durur. +- Alt sayfalarda şerit yoktur; orada başlığı içerikten `PagePixelDivider` ayırır. Şerit yalnız + ana sayfanın omurgasıdır. +- Hareketi azalt'ta renk geçişi anlıktır; şerit yine kaydırmayı izler (bilgi değil, iz). + +**Hero serpintisi damlar** (`turkey-pixel-map.tsx`, `.kt-serpinti`): kıyının altındaki +piksellerin üçte biri yerinden kopup 1–3 hücre düşerken söner, sonra yerinde yeniden belirir +(süre 4,5–8,5 sn, faz pikselden piksele farklı). §11.1 V2'nin nedeni ("harita bitmez, +dağılır") harekete çevrilmiş hâlidir; telefonda imleç olmadığı için haritanın canlılığı budur. +Hareketi azalt'ta kapalı. + ## 5. Yerleşim ### 5.1 Genel - Sayfa kabı: `mx-auto max-w-6xl px-4`. Metin sütunu: `max-w-xl` (paragraf) / `max-w-2xl`–`max-w-3xl` (uzun içerik). -- Landing bölümü: `
`; alt sayfalar `py-12`–`py-16`. +- Landing bölümü: `pt-24 pb-28 sm:pt-32 sm:pb-40 lg:pt-36 lg:pb-44` (tek kaynak `app/page.tsx` + `BOLUM_BOSLUGU`); alt sayfalar `py-12`–`py-16`. Bilal (11 Eki 2026): "section'lar arası es + yok, her şey çok sıkışık." Eski `py-20 sm:py-24`'te bölümler arasındaki piksel şeridi (§4.38) + içeriğe iki yandan ≈20 px kalıyordu. Kural: **şeridin çevresinde nefes kalır** ve alt boşluk + üsttekinden bir kademe büyüktür — şerit bitirdiği bölümden uzak, indiği bölümün rozetine + yakındır. Şeritle içerik arasında kalan boşluk (üst bölüme / alt bölüme): telefonda 56 / 40, + `sm`'de 85 / 53, `lg`'de 101 / 69 px. Kapanış yüzeyinden önce şerit yoktur; SSS'nin alt boşluğu + o esi tek başına verir. Bölüm boşluğu değişirse şeridin boyu (`BolumGecisi` `ust`/`alt`) ile + birlikte ölçülür. - Bölüm başlık bloğu (kanonik): ```tsx
@@ -971,10 +1201,12 @@ sorular DOM'da kalır (mutlak/saydam/`inert`) ki FormData toplanmaya devam etsin

…

``` - Ardından içerik `mt-12`; kart ızgarası `grid gap-6 md:grid-cols-3` (yalnızca gerçekten üç eşdeğer öğe varsa; üçe tamamlamak için içerik uydurulmaz). + Ardından içerik `mt-12` (ana sayfada `mt-12 sm:mt-16`); bir bölümün içindeki ikinci hareket + (ör. "Nasıl çalışır"da adımların altındaki veri akışı) `mt-24 sm:mt-32` ile ayrılır; kart ızgarası `grid gap-6 md:grid-cols-3` (yalnızca gerçekten üç eşdeğer öğe varsa; üçe tamamlamak için içerik uydurulmaz). - Alt sayfa başlığının altında `PagePixelDivider` (`mt-6`/`mt-8`), her sayfada **farklı seed**. -- Landing bölümleri arasında çizgi yok; `PixelDivider` (`mx-auto w-56`, farklı seed). -- Boşluk ölçeği: 4 → 6 → 8 → 12 → 16/20 → 24. Ara değer (`mt-7`, `gap-5`) yeni yerde açılmaz. +- Landing bölümleri arasında çizgi ve ayraç yok; piksel şeridi (`BolumGecisi`, §4.38, farklı `tohum`). +- Boşluk ölçeği: 4 → 6 → 8 → 12 → 16/20 → 24; yalnız ana sayfa bölüm ritminde 28 → 32 → 36 → + 40 → 44. Ara değer (`mt-7`, `gap-5`) yeni yerde açılmaz. - Mobil önce (trafiğin ~%90'ı 375 px): uzun adlar sarar (§4.15); tablo yatay kaydırmaz, dar ekranda sütunlar satır altına iner; sabit alt çubuk klavyeyle çakışmaz; `vh` yerine `dvh`. @@ -1007,6 +1239,9 @@ Dört sütunlu tablo yalnız `lg`'de. Altında her özellik kendi satırında (b `text-base font-semibold`), üç cevap altında üç sütun; KolayTercih sütunu `bg-primary/5` kutuda. Sütun adları üstte yapışık: `sticky top-24 sm:top-32` (site başlığı telefonda 96 px, sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerekiyor, olmamalı". +"Var" tiki tablo ekrana girince **kendini çizer** (§11.2-6): önce halka, sonra işaret; satırlar +70 ms arayla (`--tik-g`). `ComparisonCell` lucide `circle-check` geometrisini `pathLength="1"` +ile çizer (`.kt-tik-ciz`, globals.css); hareketi azalt'ta ve JS'siz istemcide tik hazır gelir. ### 5.3 Verimli ve havadar - İlişkili öğeler yan yana dizilir (iki sütun) — tek sütunda alt alta uzayan panel yapılmaz. @@ -1016,7 +1251,7 @@ sm'den itibaren 128 px — ölçüldü). Bilal, 25 Eyl 2026: "kaydırması gerek ## 6. Derinlik, katman ve hareket -Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` ayraç). Sahne kartları (§4.31) hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`). +Derinlik gölgeyle değil **piksel katmanı ve hareket farkıyla** verilir: başlık bloğu gövdeden hafif hızlı akar (`Parallax strength={16}` başlık, `7` içerik, `26` bölümler arası piksel şeridi — §4.38). Sahne kartları (§4.31) hover'da 4 px yükselir. Modal arka planı `bg-black/10` + hafif blur. Yığın: içerik < sabit bar < çekmece/modal (`z-50`). Hareket kuralları (Apple, `motion/react`): - Varsayılan yay: `{ type: "spring", bounce: 0, duration: 0.35 }` — sekmesiz, sakin. Sekme @@ -1025,6 +1260,16 @@ Hareket kuralları (Apple, `motion/react`): `src/lib/hareket.ts` (`YAY.akici` panel/yığın, `YAY.donusum` biçim, `YAY.birakma` parmak bıraktıktan sonraki tek hafif sekme). Yeni bir Arc bileşeni yay uydurmaz, buradan alır; sınıflar Arc'ın CSS modülünden değil bu belgeden gelir. +- **Yay ve eğri kullanım yerinde uydurulmaz** (denetim: `elle-yay`). `hareket.ts`'teki adlar: + `YAY.varsayilan / akici / donusum / birakma / adim`, `EGRI.giris / standart / gucluCikis / + yerlesme`, `SURE.anlik / hizli / cikis / standart`. Yeni bir değer gerekiyorsa önce oraya + adıyla ve tek cümle gerekçesiyle eklenir. CSS tarafında aynı eğriler `globals.css`'te + `--egri-*` değişkenleridir. +- **Sayfa başına tek orkestre giriş** (§11.2): sıralı, sahnelenmiş belirme yalnız sayfanın ilk + ekranında bir kez; alttaki bölümler sade `Reveal` ile gelir. Çekmece ve menü kendi sahnesini + taşır (§4.19, §4.36). +- **Sayfa geçişi:** rotalar arası kısa çapraz solma (≈0,2 s, `EGRI.standart`); üst çubuk + geçişe katılmaz, yerinde kalır. Hareketi azalt'ta kapalı. - Giriş ve çıkış **aynı yoldan** (aşağıdan geldiyse aşağı gider). - Basınca anında tepki: `active:scale-[0.97]` (buton), `[0.99]` (geniş satır), `[0.96]` (çarpı). - Hover'da ok `translate-x-0.5` kayar; kart yükselmesi yalnız landing kartında. @@ -1140,8 +1385,9 @@ Araştırma ve kaynaklar: `docs/tasarim/2026-09-25-samimi-arayuz-arastirmasi.md` ### 10.2 İlerleme - Sayıyla ve dürüst: "7/24", "Adım 2/3: Şehir". Yüzde, sahte hız, yapay ilerleme yok. -- Sihirbaz adım göstergesi adlı (Alan · Şehir · Öncelik); dolum `scale-x` ile soldan, - `duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]`, hareketi azalt'ta geçişsiz. +- Adım göstergesi tek bileşendir: `AdimGostergesi` (§4.35) — numaralı, adlı (Alan · Şehir · + Öncelik), tamamlanan adımda tik. Sihirbaz, tercih profili kapısı ve soru formu aynısını + kullanır; satır içi çubuk yazılmaz. - Liste çekmecesinde risk dağılımı metinle (`RiskEtiketi` + sayı). Riskleri bilinen 6+ tercihli listede güvenli yoksa tek cümle ipucu — vaat değil, gerekçe. @@ -1305,3 +1551,140 @@ satır:** başlık (`text-lg`) üstte, altında kupa+sayı ile ok **yan yana** ( gap-5`; kupa kutusu ve ok telefonda 48 px, sm'den 56 px); lg'de tek satır (sayı · başlık · ok). Blok formun yüksekliğini **aşamaz** — aşarsa `overflow-hidden` kap üstten kırpar (24 Eyl 2026'da üç satırlı ilk sürümde başlık kesildi; ölç, §9.2). + +## 11. Deneyim ilkeleri ve referans vakalar + +Bilal (9 Eki 2026): "Landing'de amacım yeni gelen kişiye bir **deneyim** vermek. Sitede benim +müdahalemle çok iyi bir deneyime dönüşmüş yerler var; bunlar gibi çalış. Web sitesi değil **web +uygulaması** olduğumuzu aktarabilmemiz lazım. Her seferinde 'UI şöyle mantıkta olsun' demeyeyim." + +Bu bölüm o zevkin yazılı hâlidir. §1–§10 **neyin nasıl göründüğünü** söyler; bu bölüm **neyin +iyi hissettirdiğini ve neden** söyler. Yeni bir ekran, bileşen ya da hareket tasarlayan herkes +(rol fark etmez) işe başlamadan §11.1'deki vakalardan en yakın olanı açıp bakar ve işi bitince +§11.5 listesinden geçirir. + +### 11.1 Referans vakalar (Bilal'in "harika" dedikleri) + +Her vaka üç şey söyler: ne oluyor, **neden iyi**, nereden bakılır. Yeni iş bunları kopyalamaz; +aynı *nedeni* kendi yerinde üretir. + +**V1 — Tema değişince logo, sayfadan sonra dönüşür** (§4.34; `src/lib/tema-gecisi.ts`, +`animasyonlu-mark.tsx`). Yeni tema logodan dışarı doğru açılır (600 ms), kısa bir nefes +(80 ms), sonra logonun üç karesi güneşe ya da aya dönüşüp geri toplanır (1,7 sn). +*Neden iyi:* iki hareket **sırayla** konuşur, üst üste binmez; ikincisi birincinin cevabıdır. +Ayarı değiştiren kişi "site beni duydu" hissini alır. Marka (logo) işin içindedir ama araya +girmez — bir an oynar ve çekilir. + +**V2 — Hero'daki Türkiye piksel haritası ve aşağı sarkan pikseller** +(`src/features/pazarlama/components/turkey-pixel-map.tsx`, `use-pixel-heat.ts`). Harita +ekranın %82'sine kadar yayılır (`w-[min(1665px,82vw)]`); kıyı hattından aşağı 9 hücre boyunca +seyrelen bir serpinti dökülür (kıyıda %45, dipte ≈%5 doluluk, derinlikle söner). +*Neden iyi:* **boş alan süsle değil, şeklin kendi uzantısıyla dolar** — harita bitmez, dağılır. +Boyutu cesurdur (küçük bir ikon değil, sahnenin kendisi) ama içerik onun önünde rahat okunur +(ortada okunurluk perdesi). İmleç yaklaşınca ısınır: bakana cevap verir. + +**V3 — Çekmecede önce logo, sonra içerik** (§4.19; `src/components/ui/acilis-sahnesi.tsx`). +"Akademik göstergeler" ve "Mezun istihdamı" açılınca logonun üç karesi ortada belirir, sonra +her biri kendi gösterge karesinin yerine ve boyuna büyür; içerik onların içinde netleşir. +*Neden iyi:* **yükleme anı marka anına döner** — kullanıcı boş bir kutu ya da gri iskelet +değil, tanıdığı bir şeyin açılışını izler. İçerik "bir yerden" gelir: logodan. Veri hazırsa +sahne oynamaz (önbellekli açılışta atlanır); bekletmek için değil, beklemeyi doldurmak için var. + +**V4 — Üst çubuktaki üçlü: Sıralamanı gir · Ara · Listem** (`katalog-arama.tsx`, +`listem-butonu.tsx`, `user-nav.tsx`). Üç ayrı işlev tek bir adada, aynı hap diliyle durur; +sıralama girilince hap sayıyı gösterir, liste dolunca rozet sayar. +*Neden iyi:* **çubuk bir menü değil, kullanıcının durumudur.** "Sıram 85.000, listemde 7 +program var" bilgisi her sayfada, her an görünür. Site kullanıcıyı tanıyor gibi davranır; +bu, "web sitesi" ile "web uygulaması" arasındaki farkın kendisidir. + +**V5 — Kayan vurgu ve dönen sayı** (`secim-haplari.tsx`, `sayi-rulo.tsx`). Seçim değişince +vurgu bir haptan öbürüne kayar; sayı değişince rakamlar döner. +*Neden iyi:* **değişim gösterilir, sonuç değil.** Eski değerle yeni değer arasında bir yol +vardır; göz neyin değiştiğini aramaz. + +### 11.2 İlkeler + +1. **Uygulama gibi davran.** Kullanıcının durumu (sıra, liste doluluğu, tema, giriş) her + sayfada yansır ve bir yerde değişince her yerde değişir. Sayfa "belge" gibi değil "ekran" + gibi kurulur: sabit üst çubuk, parmağı izleyen alt sayfa, sayfalar arası kesintisiz geçiş. +2. **Her eylemin cevabı var.** Basılan şey basıldığını gösterir (`active:scale`), sonucu olan + eylem sonucunu gösterir (rozet atar, sayı döner, kare uçar). Sessiz eylem yoktur. Cevap + eylemin büyüklüğüyle orantılıdır: hap seçmek bir kayma, liste doldurmak bir an. +3. **Önce imza, sonra içerik.** Bir yüzey açılırken (çekmece, menü, ilk ekran) önce markanın + bir parçası belirir, içerik onun ardından ve onun içinden gelir (V3). Hareketler **sırayla** + konuşur; aynı anda iki şey dikkat istemez (V1). +4. **Boşluğu şeklin kendisi doldurur.** Boş alana dekor yerleştirilmez; var olan bir şekil + uzar, dağılır, seyrelir (V2). Piksel dokusu her zaman bir şeyin devamıdır. +5. **Cesur ölçü, sakin hareket.** İmza öğeler iridir (harita sahnenin kendisi, sayı kartın + kahramanı, hap 48 px); hareket ise sekmesiz ve kısadır (§6). Büyük + sakin = güven. + Küçük + hareketli = oyuncak. +6. **Değişimi göster.** Değer değişirken eski ile yeni arasında yol çizilir (V5): kayan vurgu, + dönen rakam, dolan çizgi, kendini çizen tik. Anında yer değiştiren içerik son çaredir. +7. **Oyunlaştırma dürüsttür.** İlerleme gerçek sayıdır (7/24), kutlama öğrencinin kendi + adımınadır (§10.2, §10.3). Seri, puan, kayıp korkusu, sahte aciliyet yok (§10.7). Sempatik + = tepki veren ve tanıyan; şirin ya da gürültülü değil. +8. **Sayfa başına tek orkestre giriş.** Sahnelenmiş, sıralı belirme yalnız ilk ekranda; her + bölümde tekrarlanırsa gösteri olur, deneyim olmaz (§6). +9. **Telefon önce ve eksiksiz.** Trafiğin ~%90'ı telefonda. Bir deneyim telefonda gizleniyorsa + (harita, logo tepkisi, hover'a bağlı cevap) o deneyim yoktur; telefonda sade ama **var** + bir karşılığı tasarlanır. +10. **Hareketi azalt'ta da tam.** Her sahnenin sade karşılığı yazılır (§8); bilgi hiçbir + zaman yalnız harekette durmaz. + +### 11.3 Dışarıdan parça alma + +Bilal'in beğendiği kaynaklar: **Arc** (`uiarc.dev` — hareket ve buton dili), **React Bits** +(`reactbits.dev` — Stepper, Bubble Menu ve genel UI algısı), **Skiper UI** (`skiper-ui.com` — +deneyim bileşenleri), **Motion Primitives** (`motion-primitives.com`). Bunlara bakmak serbest +ve teşvik edilir; ama Bilal'in sözü kuraldır: "Bunlardan bir şey kullansak dahi, harika dediğim +kısımlar gibi **bize özgü renk ve tasarım şekline dönüştürüp** kullanırız." + +Bir dış bileşen siteye şu çeviriden geçerek girer (hepsi zorunlu): + +1. **Kod bizim olur.** Dosya `src/components/…` altına Türkçe adla yazılır; başına kaynak ve + lisans yorumu konur ("Esin: React Bits 'Stepper' (MIT + Commons Clause)"). Registry'den + olduğu gibi kurulup bırakılmaz. +2. **Yeni bağımlılık yok.** Sitede hareket için yalnız `motion` var. `gsap`, `framer-motion`, + `three`, `ogl`, `lenis`, `matter-js` isteyen bileşen `motion` ile yeniden yazılır ya da + alınmaz (denetim: `yabanci-hareket-paketi`). WebGL arka planlar alınmaz. +3. **Renk token'a iner.** Sabit hex, mor/yeşil/gökkuşağı vurgular gider; slate nötr, mavi + bilgi, turuncu aksiyon, yüzey `bg-card` (§2, §2.3). `dark:` yazılmaz. +4. **Hareket `hareket.ts`'e bağlanır.** Bileşenin kendi süre/easing/yay değerleri atılır, + en yakın `YAY` / `EGRI` / `SURE` adı kullanılır. Sekme yalnız bir şey fırlıyorsa. +5. **Biçim bize döner.** Köşe `rounded-full` / `rounded-2xl`, yazı `font-heading` / + `font-sans`, dokunma alanı ≥ 44 px, gölge yok. Uygun yerde piksel imzası eklenir (§1.2). +6. **Sade karşılığı ve erişilebilirliği yazılır.** `useReducedMotion` dalı, klavye, odak, + `aria-*`. Dış bileşenlerin çoğunda bunlar yoktur; biz ekleriz. +7. **Lisans:** React Bits MIT + Commons Clause (kullanmak serbest, bileşeni satmak/yeniden + dağıtmak yasak). Skiper ücretsiz sürüm atıf ister (dosya başı yorumu). Arc ücretsiz + katman MIT. Motion Primitives MIT. Ücretli (Pro) katmandan hiçbir şey alınmaz. + +Arc'tan alınan **hareket ilkeleri**dir (durum bildiren her şey sekmesiz yay, yalnız +`transform`/`opacity`, çıkış girişten hızlı, etkileşim başına tek hareket) — bunlar zaten +`hareket.ts`'te. Arc'ın görsel dili (Geist/Inter, 34 px köşe, "eyebrow yok") **alınmaz**; +bizim dilimizle çelişir. + +Kaynak kodunu okumak için shadcn MCP ya da registry JSON'u yeterlidir (`components.json`'da +`@uiarc`, `@react-bits`, `@skiper-ui` kayıtlı); `shadcn add` ile kurmak yerine okuyup yazılır. + +### 11.4 "Web uygulaması" kabuğu + +- Ana ekrana eklenebilir: `src/app/manifest.ts` (`display: "standalone"`), ikonlar, tema rengi. +- Rotalar arası çapraz solma, üst çubuk yerinde (§6). +- Telefonda tek satır üst çubuk + baloncuk menü (§4.36). +- Durum çubukta yaşar: sıra hapı, liste halkası (§4.37). +- Ayrıntı her zaman alt sayfada; telefonda arama ve sıralama kapısı da alt sayfadır, + masaüstünde ortalı pencere (§4.10). + +### 11.5 Bitirmeden: deneyim kontrolü + +Yeni ya da yeniden düzenlenmiş her UI parçası için, ekrana bakarak (AGENTS.md): + +- [ ] En yakın referans vakayı (§11.1) açtım; işim onunla aynı *nedeni* taşıyor. +- [ ] Bu ekrandaki her eylemin görünür bir cevabı var (basma, sonuç, hata). +- [ ] Kullanıcının durumu (sıra / liste / giriş) bu ekranda yansıyor ya da gerekmiyor. +- [ ] Aynı anda iki hareket dikkat istemiyor; sahne varsa bir tane. +- [ ] Telefonda (375 px) deneyim gizlenmedi; sade karşılığı var. +- [ ] Hareketi azalt'ta bilgi eksilmiyor. +- [ ] Yay/eğri/süre `hareket.ts`'ten; turuncu CTA `variant="cta"`; yeni bağımlılık yok. +- [ ] Dış kaynaktan aldıysam §11.3'ün yedi adımı tamam, kaynak yorumu dosyada. diff --git a/eslint.config.mjs b/eslint.config.mjs index 00a7342..e4751bf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,8 @@ const eslintConfig = defineConfig([ "next-env.d.ts", // Paralel ajan worktree'leri (typegen çıktıları lint'e girmesin). ".claude/worktrees/**", + // Türkçe TTS sanal ortamı (scripts/seslendir.py); içindeki torch JS dosyaları lint'e girmesin. + ".venv-tts/**", ]), ]); diff --git a/next.config.ts b/next.config.ts index 35f5fe0..cfa44ec 100644 --- a/next.config.ts +++ b/next.config.ts @@ -50,6 +50,9 @@ const nextConfig: NextConfig = { // optimize listesinde yoktur; named import'lar doğrudan alt modüle // çevrilir (dev boot / build modül grafiği küçülür). optimizePackageImports: ["radix-ui"], + // Rotalar arası çapraz solma: React + tarayıcının View + // Transitions API'si (components/sayfa-gecisi.tsx, DESIGN.md §6). + viewTransition: true, ...(kaynakKisitli ? { // Turbopack'ın Rust tarafı NODE_OPTIONS heap sınırını görmez; bellek diff --git a/public/pwa-ikon-maskable.svg b/public/pwa-ikon-maskable.svg new file mode 100644 index 0000000..204ba04 --- /dev/null +++ b/public/pwa-ikon-maskable.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/pwa-ikon.svg b/public/pwa-ikon.svg new file mode 100644 index 0000000..dd1dd39 --- /dev/null +++ b/public/pwa-ikon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/scripts/eval/revizyon-arac.ts b/scripts/eval/revizyon-arac.ts index 5af55de..84425a1 100644 --- a/scripts/eval/revizyon-arac.ts +++ b/scripts/eval/revizyon-arac.ts @@ -32,7 +32,7 @@ import Database from "better-sqlite3"; import { argOku, evalAppDbHazirla, gitDurumu, kisalt, olcerekKos, sonucYaz, tablo, TOPLAM } from "./ortak"; import type { RaporParams, RaporSonuc, RevizyonKurali } from "@/features/rapor/types/rapor"; import type { KayitliMesaj } from "@/lib/ai/sohbet-gecmisi"; -import type { Anket } from "@/lib/ai/sohbet-meta"; +import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta"; import type { KoddanIslem } from "@/lib/ai/sohbet-akisi"; // ── QA kaydından alınan geçmiş parçaları (metinler birebir) ── @@ -114,6 +114,17 @@ type Senaryo = { kural?: Partial; /** Form cevabı senaryosu: cevaplanan form (route sonAnket'i verir). */ oncekiForm?: Anket; + /** Form cevabının yapısal hâli (route anketCevabiDogrula'dan); varsayılan formda modelsiz yol. */ + anketCevabi?: AnketCevaplari; +}; +/** Varsayılan form (revizyon-niyeti.ts varsayilanForm ile aynı adlar/değerler; kimlik sabit). */ +const VARSAYILAN_ANKET: Anket = { + id: "7c1f3d2e-0000-4000-8000-varsayilan01", + baslik: "Listeni nasıl değiştirelim?", + sorular: [ + { ad: "degisiklik", soru: "Ne değişsin?", coklu: true, serbest: "Başka bir şey…", secenekler: [{ deger: "il-ekle", etiket: "Başka bir il de olsun" }, { deger: "devlet", etiket: "Sadece devlet üniversiteleri" }, { deger: "vakif-olmasin", etiket: "Vakıf üniversiteleri çıksın" }, { deger: "guvenli", etiket: "Daha güvenli olsun (riski azaltalım)" }, { deger: "iddiali", etiket: "Daha iddialı olsun (hayal ağırlıklı)" }] }, + { ad: "adet", soru: "Kaç tercih etkilensin?", zorunlu: false, secenekler: [{ deger: "5", etiket: "İlk 5" }, { deger: "10", etiket: "İlk 10" }, { deger: "24", etiket: "Listenin tamamı" }] }, + ], }; const SON_KURAL: RaporParams["revizyon"] = { ilkTercihler: { adet: 5, iller: ["İZMİR"] }, iller: { ekle: ["ANKARA"] }, not: "İlk 5 tercih İzmir olsun." }; const SENARYOLAR: Senaryo[] = [ @@ -127,6 +138,15 @@ const SENARYOLAR: Senaryo[] = [ { id: "r08", aciklama: "2 geçmiş, il ekleme", gecmis: G2, soru: "Kocaeli de olsun", beklenen: "revizyon", revizyon: SON_KURAL, kural: { iller: { ekle: ["KOCAELİ"] } } }, { id: "r09", aciklama: "2 geçmiş, belirsiz istek → form", gecmis: G2, soru: "listemi biraz değiştirmek istiyorum", beklenen: "soru", revizyon: SON_KURAL }, { id: "r10", aciklama: "2 geçmiş, revizyon değil (kontrol)", gecmis: G2, soru: "listemdeki 1. programın kontenjanı kaç, geçen yıl kaç kişi yerleşmiş?", beklenen: "hicbiri", revizyon: SON_KURAL }, + // 10 Eki 2026 (Bilal: paketli hesapta "liste oluşturma" isteği koşmadı; QA B1 "listemi değiştir" → diger): + // genel istek form açmalı, "diger"e düşmemeli; "yeniden oluştur" regex kapısından geçmeli. + { id: "r11", aciklama: "genel istek, ne değişeceği yok → form (QA B1)", gecmis: G2, soru: "listemi değiştir", beklenen: "soru", revizyon: SON_KURAL }, + { id: "r12", aciklama: "listeyi baştan isteme → form", gecmis: [], soru: "listemi yeniden oluştur", beklenen: "soru" }, + { id: "r13", aciklama: "tercih güncelleme dili → form", gecmis: G2, soru: "tercihlerimi güncellemek istiyorum", beklenen: "soru", revizyon: SON_KURAL }, + { id: "r14", aciklama: "net çoklu kural tek cümlede", gecmis: [], soru: "yeni liste yap: ilk 10 tercihim İstanbul'dan olsun, vakıf olmasın", beklenen: "revizyon", kural: { ilkTercihler: { adet: 10, iller: ["İSTANBUL"] }, universiteTipi: "devlet" } }, + // Varsayılan formun cevabı modelsiz kurala çevrilir (niyet sn ≈ 0 beklenir) + { id: "r15", aciklama: "varsayılan form cevabı → modelsiz kural (il serbest metin + devlet, ilk 5)", gecmis: [...G2, U("listemi değiştir"), A("Birkaç kısa soru hazırladım; cevaplarsan listeni ona göre yeniden kurarım.", { anket: VARSAYILAN_ANKET })], soru: "Form cevaplarım:\n- Ne değişsin? → Başka bir il de olsun, Sadece devlet üniversiteleri, Kocaeli\n- Kaç tercih etkilensin? → İlk 5", beklenen: "revizyon", revizyon: SON_KURAL, oncekiForm: VARSAYILAN_ANKET, anketCevabi: { degisiklik: ["il-ekle", "devlet", "Kocaeli"], adet: "5" }, kural: { ilkTercihler: { adet: 5, iller: ["KOCAELİ"], universiteTipi: "devlet" }, iller: { ekle: ["KOCAELİ"] } } }, + { id: "r16", aciklama: "varsayılan form cevabı → modelsiz kural (güvenli, tamamı)", gecmis: [...G2, U("listemi değiştir"), A("Birkaç kısa soru hazırladım.", { anket: VARSAYILAN_ANKET })], soru: "Form cevaplarım:\n- Ne değişsin? → Daha güvenli olsun (riski azaltalım)\n- Kaç tercih etkilensin? → Listenin tamamı", beklenen: "revizyon", revizyon: SON_KURAL, oncekiForm: VARSAYILAN_ANKET, anketCevabi: { degisiklik: ["guvenli"], adet: "24" }, kural: { iskelet: "guvenli-agirlikli" } }, ]; /** `beklenen` kural parçası `gercek`in alt kümesi mi (diziler sırasız, büyük-küçük harf duyarsız). */ @@ -214,7 +234,7 @@ async function main() { let ozet: ReturnType | null = null; const t0 = Date.now(); const niyetOlcum = regex - ? await olcerekKos(() => revizyonNiyetiCikar({ mesaj: s.soru, params, oncekiForm: s.oncekiForm ?? null, gozlem: (g) => (niyetSaglayici = g.saglayici) })) + ? await olcerekKos(() => revizyonNiyetiCikar({ mesaj: s.soru, params, oncekiForm: s.oncekiForm ?? null, anketCevabi: s.anketCevabi ?? null, gozlem: (g) => (niyetSaglayici = g.saglayici) })) : null; const niyetMs = Date.now() - t0; if (niyetOlcum) { diff --git a/scripts/tasarim-denetim.ts b/scripts/tasarim-denetim.ts index 4f606ed..803bb7b 100644 --- a/scripts/tasarim-denetim.ts +++ b/scripts/tasarim-denetim.ts @@ -281,6 +281,35 @@ const KURALLAR: Kural[] = [ mesaj: "Hata/bildirim metninde 'Hay aksi/Eyvah/Oops' ya da 'buraya tıkla' yok; ne oldu + ne yapılacak (DESIGN.md §10.4)", bul: regexKurali(/Hay aksi|Eyvah|\bOops|\bUups|[Bb]uraya tıkla|[Tt]ıklayın(?:ız)?\b/), }, + { + ad: "yabanci-hareket-paketi", + seviye: "hata", + mesaj: + "Hareket yalnız motion ile; gsap/framer-motion isteyen dış bileşen motion ile yeniden yazılır (DESIGN.md §11.3)", + uiDahil: true, + bul: regexKurali(/from\s+["'](?:gsap|@gsap\/[\w-]+|framer-motion|lenis|matter-js|ogl|three)(?:\/[\w./-]+)?["']/), + }, + { + ad: "elle-cta", + seviye: "uyari", + mesaj: 'Turuncu CTA elle yazılmaz;
- {/* Hero → Problem geçişi: border yerine piksel ayraç */} -
- - - -
+ {/* Hero → Problem geçişi: border ya da ayraç yerine piksel şeridi. + Haritanın serpintisinden başlar, kaydırdıkça turuncuya ısınarak + bölümden bölüme iner (DESIGN.md §4.38). */} + {/* Problem */} -
+
{/* Başlık gövdeden hafif hızlı akar — katman derinliği */} @@ -322,13 +303,18 @@ export default function Home() { {/* Sahne kartı (DESIGN.md §4.31): üstte hero'nun piksel diliyle - kartın söylediğini çizen sahne, altında başlık ve metin. */} - + kartın söylediğini çizen sahne, altında başlık ve metin. + Sahneler sırayla oynar; karta gelince o sahne hemen oynar + (`data-sahne-karti`, sorun-sahneleri.tsx). */} + {problems.map((p, i) => ( -
+
- +

@@ -346,14 +332,10 @@ export default function Home() {

{/* Problem → Nasıl çalışır geçişi */} -
- - - -
+ {/* How it works */} -
+
@@ -362,7 +344,7 @@ export default function Home() { {/* Kuralların şeffaf anlatımına köprü: /meraklisina */} Meraklısına {/* Adımlar kartsız (DESIGN.md §4.32): numara hero'nun piksel - diliyle iri rakam, md'den itibaren rakamlar düz çizgiyle - birbirine bağlanır. */} - -
    - {steps.map((s, i) => ( -
  1. - - - {i < steps.length - 1 ? ( - - ) : null} -

    - {s.step}. adım: - {s.title} -

    -

    - {s.description} -

    -
    -
  2. - ))} -
+ diliyle iri rakam; her rakam sırayla adımının biçimine dönüşüp + geri toplanır, md'den itibaren aradaki düz çizgi dolar. */} + + {/* Veri akışı + veriye dayalı güven + dürüstlük notu (DESIGN.md @@ -416,10 +374,10 @@ export default function Home() { ekranda solda dürüstlük notu (sola yaslı), ortada animasyon, sağda (YÖK Atlas girdilerinin yanında) üç sayı (sağa yaslı). lg altında hepsi ortalı; sıra: animasyon → sayılar → not. */} - + @@ -432,7 +390,7 @@ export default function Home() { {stat.label}
- {stat.value} + {stat.birim ? ( {stat.birim} @@ -463,14 +421,10 @@ export default function Home() {
{/* Nasıl çalışır → Karşılaştırma geçişi */} -
- - - -
+ {/* Comparison */} -
+
@@ -488,7 +442,7 @@ export default function Home() { üstte yapışık durur (site başlığının hemen altında: telefonda 96 px, sm'den itibaren 128 px — ölçüldü). lg'de klasik dört sütunlu tablo. */} - +
    - {comparison.map((row) => ( -
  • + {comparison.map((row, i) => ( +
  • {row.feature}

    @@ -551,10 +509,11 @@ export default function Home() { - {comparison.map((row) => ( + {comparison.map((row, i) => ( {row.feature} @@ -582,14 +541,10 @@ export default function Home() {
{/* Karşılaştırma → Fiyat geçişi */} -
- - - -
+ {/* Fiyat */} -
+
@@ -610,7 +565,7 @@ export default function Home() { - + + + + {acik ? ( + + + + + + Menü + + {/* Üst sıra: çubuğun yerinde. Önce imza (logo bir tur döner), + içerik ardından gelir (§11.2). */} +
+ + + Kolay + Tercih + + + + +
+ + +
+
+ ) : null} +
+ + ); +} + +/** İki çizgi ↔ çarpı. Açık hâl perdede ayrı düğmede çizildiği için burada + * geçiş yok; iki hâl aynı ölçüde (§4.3 çarpı ölçüsü). */ +function MenuCizgileri({ acik }: { acik: boolean }) { + return ( + + + + + ); +} diff --git a/src/components/piksel-morf.tsx b/src/components/piksel-morf.tsx new file mode 100644 index 0000000..31cf86c --- /dev/null +++ b/src/components/piksel-morf.tsx @@ -0,0 +1,145 @@ +"use client"; + +// Piksel morf (DESIGN.md §4.38): piksel sahnenin (§4.31) canlı hâli. Sahne +// sabit bir piksel kümesidir; her pikselin kimliği vardır ve kareden kareye +// yeni hücresine süzülür, turuncuya ısınır ya da sahneden çekilir. Logonun üç +// karesinin yaptığını (§4.34) hero'nun ızgarasında, çok pikselle yapar. +// +// Bilerek aptal bir ilkel: hangi pikselin nerede, hangi tonda duracağını +// çağıran söyler (`pikseller`), hareketin tamamı CSS geçişidir (globals.css +// `.kt-morf-px`). Zamanlamayı, tekrarı ve görünürlüğü de çağıran yönetir. +// +// Doku pikselleri imleç ısı fırçasına katılır (usePixelHeat `canli`): fırça +// konumu her taramada okur, o yüzden yer değiştiren piksel doğru ısınır. +// Turuncu vurgu, doku pikselinin üstüne binen ikinci bir karedir — fırça +// alttaki karenin rengini sürerken vurgu ona karışmaz; vurgu yanarken alttaki +// kare söner (turuncu, mavinin üstünde bulanmasın). + +import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { PIKSEL } from "@/components/pixel-decor"; +import { pikselIsiTazele, usePixelHeat } from "@/components/use-pixel-heat"; +import { cn } from "@/lib/utils"; + +export type MorfTon = + /** Hero haritasının mavi dokusu */ + | "doku" + /** Aynı doku yarı opaklıkta (dolgu, arka plan) */ + | "silik" + /** Turuncu vurgu; hero'daki şehirler gibi nabız atar */ + | "vurgu" + /** Sahnede yok: küçülüp söner, yerinde bekler */ + | "gizli"; + +export type MorfPiksel = { + /** Kareler arası kimlik: aynı `id` aynı pikseldir, yeni yerine süzülür. */ + id: string; + /** Hücre koordinatı (buçuklu olabilir: ortalanmış küme). */ + x: number; + y: number; + ton: MorfTon; + /** Doku opaklığı (0–1). Kimliğe bağlıdır; piksel taşınınca değişmez. */ + op: number; + /** Bu karedeki geçişin gecikmesi (ms): sırayla ısınma, sırayla kalkış. */ + gecikme?: number; +}; + +const { hucre: CELL, nokta: DOT, bosluk: INSET } = PIKSEL; + +export function PikselMorf({ + pikseller, + sutun, + satir, + yaricap = 26, + firca = true, + className, + style, +}: { + /** Sıra kareler arasında sabit kalmalı (DOM yer değiştirirse geçiş kopar). */ + pikseller: readonly MorfPiksel[]; + sutun: number; + satir: number; + /** Isı fırçasının yarıçapı (viewBox birimi; hücre 10) */ + yaricap?: number; + /** `false`: bu karede fırça kapalı — turuncunun anlam taşıdığı biçimde + * (ör. "sen buradasın" karesi) imleç ısısı ona karışmasın. */ + firca?: boolean; + className?: string; + /** Süre ayarı için: `--morf-sure` (kayma), varsayılan 0,55 sn */ + style?: CSSProperties; +}) { + const svgRef = useRef(null); + usePixelHeat(svgRef, { radius: yaricap, canli: true }); + + // Pikseller yer değiştirdi: imleç yerinde dursa da fırça yeniden baksın. + useEffect(() => { + pikselIsiTazele(); + }, [pikseller, firca]); + + // Turuncu üst kare yalnız bir kez vurgu olmuş piksele eklenir ve orada + // kalır: sönerken de DOM'da olmalı ki soğuma geçişi oynasın. + const [vurguGordu, setVurguGordu] = useState( + () => new Set(pikseller.filter((p) => p.ton === "vurgu").map((p) => p.id)), + ); + if (pikseller.some((p) => p.ton === "vurgu" && !vurguGordu.has(p.id))) { + const genis = new Set(vurguGordu); + for (const p of pikseller) if (p.ton === "vurgu") genis.add(p.id); + setVurguGordu(genis); + } + + return ( + + ); +} diff --git a/src/components/piksel-serit.tsx b/src/components/piksel-serit.tsx new file mode 100644 index 0000000..0e7b3fa --- /dev/null +++ b/src/components/piksel-serit.tsx @@ -0,0 +1,75 @@ +"use client"; + +// Piksel şeridi (DESIGN.md §4.38): hero haritasından dökülen serpintinin tek +// sıraya inmiş hâli. Dikey bir piksel sütunudur ve kaydırmaya bağlıdır: +// ekranın okuma çizgisini (varsayılan: yüksekliğin %65'i) geçen her piksel +// turuncuya ısınır, geri kaydırınca yavaşça soğur. Animasyon yoktur; şeridi +// kullanıcının parmağı çizer (Parallax gibi kaydırmayla 1:1). +// +// Renk geçişi CSS'tir (globals.css `.kt-serit-px`): çabuk ısınma, yavaş soğuma +// — ısı fırçasıyla aynı asimetri. Hareketi azalt'ta geçiş anlıktır. + +import { useRef, useState, type CSSProperties } from "react"; +import { useMotionValueEvent, useScroll } from "motion/react"; +import { PIKSEL, dokuOpakligi } from "@/components/pixel-decor"; +import { cn } from "@/lib/utils"; + +const { hucre: CELL, nokta: DOT, bosluk: INSET } = PIKSEL; + +export function PikselSerit({ + uzunluk, + okuma = 0.65, + tohum = 0, + hucreSinifi, + className, + style, +}: { + /** Şeridin boyu (hücre) */ + uzunluk: number; + /** Okuma çizgisi: ekran yüksekliğinin oranı (0 üst, 1 alt) */ + okuma?: number; + /** Doku deseni; yan yana şeritler aynı görünmesin */ + tohum?: number; + /** Sıra başına ek sınıf: dar boşlukta içeriğe değecek pikseli gizlemek + * için (ör. `max-sm:hidden`). */ + hucreSinifi?: readonly (string | undefined)[]; + className?: string; + style?: CSSProperties; +}) { + const kapRef = useRef(null); + const [yanan, setYanan] = useState(0); + // 0: şeridin tepesi okuma çizgisinde; 1: dibi okuma çizgisinde. + const { scrollYProgress } = useScroll({ + target: kapRef, + offset: [`start ${okuma}`, `end ${okuma}`], + }); + useMotionValueEvent(scrollYProgress, "change", (ilerleme) => { + setYanan(Math.round(ilerleme * uzunluk)); + }); + + return ( + + ); +} diff --git a/src/components/pixel-decor.tsx b/src/components/pixel-decor.tsx index 4807897..baf5228 100644 --- a/src/components/pixel-decor.tsx +++ b/src/components/pixel-decor.tsx @@ -39,6 +39,12 @@ const hash = (x: number, y: number, seed: number) => { return n - Math.floor(n); }; +// Kendi sahnesini üreten bileşenler (PikselMorf ve sahneleri) aynı ızgarayı, +// dokuyu ve sözde-rastgeleyi buradan alır; ölçü ikinci bir yerde yazılmaz. +export const PIKSEL = { hucre: CELL, nokta: DOT, bosluk: INSET } as const; +export const dokuOpakligi = opacityFor; +export const pikselHash = hash; + function pixelRect( x: number, y: number, @@ -551,7 +557,7 @@ export function PixelSahne({ } // 5×7 dot-matrix rakamlar. Yeni bir rakam gerekirse ızgarası buraya eklenir. -const RAKAMLAR = { +export const RAKAMLAR = { 1: ["..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."], 2: [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"], 3: [".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."], diff --git a/src/components/sayfa-gecisi.tsx b/src/components/sayfa-gecisi.tsx new file mode 100644 index 0000000..0d2d561 --- /dev/null +++ b/src/components/sayfa-gecisi.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { ViewTransition, type ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +/** + * Rotalar arası kısa çapraz solma (DESIGN.md §6, §11.4): sayfa "belge" gibi + * bir anda değişmez, eski içerik söner yenisi belirir. Yalnız yol değişince + * oynar (`key`): aynı sayfadaki güncellemeler (arama, sıralama, Suspense) + * `default="none"` ile sessiz kalır. Üst çubuk geçişe katılmaz, yerinde durur + * (globals.css `.kt-ust-cubuk`). Süre ve eğri globals.css'te + * (`.kt-sayfa-gir` / `.kt-sayfa-cik`); hareketi azalt'ta kapalı. Tarayıcı + * View Transitions bilmiyorsa sayfa eskisi gibi düz değişir. + */ +export function SayfaGecisi({ children }: { children: ReactNode }) { + const yol = usePathname(); + return ( + + {children} + + ); +} diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx index d06ea03..453a5a9 100644 --- a/src/components/site-footer.tsx +++ b/src/components/site-footer.tsx @@ -25,8 +25,10 @@ export function SiteFooter() { .sort((a, b) => b.programSayisi - a.programSayisi) .slice(0, 8); + // data-site-footer: layout'taki #icerik alt boşluğunu (pb-28) kapatır — + // dev yazı logosu ekranın altına yapışsın. return ( -
+
+ {/* Dev yazı logosu: header'daki "KolayTercih" (Bricolage, ince + yarı + kalın) kenardan kenara, alttan kırpılmış ve aşağı doğru sönerek. + SVG + textLength: yazı her genişlikte kabı tam doldurur, font + ölçüsüne göre taşmaz. Renkler slate token'ı — koyu temada kendiliğinden + döner. Süs: ekran okuyucuya kapalı. */} + + + + + + Kolay + Tercih + +
); } diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 3f63331..8c4ea01 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -8,7 +8,7 @@ import { getRehberAramaKayitlari } from "@/lib/rehber"; export function SiteHeader() { return ( -
+
{/* lg altında 2 satır: logo+UserNav / sıralama+ara. lg+: tek satır, ortada nav. */}
{/* Sayfa başlığı yuvası (NavBaslik): bir sayfa 'e diff --git a/src/components/tema-ikonu.tsx b/src/components/tema-ikonu.tsx new file mode 100644 index 0000000..cd64ca1 --- /dev/null +++ b/src/components/tema-ikonu.tsx @@ -0,0 +1,54 @@ +import { useId } from "react"; + +// Tema seçicinin ikonları (DESIGN.md §2.3): tema geçişinde header'daki logonun +// dönüştüğü biçimlerin durağan hâli (animasyonlu-mark.tsx `temaSekli`). +// - "light": güneş — turuncu daire, arkasında şeftali hale. +// - "dark": ay — şeftali hilal, iki ucunun ortasında mavi bir yıldız. +// - "system": logonun üç karesi — ikisine de dönüşebilen hâl. +// Renkler logonun renkleridir ve iki temada aynıdır (§2.3); 180'lik kutu +// public/kolay-tercih-mark.svg ile aynı. Dekoratiftir: anlamı yanındaki +// etiket taşır. + +const MAVI = "#3b82f6"; +const SEFTALI = "#fdc7a7"; +const TURUNCU = "#ff5a00"; + +export type TemaIkonuTuru = "light" | "dark" | "system"; + +export function TemaIkonu({ + tur, + className, +}: { + tur: TemaIkonuTuru; + className?: string; +}) { + const maskeId = useId(); + return ( + + ); +} diff --git a/src/components/tema-kisayolu.tsx b/src/components/tema-kisayolu.tsx new file mode 100644 index 0000000..bcac8b4 --- /dev/null +++ b/src/components/tema-kisayolu.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; +import { useTheme } from "next-themes"; +import { temayiGecisle } from "@/lib/tema-gecisi"; + +/** Tuş yazı alanında basıldıysa kısayol değildir. */ +function yaziAlanindaMi(hedef: EventTarget | null) { + if (!(hedef instanceof HTMLElement)) return false; + if (hedef.isContentEditable) return true; + const etiket = hedef.tagName; + return etiket === "INPUT" || etiket === "TEXTAREA" || etiket === "SELECT"; +} + +/** + * 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 yumuşak bir halka gibi yayılır. + */ +export function TemaKisayolu() { + const { resolvedTheme, setTheme } = useTheme(); + + useEffect(() => { + const dinle = (e: KeyboardEvent) => { + if (e.key !== "d" && e.key !== "D") return; + if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return; + if (yaziAlanindaMi(e.target)) return; + e.preventDefault(); + const hedef = resolvedTheme === "dark" ? "light" : "dark"; + temayiGecisle(() => setTheme(hedef)); + }; + window.addEventListener("keydown", dinle); + return () => window.removeEventListener("keydown", dinle); + }, [resolvedTheme, setTheme]); + + return null; +} diff --git a/src/components/tema-saglayici.tsx b/src/components/tema-saglayici.tsx index 452e129..aeb4462 100644 --- a/src/components/tema-saglayici.tsx +++ b/src/components/tema-saglayici.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { ThemeProvider, useTheme } from "next-themes"; +import { TemaKisayolu } from "@/components/tema-kisayolu"; // Gövde zemini (bg-slate-50) — açık ve koyu temadaki karşılığı const ZEMIN = { light: "#f8fafc", dark: "#080e19" }; @@ -25,7 +26,8 @@ function AdresCubuguRengi() { * Tema (DESIGN.md §2.3): 'e `dark` sınıfı yazılır, renkler * 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. + * tarayıcıda saklanır; `D` tuşu açık ↔ koyu geçişi yapar (TemaKisayolu). + * 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 ( @@ -37,6 +39,7 @@ export function TemaSaglayici({ children }: { children: React.ReactNode }) { disableTransitionOnChange > + {children} ); diff --git a/src/components/tema-secici.tsx b/src/components/tema-secici.tsx index 9290305..f13eac5 100644 --- a/src/components/tema-secici.tsx +++ b/src/components/tema-secici.tsx @@ -2,13 +2,24 @@ import { useSyncExternalStore } from "react"; import { useTheme } from "next-themes"; -import { Monitor, Moon, Sun } from "lucide-react"; +import { TemaIkonu } from "@/components/tema-ikonu"; +import { Kbd } from "@/components/ui/kbd"; import { SecimHaplari } from "@/components/ui/secim-haplari"; +import { temayiGecisle } from "@/lib/tema-gecisi"; +import { cn } from "@/lib/utils"; + +// İkonlar geçişte logonun dönüştüğü biçimlerin durağan hâlidir (DESIGN.md +// §4.34): güneş, ay ve logonun kendisi. SecimHaplari ikonu bileşen olarak +// ister; her tür için ince bir sarmalayıcı. +type IkonProps = { className?: string }; +const Gunes = ({ className }: IkonProps) => ; +const Ay = ({ className }: IkonProps) => ; +const Sistem = ({ className }: IkonProps) => ; const SECENEKLER = [ - { deger: "light", etiket: "Açık", ikon: Sun }, - { deger: "dark", etiket: "Koyu", ikon: Moon }, - { deger: "system", etiket: "Sistem", ikon: Monitor }, + { deger: "light", etiket: "Açık", ikon: Gunes }, + { deger: "dark", etiket: "Koyu", ikon: Ay }, + { deger: "system", etiket: "Sistem", ikon: Sistem }, ] as const; type Tema = (typeof SECENEKLER)[number]["deger"]; @@ -18,9 +29,18 @@ 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). Koyu vurgu seçenekten - * seçeneğe kayar (secim-haplari.tsx, kapsül varyantı). + * seçeneğe kayar (secim-haplari.tsx, kapsül varyantı). Seçim logodan yumuşak + * bir halka gibi yayılarak uygulanır, ardından logo yeni temanın biçimine + * dönüşür (lib/tema-gecisi.ts, §4.34); `ipucu` ile altında `D` kısayolu durur. */ -export function TemaSecici({ className }: { className?: string }) { +export function TemaSecici({ + className, + ipucu = true, +}: { + className?: string; + /** Altındaki klavye kısayolu satırı; dokunmatik menüde kapatılır. */ + ipucu?: boolean; +}) { const { theme, setTheme } = useTheme(); const tarayicida = useSyncExternalStore( aboneOlma, @@ -32,14 +52,21 @@ export function TemaSecici({ className }: { className?: string }) { : null; return ( - - ariaLabel="Tema" - secenekler={SECENEKLER} - deger={secili} - onChange={setTheme} - varyant="kapsul" - boyut="md" - className={className} - /> +
+ + ariaLabel="Tema" + secenekler={SECENEKLER} + deger={secili} + onChange={(deger) => temayiGecisle(() => setTheme(deger))} + varyant="kapsul" + boyut="md" + /> + {ipucu ? ( +

+ D + ile açık / koyu arasında geç +

+ ) : null} +
); } diff --git a/src/components/ui/acilis-sahnesi.tsx b/src/components/ui/acilis-sahnesi.tsx index 31efb96..6fb1336 100644 --- a/src/components/ui/acilis-sahnesi.tsx +++ b/src/components/ui/acilis-sahnesi.tsx @@ -9,7 +9,9 @@ // - 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. +// `[data-sonra]` bloklar netleşerek gelir. sahnenin kardeşi bir `[data-logo-hedef]` varsa kareler +// sönmek yerine küçülerek o yazıya (çekmece başlığı) uçar, yazı onlardan +// netleşir (Bilal, 10 Eki 2026). // "Hareketi azalt"ta logo ve büyüme yok, yalnız 0,25 s solma (§6). Veri // bekleyen çekmecede (`hazir` verilmiş) sahne yalnız veri ilk geldiğinde // (false → true) oynar; önbellekli yeniden açılışta aynı 0,25 s solma @@ -18,6 +20,7 @@ import { useLayoutEffect, useRef, type ReactNode } from "react"; import { animate, useReducedMotion } from "motion/react"; +import { EGRI } from "@/lib/hareket"; // Logodaki üç kare (public/kolay-tercih-mark.svg, 180'lik viewBox): solda // mavi, sağ üstte şeftali, sağ altta turuncu. animasyonlu-mark.tsx ile aynı. @@ -37,8 +40,8 @@ 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; +const GUCLU_CIKIS = EGRI.gucluCikis; +const YERLESME = EGRI.yerlesme; // Kartsız mod (saniye): logo karesi belir → dur → sön; kareler 0,05 s arayla // gelir, içerik blokları sönmeyle çaprazlanarak 0,05 s arayla netleşir. @@ -55,6 +58,22 @@ const LOGO_ZAMANLARI = [ (LOGO_BELIRME + LOGO_DURUS) / LOGO_SURE, 1, ]; +// Hedefli kartsız mod: kareler sönmek yerine LOGO_UCUS süresince hedef yazıya +// uçar; uçuşun son yarısında solar, yazı o sırada netleşir. +const LOGO_UCUS = 0.45; +const UCUS_SURE = LOGO_BELIRME + LOGO_DURUS + LOGO_UCUS; +const UCUS_ZAMANLARI = [ + 0, + LOGO_BELIRME / UCUS_SURE, + (LOGO_BELIRME + LOGO_DURUS) / UCUS_SURE, + 1, +]; +const UCUS_SOLMA_ZAMANLARI = [ + 0, + LOGO_BELIRME / UCUS_SURE, + (LOGO_BELIRME + LOGO_DURUS + LOGO_UCUS * 0.5) / UCUS_SURE, + 1, +]; const KARTSIZ_SLOT_ADIMI = 0.05; const KARTSIZ_ICERIK_BASI = 0.6; const KARTSIZ_BLOK_ADIMI = 0.05; @@ -63,14 +82,21 @@ const KARTSIZ_NETLESME = 0.35; /** Kartsız modun logo kareleri, belirme sırasıyla (`data-logo-kare` değerleri). */ export const LOGO_SLOTLARI = SLOT_SIRASI; +/** + * Kartsız modun logo kabının kenarı (px) = `size-44`. Kartlı moddaki logoyla + * (akademik göstergeler, kare alanının yarısı, en çok 200 px) aynı ölçekte; + * 375 px'lik telefonda da sığar. + */ +export const LOGO_KAP_BOYU = 176; + /** * 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: - * ``. + * (`data-logo` kabı `relative mx-auto size-44` → 176/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) { +export function logoKareStili(slot: Slot, kapBoyu = LOGO_KAP_BOYU) { const olcek = kapBoyu / 180; return { left: LOGO[slot].x * olcek, @@ -100,6 +126,10 @@ export function logoKareStili(slot: Slot, kapBoyu = 64) { * açıldı): sahne atlanır, içerik 0,25 s solmayla gelir — kareler o sırada * çekmece daha ekrana kaymadan oynuyor, gövde boş kalıyordu. Verilmezse * (içerik sunucudan hazır gelen çekmece) sahne her montajda oynar. + * + * `data-logo-hedef`: kartsız modda logo karelerinin küçülerek dönüşeceği yazı + * (sahnenin kardeşi, ör. çekmece başlığı). Kareler yazının üç eşit dilimine + * uçar; yazı sahne başında görünmez olur, kareler varırken netleşir. */ export function AcilisSahnesi({ children, @@ -194,12 +224,52 @@ export function AcilisSahnesi({ // iner ki altında boşluk kalmasın; içerik KARTSIZ_ICERIK_BASI'ndan // itibaren, sönmeyle çaprazlanarak gelir. let sonKare = 0; + // Hedef yazının kutusu (blok değil, yazının kendisi: Range). + const hedef = + kap.parentElement?.querySelector("[data-logo-hedef]") ?? null; + let hedefKutu: DOMRect | null = null; + if (hedef) { + const aralik = document.createRange(); + aralik.selectNodeContents(hedef); + hedefKutu = aralik.getBoundingClientRect(); + } logoKareleri.forEach((el) => { const slot = (el.dataset.logoKare ?? "sol") as Slot; - const gecikme = BASLANGIC + SLOT_SIRASI.indexOf(slot) * KARTSIZ_SLOT_ADIMI; + const sira = SLOT_SIRASI.indexOf(slot); + const gecikme = BASLANGIC + sira * KARTSIZ_SLOT_ADIMI; sonKare = Math.max(sonKare, gecikme); el.style.opacity = "0"; dokunulan.push(el); + if (hedefKutu) { + // Kare ile hedef aynı çekmecede: çekmece kayarken fark değişmez. + const k = el.getBoundingClientRect(); + const dx = + hedefKutu.left + (hedefKutu.width * (sira + 0.5)) / 3 - (k.left + k.width / 2); + const dy = hedefKutu.top + hedefKutu.height / 2 - (k.top + k.height / 2); + kontroller.push( + animate( + el, + { x: [0, 0, 0, dx], y: [0, 0, 0, dy], scale: [0.4, 1, 1, 0.2] }, + { + duration: UCUS_SURE, + delay: gecikme, + times: UCUS_ZAMANLARI, + ease: [GUCLU_CIKIS, "linear", YERLESME], + }, + ), + animate( + el, + { opacity: [0, 1, 1, 0] }, + { + duration: UCUS_SURE, + delay: gecikme, + times: UCUS_SOLMA_ZAMANLARI, + ease: [GUCLU_CIKIS, "linear", "easeIn"], + }, + ), + ); + return; + } kontroller.push( animate( el, @@ -218,10 +288,13 @@ export function AcilisSahnesi({ }); const sonmeBasi = sonKare + LOGO_SURE * LOGO_ZAMANLARI[2]; dokunulan.push(logo!); + if (hedef && hedefKutu) { + netlestir(hedef, sonmeBasi + LOGO_UCUS * 0.4, 0.4); + } const kapat = animate( logo!, { height: [logo!.offsetHeight, 0] }, - { duration: LOGO_SONME, delay: sonmeBasi, ease: YERLESME }, + { duration: hedefKutu ? LOGO_UCUS : LOGO_SONME, delay: sonmeBasi, ease: YERLESME }, ); kapat.then(() => { logo!.style.display = "none"; diff --git a/src/components/ui/adim-gostergesi.tsx b/src/components/ui/adim-gostergesi.tsx new file mode 100644 index 0000000..87dc4f2 --- /dev/null +++ b/src/components/ui/adim-gostergesi.tsx @@ -0,0 +1,177 @@ +"use client"; + +// Çok adımlı akışların tek göstergesi (DESIGN.md §4.35, §10.2): numaralı +// daireler ince çizgiyle bağlı; aktif adım mavi dolu, tamamlanan adımda tik +// kendini çizer, aradaki çizgi soldan dolar. Geri gidince aynı yoldan boşalır. +// +// Esin: React Bits "Stepper" (reactbits.dev, MIT + Commons Clause). Kod +// KolayTercih diline çevrildi: sabit renkler token'a, zamanlama +// `lib/hareket.ts`'e bağlandı; kontrollü hâle getirildi (içerik geçişi +// `TransitionPanel` + `lib/adim-gecisi.ts`'te kalır), `aria-current`, +// durum duyurusu ve hareketi azalt dalı eklendi. Turuncu yok: gösterge +// bilgidir, aksiyon değil (§2). + +import { Fragment } from "react"; +import { motion, useReducedMotion } from "motion/react"; +import { SURE, YAY } from "@/lib/hareket"; +import { cn } from "@/lib/utils"; + +type Durum = "bekliyor" | "aktif" | "tamam"; + +export function AdimGostergesi({ + adimlar, + aktif, + onAdimSec, + ariaLabel = "Adımlar", + className, +}: { + /** Adım adları, sırayla. Sayı gerçek adım sayısıdır (§10.2). */ + adimlar: readonly string[]; + /** 0 tabanlı aktif adım. */ + aktif: number; + /** Verilirse yalnız TAMAMLANMIŞ adımlar tıklanabilir olur (ileri atlama yok). */ + onAdimSec?: (adim: number) => void; + ariaLabel?: string; + className?: string; +}) { + const azalt = useReducedMotion(); + const anlik = { duration: 0 }; + + return ( +
+
    (i === 0 ? "auto" : "1fr auto")) + .join(" "), + }} + > + {adimlar.map((ad, i) => { + const durum: Durum = i < aktif ? "tamam" : i === aktif ? "aktif" : "bekliyor"; + const tiklanabilir = !!onAdimSec && durum === "tamam"; + const daire = ( + + {/* Numara ile tik aynı yuvada; biri çıkarken öbürü çizilir. */} + + {i + 1} + + + + + + ); + const etiket = ( + + {ad} + + ); + return ( + + {i > 0 ? ( + // Çizgi dairenin ortasında (size-9 → 18 px); dolum transform ile. +
  1. + +
  2. + ) : null} +
  3. + {tiklanabilir ? ( + + ) : ( + <> + {daire} + {etiket} + {durum === "tamam" ? (tamamlandı) : null} + + )} +
  4. +
    + ); + })} +
+

+ Adım {Math.min(aktif + 1, adimlar.length)} / {adimlar.length}: {adimlar[Math.min(aktif, adimlar.length - 1)]} +

+
+ ); +} + +/** Lazy yüklenen akışın iskeleti: göstergenin kutusunu birebir tutar (yerleşim sıçramaz). */ +export function AdimGostergesiIskeleti({ adet = 3 }: { adet?: number }) { + return ( +
(i === 0 ? "auto" : "1fr auto")).join(" "), + }} + > + {Array.from({ length: adet }, (_, i) => ( + + {i > 0 ? : null} + + + + + + ))} +
+ ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 3a4c594..699b894 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -19,6 +19,11 @@ const buttonVariants = cva( destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20", link: "text-primary underline-offset-4 hover:underline", + // DESIGN.md §4.1: funnel CTA'sının tek kaynağı. Basma tepkisi ölçekle + // (tabandaki 1 px kayma bu iki varyantta kapalı; ikisi birlikte titrer). + cta: "cursor-pointer bg-orange-500 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.97] motion-reduce:active:scale-100", + ikincil: + "cursor-pointer border-border bg-background transition-[background-color,border-color,color,transform] duration-150 hover:border-orange-500 hover:bg-orange-50/60 hover:text-orange-500 active:not-aria-[haspopup]:translate-y-0 active:scale-[0.97] motion-reduce:active:scale-100", }, size: { default: diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 21e4ac8..7d2f548 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -61,7 +61,11 @@ function DialogContent({ - + )}
@@ -125,8 +133,9 @@ function DialogTitle({ return ( { deger: T; diff --git a/src/components/use-pixel-heat.ts b/src/components/use-pixel-heat.ts index 5701bc0..daadfe0 100644 --- a/src/components/use-pixel-heat.ts +++ b/src/components/use-pixel-heat.ts @@ -13,6 +13,10 @@ * modül seviyesinde TEK pointermove dinleyicisi + rAF birleştirmesi kullanılır; * kare başına en fazla bir tarama yapılır, imleçten uzak örnekler hiç * piksel iterasyonuna girmez. + * + * `canli`: pikselleri yer ve rol değiştiren sahneler (PikselMorf) içindir. + * Konum ve taban opaklık mount'ta kopyalanmaz, her taramada DOM'dan okunur; + * `data-px`'i kalkan piksel (turuncu vurgu oldu ya da gizlendi) fırçadan çıkar. */ import { useEffect, type RefObject } from "react"; @@ -46,6 +50,8 @@ type HeatInstance = { /** preserveAspectRatio "slice" mi (kabı doldurur) */ slice: boolean; baseColor: string; + /** Pikseller yer/rol değiştirir: konum ve opaklık her taramada okunur */ + canli: boolean; pixels: HeatTarget[]; hot: Set; controls: Map; @@ -99,8 +105,17 @@ function tara() { const x = vb.x + (e.clientX - rect.left - ox) / scale; const y = vb.y + (e.clientY - rect.top - oy) / scale; for (const p of inst.pixels) { - const d2 = (p.cx - x) ** 2 + (p.cy - y) ** 2; - const isHot = d2 <= inst.radius * inst.radius; + let { cx, cy, baseOpacity } = p; + let katilir = true; + if (inst.canli) { + const veri = p.el.dataset; + katilir = veri.px !== undefined; + cx = Number(veri.cx); + cy = Number(veri.cy); + baseOpacity = Number(veri.op); + } + const d2 = (cx - x) ** 2 + (cy - y) ** 2; + const isHot = katilir && d2 <= inst.radius * inst.radius; if (isHot && !inst.hot.has(p.el)) { inst.hot.add(p.el); calistir( @@ -114,7 +129,7 @@ function tara() { calistir( inst, p.el, - { fill: inst.baseColor, fillOpacity: p.baseOpacity }, + { fill: inst.baseColor, fillOpacity: baseOpacity }, COOL_DURATION, ); } @@ -122,6 +137,14 @@ function tara() { } } +/** Pikseller yer değiştirdikten sonra (PikselMorf kare değişimi) fırçayı son + * imleç konumuyla yeniden çalıştırır: imleç yerinde dursa da ısı güncel kalır. */ +export function pikselIsiTazele() { + if (sonOlay && instances.size > 0 && !rafId) { + rafId = requestAnimationFrame(tara); + } +} + function onMove(e: PointerEvent) { sonOlay = e; if (!rafId) rafId = requestAnimationFrame(tara); @@ -201,7 +224,9 @@ export function usePixelHeat( radius = 34, /** İmleç olmadan da rastgele pikseller seyrekçe ısınıp sönsün (hero) */ kirpisma = false, - }: { radius?: number; kirpisma?: boolean } = {}, + /** Pikseller yer/rol değiştiriyor (PikselMorf): konum her taramada okunur */ + canli = false, + }: { radius?: number; kirpisma?: boolean; canli?: boolean } = {}, ) { useEffect(() => { const svg = svgRef.current; @@ -216,8 +241,9 @@ export function usePixelHeat( if (!svg.viewBox.baseVal.width) return; + // Canlı sahnede `data-px` kareden kareye açılıp kapanır; hepsi toplanır. const pixels: HeatTarget[] = Array.from( - svg.querySelectorAll("[data-px]"), + svg.querySelectorAll(canli ? "[data-cx]" : "[data-px]"), ).map((el) => ({ el, cx: Number(el.dataset.cx), @@ -231,6 +257,7 @@ export function usePixelHeat( radius, slice: (svg.getAttribute("preserveAspectRatio") ?? "").includes("slice"), baseColor: getComputedStyle(svg).color, + canli, pixels, hot: new Set(), controls: new Map(), @@ -241,5 +268,5 @@ export function usePixelHeat( kirpismaDur?.(); ayril(inst); }; - }, [svgRef, radius, kirpisma]); + }, [svgRef, radius, kirpisma, canli]); } diff --git a/src/features/katalog/components/katalog-arama.tsx b/src/features/katalog/components/katalog-arama.tsx index 35aad40..3d8c5fc 100644 --- a/src/features/katalog/components/katalog-arama.tsx +++ b/src/features/katalog/components/katalog-arama.tsx @@ -43,6 +43,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; +import { SayiRulo } from "@/components/ui/sayi-rulo"; +import { ARAMA_AC_OLAYI } from "@/components/mobil-menu"; import { ProgressiveBlur } from "@/components/ui/skiper-ui/skiper41"; type BolumSonucu = { @@ -172,6 +174,13 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { // modal her açılıp kapandığında remove/add döngüsü dönmesin // (client-event-listeners) const kisayolTetiklendi = useEffectEvent(() => modalDegisti(!acik)); + // Telefon menüsündeki "Ara" hapı (mobil-menu.tsx) pencereyi olayla açtırır. + const aramaIstendi = useEffectEvent(() => modalDegisti(true)); + useEffect(() => { + window.addEventListener(ARAMA_AC_OLAYI, aramaIstendi); + return () => window.removeEventListener(ARAMA_AC_OLAYI, aramaIstendi); + }, []); + useEffect(() => { function kisayolDinle(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { @@ -279,9 +288,7 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { > - - {profil.sira.toLocaleString("tr-TR")} - + {" "} · {PUAN_TURU_ETIKET[profil.tur] ?? profil.tur} @@ -331,58 +338,42 @@ export function KatalogArama({ rehberler }: { rehberler: RehberAramaKaydi[] }) { - {/* lg altında 2. satır: sıralama + ara (header flex-wrap + order-3) */} -
- {profil ? ( - - ) : ( - - )} + {/* lg altı: tek satırın ortasındaki sıralama hapı (DESIGN.md §4.36). + Arama ve geri kalan her şey sağdaki baloncuk menüde. Sayfa başlığı + çubuğa çıkınca (NavBaslik) hap yalnız kupaya iner, başlığa yer açar. */} +
diff --git a/src/features/katalog/components/program-ayrinti.tsx b/src/features/katalog/components/program-ayrinti.tsx index 8c3cacd..92b1fc6 100644 --- a/src/features/katalog/components/program-ayrinti.tsx +++ b/src/features/katalog/components/program-ayrinti.tsx @@ -55,6 +55,7 @@ import { AcilisSahnesi, LOGO_SLOTLARI, logoKareStili, + LOGO_KAP_BOYU, } from "@/components/ui/acilis-sahnesi"; import { Table, @@ -183,7 +184,15 @@ export function ProgramAyrintiButonu({ Kapat - + {/* Başlık veri gelene dek görünmez: logo kareleri küçülüp buraya + uçar, yazı onlardan netleşir (AcilisSahnesi `data-logo-hedef`). */} + {isim} @@ -226,14 +235,20 @@ function AyrintiIcerik({ durum, veri }: { durum: Durum; veri: ProgramAyrintisi |

); } - // Sıra = AcilisSahnesi'nin `data-sonra` sırası: logo söner, bloklar - // yukarıdan aşağıya 0,05 s arayla netleşir. + // Sıra = AcilisSahnesi'nin `data-sonra` sırası: logo küçülerek başlığa + // dönüşür, bloklar yukarıdan aşağıya 0,05 s arayla netleşir. return ( <> - {/* Logo kareleri: sahne sırasında görünür, sönünce kap sıfır boya iner. */} -
+ {/* Logo kareleri: sahne sırasında görünür, küçülerek başlığa uçar; kap + sıfır boya iner. */} +
{LOGO_SLOTLARI.map((slot) => ( - + ))}
diff --git a/src/features/kullanici/components/giris-linki.tsx b/src/features/kullanici/components/giris-linki.tsx index 39f34b5..6e41caf 100644 --- a/src/features/kullanici/components/giris-linki.tsx +++ b/src/features/kullanici/components/giris-linki.tsx @@ -33,7 +33,7 @@ import { Button } from "@/components/ui/button"; */ const AUTH_GUVENLI_YOL = /^\/(?!\/|\\)[\w\-./+@]*$/; -export function GirisLinki() { +export function GirisLinki({ menu = false }: { /** Telefon menüsündeki iri hap (DESIGN.md §4.36). */ menu?: boolean }) { const pathname = usePathname(); // /giris kendine callback veremez (döngü); pathname yoksa sade hedef. const href = @@ -44,7 +44,11 @@ export function GirisLinki() { return ( diff --git a/src/features/kullanici/components/sign-out-button.tsx b/src/features/kullanici/components/sign-out-button.tsx index b92997f..2459ada 100644 --- a/src/features/kullanici/components/sign-out-button.tsx +++ b/src/features/kullanici/components/sign-out-button.tsx @@ -5,8 +5,23 @@ import { LogOut } from "lucide-react"; import { authClient } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; -export function SignOutButton() { +export function SignOutButton({ menu = false }: { /** Telefon menüsündeki yazılı hap (DESIGN.md §4.36). */ menu?: boolean }) { const router = useRouter(); + if (menu) { + return ( + + ); + } return ( ); } diff --git a/src/features/liste/components/program-ekle-butonu.tsx b/src/features/liste/components/program-ekle-butonu.tsx index 8275d43..fc87a5f 100644 --- a/src/features/liste/components/program-ekle-butonu.tsx +++ b/src/features/liste/components/program-ekle-butonu.tsx @@ -1,10 +1,14 @@ "use client"; import { SATIR_DUGME_BOYU } from "@/components/liste-satiri"; -import { ArrowRight, Check, Plus } from "lucide-react"; +import { useState } from "react"; +import { motion, useReducedMotion } from "motion/react"; +import { ArrowRight, Plus } from "lucide-react"; import { toast } from "@/components/ui/bildirim"; import type { Program } from "@/types/yokatlas"; import { olay } from "@/lib/analitik"; +import { YAY } from "@/lib/hareket"; +import { logoTepkisi } from "@/lib/logo-tepkisi"; import { MANUEL_LISTE_MAX, cekmeceAc, @@ -18,6 +22,7 @@ import { programEkleIste, } from "../hooks/use-tercih-profili"; import { preloadTercihProfiliKapisi } from "./tercih-profili-kapisi-lazy"; +import { ucusBaslat } from "./ucus-katmani"; type ProgramOzet = Pick< Program, @@ -51,10 +56,15 @@ export function ProgramEkleButonu({ // taramanın listedeMi ile ikinci kez yapılması vardı). const listede = useListedeMi(program.id); const doldu = useListeDoluMu(); + const azalt = useReducedMotion(); + // Tik yalnız bu düğmeyle eklenince kendini çizer; sayfa açılışında zaten + // listede olan satırlar çizim oynatmaz. + const [yeniEklendi, setYeniEklendi] = useState(false); function handleClick(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); + const dugme = e.currentTarget; const sonuc = programEkleIste({ program, kaynak, adaySira, adayTur }); if (sonuc.doldu) { // Ne olduğunu ve ne yapılacağını söyle (DESIGN.md §10) @@ -75,6 +85,12 @@ export function ProgramEkleButonu({ // olur, navbar sayacı artar). Profilsiz eklemede aşağıdaki toast // zaten açıklama taşıyor. const adet = listeOku().length; + // Eylemin kendi cevabı (§4.37): kare rozete uçar, logo döner, parmak + // hafifçe titrer. Bildirim değil; her eklemede olur. + setYeniEklendi(true); + ucusBaslat(dugme); + logoTepkisi(adet === MANUEL_LISTE_MAX ? "tamam" : "ekle"); + navigator.vibrate?.(8); if (!sonuc.profilsiz && adet === 1) { toast.success(`İlk programın listende · 1/${MANUEL_LISTE_MAX}`, { description: "Beğendiklerini ekledikçe sırasını çekmeceden değiştirebilirsin.", @@ -139,7 +155,23 @@ export function ProgramEkleButonu({ } ${className}`} > {listede ? ( - + + + ) : ( )} diff --git a/src/features/liste/components/program-tablosu.tsx b/src/features/liste/components/program-tablosu.tsx index d720c5a..164e22d 100644 --- a/src/features/liste/components/program-tablosu.tsx +++ b/src/features/liste/components/program-tablosu.tsx @@ -94,12 +94,15 @@ const BOS_EKSTRA: Record = { garanti: [], }; -// Dengeli ve hayal dilimleri tamamen boşsa (sıralama çok gerideyse) boş bir -// "dengeli" sekmesi yerine dolu olan "garanti" ile açılır. +// Açılış sekmesi hiçbir zaman boş bir dilim olmaz (Bilal, 10 Eki 2026: +// "dengeli 0 dahi olsa tab aktif oluyor"): önce dengeli, o boşsa güvenli, +// o da boşsa hayal. Üçü de boşsa dengeli kalır — tek boş mesaj orada okunur. function varsayilanDilim(sonuclar: RankResultsListe): DilimKey { - return sonuclar.toplam.dengeli === 0 && sonuclar.toplam.hayal === 0 - ? "garanti" - : "dengeli"; + const { toplam } = sonuclar; + if (toplam.dengeli > 0) return "dengeli"; + if (toplam.garanti > 0) return "garanti"; + if (toplam.hayal > 0) return "hayal"; + return "dengeli"; } export function ProgramTablosu({ @@ -133,9 +136,10 @@ export function ProgramTablosu({ if (veriAnahtari !== oncekiAnahtar) { setOncekiAnahtar(veriAnahtari); setEkstra(BOS_EKSTRA); - // Yeni sıra/türde dengeli+hayal boş kaldıysa boş sekmede bırakma - if (varsayilanDilim(sonuclar) === "garanti" && aktifDilim !== "garanti") { - setAktifDilim("garanti"); + // Yeni sıra/tür/filtrede açık sekme boşaldıysa dolu olana geç; kullanıcı + // bilerek dolu bir sekmedeyse (0 değilse) seçimi korunur. + if (sonuclar.toplam[aktifDilim] === 0) { + setAktifDilim(varsayilanDilim(sonuclar)); } } diff --git a/src/features/liste/components/tercih-profili-kapisi.tsx b/src/features/liste/components/tercih-profili-kapisi.tsx index 53399b4..fed64a1 100644 --- a/src/features/liste/components/tercih-profili-kapisi.tsx +++ b/src/features/liste/components/tercih-profili-kapisi.tsx @@ -1,5 +1,6 @@ "use client"; +import { logoTepkisi } from "@/lib/logo-tepkisi"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowRight, Trophy } from "lucide-react"; @@ -7,6 +8,7 @@ import { useReducedMotion } from "motion/react"; import useMeasure from "react-use-measure"; import { toast } from "@/components/ui/bildirim"; import { TransitionPanel } from "@/components/core/transition-panel"; +import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -230,6 +232,8 @@ function KapıIcerik({ if (sira == null) return; const yeniProfil: TercihProfili = { sira, tur, secimler }; tercihProfiliYaz(yeniProfil); + // Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34). + logoTepkisi("sira"); const bekleyenVar = Boolean(bekleyen); const yonlendir = profilTamamlandi(yeniProfil, girisli); // "sihirbaz" hedefinde akış /sonuc'ta devam eder (üretim ya da giriş @@ -255,42 +259,11 @@ function KapıIcerik({ ({ - x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%", - opacity: 0, - height: panelYuksekligi, - position: "relative", - }), - center: { - zIndex: 1, - x: 0, - opacity: 1, - height: panelYuksekligi, - }, - exit: (gecisYonu: number) => ({ - zIndex: 0, - x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%", - opacity: 0, - position: "absolute", - top: 0, - left: 0, - width: "100%", - }), - }} - transition={ - hareketiAzalt - ? { - opacity: { duration: 0.12 }, - x: { duration: 0 }, - height: { duration: 0 }, - } - : { - x: { type: "spring", stiffness: 300, damping: 30 }, - height: { type: "spring", stiffness: 300, damping: 30 }, - opacity: { duration: 0.2 }, - } - } + variants={adimGecisiVaryantlari({ + hareketiAzalt: !!hareketiAzalt, + yukseklik: panelYuksekligi, + })} + transition={adimGecisi(!!hareketiAzalt)} > {/* p-1: geçiş paneli taşmayı kırpar; alanların 3 px odak halkası (Input/Select/Button ring-3) sığsın (Bilal, 24 Eyl 2026). */} @@ -330,7 +303,7 @@ function KapıIcerik({ type="button" disabled={yukleniyor} onClick={() => void giristenIlerle()} - className="cursor-pointer bg-orange-500 text-white hover:bg-orange-600" + variant="cta" > {yukleniyor ? "Hazırlanıyor…" : "Tercihlerimi belirle"} @@ -423,7 +396,7 @@ function KapıIcerik({
diff --git a/src/features/rapor/components/liste-uretici.tsx b/src/features/rapor/components/liste-uretici.tsx index a6d5366..648d5b0 100644 --- a/src/features/rapor/components/liste-uretici.tsx +++ b/src/features/rapor/components/liste-uretici.tsx @@ -274,11 +274,7 @@ export function ListeUretici({ ? "Liste üretimi 3 kredi. +30 kredi yüklediğinde listen ve sohbet geçmişin duruyor, kaldığın yerden devam edersin." : "Liste üretimi 3 kredi. Tercih Dönemi Paketi'yle 60 kredi, tam liste, PDF ve revizyon hakları birlikte gelir."}

-
-
+ {/* `shrink-0` yok: üç düğme 375 px'te 343 px'lik sütuna sığmıyor, + grup küçülemeyince PDF sağdan taşıyordu (10 Eki 2026, ölçüldü); + grup daralır, düğmeler alt satıra sarar. */} +
{/* Tercih düzenleme her kullanıcıya açık: kapı (layout'ta monte TercihProfiliKapisi) profili günceller, /listem'e döner. Profil kayıtlı listeden ayrıldığı anda bu buton "Tercihlerin @@ -316,7 +319,7 @@ export function ListemGovde({ ) : (
@@ -489,7 +496,7 @@ export function ListemGovde({ {[ "24 tercihin tamamında kişisel risk analizi", "Her program için kısa, gerekçeli Yapay Zeka yorumu", - "2 liste revizyonu ve paylaşılabilir PDF", + "Sınırsız liste revizyonu ve paylaşılabilir PDF", `${paketKredi} Yapay Zeka danışman sorusu`, ].map((ozellik) => (
  • @@ -509,14 +516,10 @@ export function ListemGovde({

  • - diff --git a/src/features/rapor/components/listem-icerik.tsx b/src/features/rapor/components/listem-icerik.tsx index daca108..3fa12ac 100644 --- a/src/features/rapor/components/listem-icerik.tsx +++ b/src/features/rapor/components/listem-icerik.tsx @@ -1,6 +1,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { Sparkles } from "lucide-react"; +import { Check } from "lucide-react"; +import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { verifySession, getCurrentUser } from "@/lib/session"; import { PROFIL_CEREZ_ADI, @@ -69,7 +70,9 @@ export async function ListemIcerik({ if (!raporVar) { const bosDurum = (
    - + {/* Boş durumun tepesinde yıldız değil marka işareti (§7 "AI izi"; + §11.2 "önce imza, sonra içerik"); durağan. */} +

    Henüz Yapay Zeka listen yok

    @@ -148,7 +151,7 @@ export async function ListemIcerik({ role="status" className="mb-6 flex items-center gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800" > - + Paketin aktif — listendeki tercihlerin tamamı, gerekçeler, riskler ve PDF artık açık.
    diff --git a/src/features/rapor/components/rapor-listesi.tsx b/src/features/rapor/components/rapor-listesi.tsx index 29cde2b..fe6be99 100644 --- a/src/features/rapor/components/rapor-listesi.tsx +++ b/src/features/rapor/components/rapor-listesi.tsx @@ -82,6 +82,7 @@ export function RaporSatirDetayi({ riskNotu, trendOzeti, oneCikan, + sikistir, }: { dilim: string; risk: RiskSeviyesi; @@ -91,11 +92,18 @@ export function RaporSatirDetayi({ trendOzeti: string; /** Koddan hesaplanan "öne çıkan" veriler (uyum puanı); yoksa satır çizilmez */ oneCikan?: string[]; + /** Sıkı ölçü (tadımlık, dar sütun): metin bir kademe küçük, kutu dolgusu + * bir kademe az. Aynı iskelet, aynı etiketler — yalnız ölçü değişir. */ + sikistir?: boolean; }) { const stil = RISK_STIL[risk]; // Risk etiketi yalnızca dilimin varsayılanından farklıysa yazılır; yoksa // üçüncü dilimde başlık "Güvenli · Güvenli" olurdu (bkz. risk.ts). const farkliRisk = dilimdenFarkliRiskEtiketi(dilim, risk); + const kutu = cn( + "rounded-xl border border-slate-200 bg-card", + sikistir ? "p-3" : "p-4", + ); return ( <>
    @@ -108,8 +116,13 @@ export function RaporSatirDetayi({ · {UNITUR_ETIKET[unitur]?.cip ?? unitur} ) : null}
    -
    -
    +
    +
    Neden listede?
    @@ -121,7 +134,7 @@ export function RaporSatirDetayi({ ) : null}
    -
    +
    Risk ve Yapay Zeka yorumu
    diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 3c6ecbe..5d08b3f 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -5,20 +5,21 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport, type ChatTransport, type UIMessage } from "ai"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { animate, useReducedMotion } from "motion/react"; +import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react"; import { + ArrowRight, ArrowUp, Coins, Info, Lock, Maximize2, Minimize2, - Sparkles, Trash2, X, } from "lucide-react"; import { toast } from "@/components/ui/bildirim"; import { cn } from "@/lib/utils"; +import { YAY } from "@/lib/hareket"; import { SayiRulo } from "@/components/ui/sayi-rulo"; import { AnimasyonluMark } from "@/components/animasyonlu-mark"; import { AracPilleri, aracPilleri } from "./arac-pilleri"; @@ -168,8 +169,10 @@ function cevapAnahtari(mesajlar: UIMessage[], i: number): string { // Araç adı → öğrenci dili eşlemesi ve "…bakıyorum" hapları arac-pilleri.tsx'te. -/** Panelin boyut geçişi: sekmesiz yay (DESIGN.md §6); listem-govde ızgarası aynı yayı kullanır. */ -export const SOHBET_GENISLEME_GECISI = { type: "spring", bounce: 0, duration: 0.35 } as const; +/** Panelin boyut geçişi: sihirbaz adım paneliyle AYNI yay (`YAY.adim`, + * adim-gecisi.ts; Bilal, 10 Eki 2026: "danışmanla konuş'taki motion wizard + * ile aynı olsun"). listem-govde ızgarası ve sayfa kaydırması da bunu kullanır. */ +export const SOHBET_GENISLEME_GECISI = YAY.adim; /** * Genişletilmiş panel üstten `top-28` (7rem; listem sütununun sticky ofseti) @@ -198,6 +201,28 @@ const GENIS_EN_AZ_PX = 380; /** /dev/sohbet gibi sticky'siz yerlerde de aynı hesap; sabit yükseklik istenirse `genisYukseklik`. */ const GENIS_YUKSEKLIK = ""; +/** + * Panel başlığındaki ikon düğmeleri (genişlet / bilgi / temizle): görünür + * 32 px (paneldeki h-8 çip ve araç haplarıyla aynı boy), dokunma alanı + * görünmez taşmayla 44 px (§8) — eski `size-7` hem 44'ün altındaydı hem ikonu + * çip boyutunda bırakıyordu. 36 px (`size-9`) denendi (10 Eki 2026): 375 px'te + * başlık "DanışmanlaK…" diye kırpılıyordu; 32 px'te üç düğme + kredi hapı + + * başlık yan yana sığıyor. + */ +const BASLIK_DUGMESI = + "relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,color,transform] duration-150 before:absolute before:-inset-1.5 before:content-[''] hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] motion-reduce:active:scale-100"; + +/** + * Kredi hapı: üst çubuktaki kredi hapının sınıf dizisi (user-nav.tsx; §11.1 V4 + * "çubuk kullanıcının durumudur" — aynı durum burada da aynı biçimde durur), + * yalnız boy panelin ölçeğinde (h-8, başlık düğmeleri ve çiplerle aynı). + * Eski amber hap bu üründe risk dilinin "az riskli" + * tonuydu (tercih-degisti-modali.tsx notu), kredi için yanlış sinyaldi. + * "kredi" sözcüğü yalnız sm'den itibaren: 375 px'te başlık kırpılmasın. + */ +const KREDI_HAPI = + "inline-flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-slate-200 bg-card px-2.5 text-xs font-semibold text-slate-700 transition-colors duration-200 hover:border-slate-300 hover:bg-slate-50 sm:px-3"; + type Red = { status: number; error?: string }; /** @@ -725,31 +750,35 @@ export function SohbetClient({ Temizle butonu ikon-only — metinli hâli başlıkla yarışıp taşıyordu. */}
    -
    +
    {bildikleri?.length ? ( + {/* Telefonda (sm altı) gizli: ipucu hover/odakla açılır, + dokunmatikte zaten açılmıyordu (ölü düğme) ve 375 px'te + başlığı kırpıyordu. Dokunmayla açılan karşılığı (popover) + ayrı iş — raporda. */} setTemizleAcik(true)} disabled={bekliyor || temizleniyor} aria-label="Sohbeti temizle" - className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border border-slate-200 bg-card text-slate-600 transition-[background-color,transform] duration-150 hover:bg-slate-100 hover:text-slate-800 active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-50" + className={cn( + BASLIK_DUGMESI, + "disabled:cursor-not-allowed disabled:opacity-50", + )} title="Sohbet geçmişini sil" > - + ) : null} - - kredi + + + + + kredi
    @@ -804,26 +839,39 @@ export function SohbetClient({ Kaydettiklerim - {profil.map((s) => ( - // Etkileşimli çip (§4.17 kalıbı, kompakt h-8): rozet değil. - - {s.metin} - - - ))} + {s.metin} + {/* Görünür 24 px, dokunma alanı taşmayla 44 px (§8) */} + + + ))} +
    ) : null} + {/* Onay modalı: yüzey ve başlık DialogContent/DialogTitle varsayılanı + (§4.4 sade kutu, §3 h3); aksiyonlar tek kaynak varyantlar (§4.1). */} @@ -836,15 +884,16 @@ export function SohbetClient({
    ) : ( @@ -1102,9 +1155,9 @@ export function SohbetClient({ + {sira.toLocaleString("tr-TR")}.{" "} + sıra için 24 tercih, her satırı böyle gerekçeli + +
      + {KANITLAR.map((kanit) => ( +
    • + + {kanit} +
    • + ))} + {/* Üçüncü kanıt: dürüst vaat + yöntem açıklığı tek satırda; + link ne açtığını söyler (§10.4). */} +
    • + + + Garanti, komisyon, reklam yok;{" "} + {/* nowrap: telefonda satır sonunda "açık" tek başına kalmasın */} + + yöntem açık + + +
    • +
    +
    + + {/* Sağ: gerçek rapor satırı — kartın kendi bandı (§4.15 satır + + RaporSatirDetayi), yuvarlatılmış iç kutu değil. lg'de iki + satır boyu, sol sütunun yanında. */} +
    +

    + {secimeOzel + ? "Sırana ve seçimlerine uygun örnek satır" + : "Sırana uygun örnek satır"} +

    +
    +
    + {/* Telefonda logo gizli — rapor listesiyle aynı karar (§4.15: + logo adı üç satıra sıkıştırıyordu). */} + + + {p.isim} + + + {trBaslikDuzeni(p.universite)} + + + + {p.il ? ( + + {trBaslikDuzeni(p.il)} + + ) : null} + + + + + + + + + {taban != null ? `~${taban.toLocaleString("tr-TR")}.` : "—"} + +
    + +
    + +
    +
    +
    + + {/* Sol alt: CTA. Telefonda örneğin altına iner (önce kanıt, sonra + eylem); lg'de sol sütunun dibinde, örnekle aynı hizada biter. */} +
    + + {/* "İlk 3 satır" = rapor-maske.ts içindeki ACIK_SATIR (3). O dosya + `server-only`, bu dosya "use client" — sabit buraya import + edilemiyor, sayı elle yazıldı. ACIK_SATIR değişirse bu cümle de + aynı commit'te değişmeli. Cümle kasten sınırlıyor: maske + girişsiz/paketsiz kullanıcıya 3 satır açıyor, kalan 21'in + gerekçe/risk/trend metni maskeli geliyor. */} +

    + Kredi kartı gerekmez · 30 saniyede giriş · İlk 3 satır hemen açık +

    +
    diff --git a/src/features/rapor/components/tercih-degisti-modali.tsx b/src/features/rapor/components/tercih-degisti-modali.tsx index 68f226c..4ee7661 100644 --- a/src/features/rapor/components/tercih-degisti-modali.tsx +++ b/src/features/rapor/components/tercih-degisti-modali.tsx @@ -161,7 +161,7 @@ export function TercihDegistiModali({
    - Listen güncellendi + Listen güncellendi Yeni tercihlerine göre yeniden kuruldu — arkadaki liste birkaç saniye içinde tazelenir. @@ -169,8 +169,10 @@ export function TercihDegistiModali({
    {durum.ad === "hata" ? ( ) : krediYeter ? ( ) : ( - + ); } @@ -161,9 +177,9 @@ export function HeroForm() { type="submit" size="lg" disabled={yukleniyor} - className="cursor-pointer bg-orange-500 text-white transition-colors duration-200 hover:bg-orange-600" + variant="cta" > - Yapay Zeka listemi kur + {yukleniyor ? "Hazırlanıyor…" : "Yapay Zeka listemi kur"} diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx index 03cff3b..0ef0b9c 100644 --- a/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx +++ b/src/features/sihirbaz/components/sihirbaz-adimlar-lazy.tsx @@ -5,6 +5,7 @@ // dynamic import: hem hero-form hem sonuc/sihirbaz-modal burayı kullanır. import dynamic from "next/dynamic"; +import { AdimGostergesiIskeleti } from "@/components/ui/adim-gostergesi"; import { Skeleton } from "@/components/ui/skeleton"; function SihirbazAdimlarSkeleton() { @@ -14,11 +15,8 @@ function SihirbazAdimlarSkeleton() { aria-live="polite" className="flex min-h-64 flex-col gap-5" > -
    - - - - +
    +
    @@ -36,8 +34,8 @@ function SihirbazAdimlarSkeleton() {
    - - + +
    ); diff --git a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx index 52caba2..b3cf895 100644 --- a/src/features/sihirbaz/components/sihirbaz-adimlar.tsx +++ b/src/features/sihirbaz/components/sihirbaz-adimlar.tsx @@ -15,6 +15,7 @@ import { toast } from "@/components/ui/bildirim"; import { cities } from "turkey-map-react/lib/data"; import { TransitionPanel } from "@/components/core/transition-panel"; import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi"; +import { AdimGostergesi } from "@/components/ui/adim-gostergesi"; import { Button } from "@/components/ui/button"; import { IlSecimHaritasi } from "./il-secim-haritasi"; import { normalizeIlAdi } from "@/lib/harita"; @@ -214,41 +215,16 @@ export function SihirbazAdimlar({ return (
    - {/* Adım göstergesi (DESIGN.md §10): üç parçalı çubuk + adların - kendisi; öğrenci nerede olduğunu ve ne kaldığını görür. Değişim - ekran okuyucuya duyurulur (WCAG 4.1.3). */} -
    -
      - {ADIM_ADLARI.map((ad, i) => ( -
    1. - {/* Dolum transform ile (yalnız transform/opaklık canlandırılır); - soldan dolar, güçlü ease-out 300 ms. */} - - - - - {ad} - -
    2. - ))} -
    -

    - Adım {adim + 1} / 3: {ADIM_ADLARI[adim]} -

    -
    + {/* Adım göstergesi (DESIGN.md §4.35, §10.2): numaralı, adlı; tamamlanan + adıma dokunarak geri dönülür. Değişimi ekran okuyucuya kendisi duyurur. */} + {/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */} adimaGit(adim + 1)} disabled={!adimGecerli} - className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]" + variant="cta" + className="h-11 px-5" > Devam @@ -555,7 +532,8 @@ export function SihirbazAdimlar({ onTamamla({ kategoriler, iller, universiteTipi, oncelikler, ozelDurum }); }} disabled={kategoriler.length === 0} - className="h-11 cursor-pointer bg-orange-500 px-5 text-white transition-[background-color,transform] duration-200 hover:bg-orange-600 active:scale-[0.97]" + variant="cta" + className="h-11 px-5" > {sonButonEtiketi} diff --git a/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx b/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx index 70627d5..9ebfece 100644 --- a/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx +++ b/src/features/sihirbaz/components/sihirbaz-cagri-karti.tsx @@ -1,5 +1,6 @@ "use client"; +import { logoTepkisi } from "@/lib/logo-tepkisi"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -182,6 +183,8 @@ export function SihirbazBolumu({ */ async function ilerle(secimler: SihirbazSecimleri) { tercihProfiliYaz({ sira, tur, secimler }); + // Üst çubuktaki logo kürsüye döner (DESIGN.md §4.34). + logoTepkisi("sira"); await modalCikisiniOynat(); setElleAcik(false); if (!girisliMi) { diff --git a/src/lib/adim-gecisi.ts b/src/lib/adim-gecisi.ts index 7096570..ac05ac0 100644 --- a/src/lib/adim-gecisi.ts +++ b/src/lib/adim-gecisi.ts @@ -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 }, }; } diff --git a/src/lib/ai/araclar.ts b/src/lib/ai/araclar.ts index f95ae08..f9915a3 100644 --- a/src/lib/ai/araclar.ts +++ b/src/lib/ai/araclar.ts @@ -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, diff --git a/src/lib/ai/cagri.ts b/src/lib/ai/cagri.ts index 0a641f9..d7e37dc 100644 --- a/src/lib/ai/cagri.ts +++ b/src/lib/ai/cagri.ts @@ -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; }; @@ -162,7 +176,7 @@ export async function yapilandirilmisUret(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 }, diff --git a/src/lib/ai/revizyon-niyeti.ts b/src/lib/ai/revizyon-niyeti.ts index 809589f..3319c45 100644 --- a/src/lib/ai/revizyon-niyeti.ts +++ b/src/lib/ai/revizyon-niyeti.ts @@ -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 = { 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 { 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): 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 = {}; + 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 { 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", diff --git a/src/lib/ai/sohbet-gecmisi.ts b/src/lib/ai/sohbet-gecmisi.ts index e503795..8d2672d 100644 --- a/src/lib/ai/sohbet-gecmisi.ts +++ b/src/lib/ai/sohbet-gecmisi.ts @@ -64,6 +64,11 @@ const REVIZYON_NIYETI: RegExp[] = [ /(?(LOGO_TEPKI_OLAYI, { detail: tur })); +} diff --git a/src/lib/tema-gecisi.ts b/src/lib/tema-gecisi.ts new file mode 100644 index 0000000..88939e2 --- /dev/null +++ b/src/lib/tema-gecisi.ts @@ -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 '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ı '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ı 'e henüz yazılmamış olabilir; logo yanlış + // biçime dönüşmesin. + window.setTimeout( + () => window.dispatchEvent(new Event(TEMA_GECIS_OLAYI)), + LOGO_NEFESI, + ); + }); +}