diff --git a/src/components/ui/loader.tsx b/src/components/ui/loader.tsx index caccb3c..92694c7 100644 --- a/src/components/ui/loader.tsx +++ b/src/components/ui/loader.tsx @@ -334,16 +334,13 @@ export function TextShimmerLoader({ lg: "text-base", } + // prompt-kit'in özgün gradyanı `--muted-foreground/--foreground` token'ı ve + // projede tanımsız bir `shimmer` keyframe'i kullanıyordu (hareket etmiyordu). + // Sitenin tek parıltı kaynağı shadcn `shimmer` utility'sidir (currentColor + // üstünden; liste-uretici.tsx ve tercih-degisti-modali.tsx ile aynı), renk + // çağıranın `text-slate-*` sınıfından gelir, karanlık mod token'dan döner. return ( -
+
{text}
) diff --git a/src/features/rapor/components/sohbet-anketi.tsx b/src/features/rapor/components/sohbet-anketi.tsx index e972acd..3636519 100644 --- a/src/features/rapor/components/sohbet-anketi.tsx +++ b/src/features/rapor/components/sohbet-anketi.tsx @@ -9,10 +9,21 @@ // danışman mesajındayken açıktır: cevaplandıysa ya da aday başka bir şey // yazdıysa kapanır ve tek satırlık durumla kalır (sayfa yenilenince kayıttan // aynı hâlde döner). +// +// Sorular arası geçiş sihirbaz adımlarıyla birebir aynı yoldan (lib/adim-gecisi.ts): +// ileri giderken yeni soru sağdan gelir, eskisi sola çıkar; geri dönerken +// tersi; boy ölçülen yüksekliğe yayla uzar. İlkel pasif soruyu `hidden` +// yapar; burada `hidden={false}` ile görünürlük bize geçer (mergeProps'ta +// kullanıcı prop'u kazanır — @shadcn/react chunk-HBS6WEDP.js `b`), pasif +// sorular mutlak konumda saydam durur ve ilkelin verdiği `inert`i korur; +// DOM'da kaldıkları için gönderimde FormData önceki cevapları da toplar. -import { useId } from "react"; +import { useId, useState } from "react"; import { Check, MessageSquareText } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import useMeasure from "react-use-measure"; import type { Anket, AnketCevaplari } from "@/lib/ai/sohbet-meta"; +import { adimGecisi, adimGecisiVaryantlari } from "@/lib/adim-gecisi"; import { Questionnaire, QuestionnaireActions, @@ -43,6 +54,12 @@ export function SohbetAnketi({ onGonder: (cevaplar: AnketCevaplari) => void; }) { const kimlik = useId(); + // Aktif soru bizde (kontrollü ilkel): geçiş yönü sorunun sırasından çıkar. + const [aktif, setAktif] = useState(anket.sorular[0]?.ad); + const [gecisBasladi, setGecisBasladi] = useState(false); + // Yalnız aktif sorunun fieldset'i ölçülür; ref soru değişince ona geçer. + const [olcumRef, olculer] = useMeasure(); + const hareketiAzalt = !!useReducedMotion(); if (durum === "cevaplandi" || durum === "kapandi") { return ( @@ -63,6 +80,11 @@ export function SohbetAnketi({ choices: s.secenekler.map((o) => ({ value: o.deger })), })); + const aktifIndeks = anket.sorular.findIndex((s) => s.ad === aktif); + const yukseklik = gecisBasladi && olculer.height > 0 ? olculer.height : "auto"; + const varyantlar = adimGecisiVaryantlari({ hareketiAzalt, yukseklik }); + const gecis = adimGecisi(hareketiAzalt); + function gonder(e: React.FormEvent) { e.preventDefault(); if (durum === "gonderiliyor") return; @@ -86,7 +108,11 @@ export function SohbetAnketi({ aria-labelledby={anket.baslik ? `${kimlik}-baslik` : undefined} aria-label={anket.baslik ? undefined : "Danışmanın sorusu"} className="rounded-2xl border border-slate-200 bg-card p-4" - defaultItem={anket.sorular[0]?.ad} + item={aktif} + onItemChange={(ad) => { + setGecisBasladi(true); + setAktif(ad); + }} items={items} shortcuts="letters" onSubmit={gonder} @@ -103,34 +129,54 @@ export function SohbetAnketi({ )} />
- {anket.sorular.map((s) => ( - - {s.soru} - {s.aciklama ? {s.aciklama} : null} - - {s.secenekler.map((o) => ( - - {o.etiket} - {o.aciklama ? ( - {o.aciklama} - ) : null} - - ))} - {s.serbest ? ( - - ) : null} - - - {s.coklu ? "En az bir seçenek işaretle." : "Bir seçenek seç."} - - - ))} + {/* Kayan sorular taşmasın diye kırpılır; 4 px'lik negatif kenar + + fieldset'teki p-1 odak halkasına (ring-3) yer bırakır. */} +
+ {anket.sorular.map((s, i) => { + const buAktif = s.ad === aktif; + return ( + + + + ); + })} +
diff --git a/src/features/rapor/components/sohbet-client.tsx b/src/features/rapor/components/sohbet-client.tsx index 59a8846..e0be39b 100644 --- a/src/features/rapor/components/sohbet-client.tsx +++ b/src/features/rapor/components/sohbet-client.tsx @@ -54,7 +54,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DotsLoader } from "@/components/ui/loader"; +import { DotsLoader, TextShimmerLoader } from "@/components/ui/loader"; import { Skeleton } from "@/components/ui/skeleton"; import { olay } from "@/lib/analitik"; import type { ProfilAlani } from "@/lib/danisman-profil"; @@ -283,9 +283,10 @@ export function DanismanBaslik() { /** Danışman mesajlarının avatarı — KolayTercih markası. `canli` iken (cevap * beklenirken/yazılırken) panel başlığındaki DanışmanlaKonuş işareti gibi * yerinde nabız atar: danışmanın "düşündüğü" avatardan okunur. `aracta` iken - * (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler sırayla - * birbirinin yerine yürür; araç bitince nabza döner. Her geçişte ve cevap - * bitince kareler yumuşakça yerine oturur. + * (danışman veriye bakıyor, "…bakıyorum" hapı dönüyor) kareler site + * başlığındaki logoyla aynı "rota" koreografisini oynar (her kare sıradakinin + * yerine süzülür; site-header.tsx, animasyonlu-mark.tsx); araç bitince nabza + * döner. Her geçişte ve cevap bitince kareler yumuşakça yerine oturur. * `renk="turuncu"`: panel başlığındaki DanışmanlaKonuş işaretinin rengi — * yalnız "Danışman uyarısı" balonunda (durağan; hareket "düşünüyor" demek). */ export function DanismanAvatar({ @@ -304,7 +305,7 @@ export function DanismanAvatar({ > @@ -989,6 +990,16 @@ export function SohbetClient({ const piller = aracPilleri(m); // Soru formu balonun altında (sohbet-anketi.tsx) const anket = mesajinAnketi(m); + // soru_sor aracı hap çizmez (arac-pilleri.tsx HAPSIZ_ARACLAR); + // sorular üretilirken (parça henüz output-available değil) + // formun yerinde shimmer satırı durur, form gelince kalkar. + const soruHazirlaniyor = m.parts.some( + (p) => + p.type === "tool-soru_sor" && + "state" in p && + p.state !== "output-available" && + p.state !== "output-error", + ); return ( {baglantilariIsaretle(metin)} - ) : ( + ) : soruHazirlaniyor ? null : ( )} + {soruHazirlaniyor ? ( +
+ +
+ ) : null} {anket ? ( + {/* Geçiş sabitleri soru formuyla ortak (lib/adim-gecisi.ts) */} ({ - 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 - ? // Azaltılmış harekette yalnızca kısa çapraz solma; height/x - // varsayılan spring'e düşüp oynamasın diye açıkça sıfırlanır. - { - 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)} >

diff --git a/src/lib/adim-gecisi.ts b/src/lib/adim-gecisi.ts new file mode 100644 index 0000000..7096570 --- /dev/null +++ b/src/lib/adim-gecisi.ts @@ -0,0 +1,65 @@ +// Adım adım ilerleyen panellerin ortak geçişi (DESIGN.md §6): sihirbazın +// alan → il → öncelik adımları (sihirbaz-adimlar.tsx, TransitionPanel) ile +// danışmanın soru formundaki sorular (sohbet-anketi.tsx) aynı yoldan gelir +// gider — yeni adım ileri giderken sağdan, geri dönerken soldan kayar; boy +// ölçülen yüksekliğe yayla uzar. Sabitler yalnız burada; iki yerde de bu iki +// fonksiyon çağrılır, kopyası yazılmaz. Yay sekmesiz (stiffness 300 / +// damping 30), opaklık 0,2 s. "Hareketi azalt"ta kayma ve boy yayı yok, +// yalnız 0,12 s'lik çapraz solma. + +import type { Transition, Variants } from "motion/react"; + +/** + * enter / center / exit varyantları; `custom` geçiş yönüdür (1 ileri, -1 + * geri). Giren panel akışta (`relative`) durur, çıkan panel mutlak konuma + * alınır ki ikisi aynı anda üst üste kayabilsin. `yukseklik` ölçülen boy + * (ilk ölçümden önce "auto"). + */ +export function adimGecisiVaryantlari({ + hareketiAzalt, + yukseklik, +}: { + hareketiAzalt: boolean; + yukseklik: number | "auto"; +}): Variants { + return { + enter: (gecisYonu: number) => ({ + x: hareketiAzalt ? 0 : gecisYonu > 0 ? "100%" : "-100%", + opacity: 0, + height: yukseklik, + position: "relative", + }), + center: { + zIndex: 1, + x: 0, + opacity: 1, + height: yukseklik, + position: "relative", + }, + exit: (gecisYonu: number) => ({ + zIndex: 0, + x: hareketiAzalt ? 0 : gecisYonu < 0 ? "100%" : "-100%", + opacity: 0, + position: "absolute", + top: 0, + left: 0, + width: "100%", + }), + }; +} + +/** Geçişin zamanlaması; azaltılmış harekette x/height açıkça sıfırlanır ki + * varsayılan yaya düşüp oynamasın. */ +export function adimGecisi(hareketiAzalt: boolean): Transition { + return 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 }, + }; +}