Files
kolaytercih/src/components/pixel-decor.tsx
bilalgursen 613d9e012a fix(tasarim): piksel zemin yalnız CTA yüzeylerinde, kabın ölçüsüne göre, küçük kare yok
Bilal (24 Eyl 2026): ana sayfa CTA'sında doku hiç görünmüyordu, bazı
kartlarda çok azdı; sonra "çok fazla piksel, küçük olanlar hiç olmasın"
ve "/sonuc'taki kartın arkasına da piksel koymuşsun; pikseller CTA'larda
olacak sadece".

- Kök neden: PixelMasonry sabit 420×120 çizimi kaplayacak şekilde
  ölçekleniyordu; yüksek kartlarda görünen kısım boş banda düşüyordu.
  Artık kabın gerçek ölçüsüne göre üretiliyor (ResizeObserver), yalnız
  kenar çerçevesinde, orta boş; `haric` verilen öğenin (24'lük piksel
  tablosu) çevresi her genişlikte boş. Sunucuda boş, ölçülünce belirir.
- Küçük (tek hücre) kare yok: 4×4 makro hücrede 2–4 hücrelik kareler,
  iri olan seyrek ve silik. Ölçüm: kapanış kartı 1280 px'te 58 kare
  (tabloya binen 0), 375 px'te 27 kare.
- Beyaz içerik kartlarından zemin geri alındı (tadımlık, /listem üretim
  ve kilit kartları, boş durum, sıralama-yok, meraklısına, paket kartları,
  kaydet bandı, katalog üstü kompakt satır, /sonuc "listen hazır").
  Kalanlar: CtaBandi, PikselCtaYuzeyi, arama penceresi kartı.
  PikselZemin'in açık tonu kaldırıldı. DESIGN.md §10.8 güncellendi.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 12:38:47 +03:00

493 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefObject,
} from "react";
import { cn } from "@/lib/utils";
import { usePixelHeat } from "@/components/use-pixel-heat";
/**
* Hero'daki dot-matrix dilini (TurkeyPixelMap) bölüm ayraçlarına ve
* dekorlara taşıyan piksel süsleri. Rastgelelik yerine deterministik
* hash kullanılır ki sunucu ve istemci aynı deseni üretsin.
*
* Hepsi hero'daki imleç ısı fırçasına katılır (usePixelHeat): imleç
* yaklaşınca pikseller turuncuya ısınır, uzaklaşınca yavaşça soğur.
* Turuncu vurgu (accent) pikselleri hero'daki şehirler gibi fırçaya
* katılmaz.
*/
// Hero ile aynı ızgara ölçüleri
const CELL = 10;
const DOT = 7.4;
const INSET = (CELL - DOT) / 2;
// Hero ile aynı doku paleti
const OPACITIES = [0.3, 0.16, 0.24, 0.12, 0.27, 0.19];
const opacityFor = (x: number, y: number) =>
OPACITIES[(x * 7 + y * 11) % OPACITIES.length];
// Deterministik sözde-rastgele: aynı girdi her ortamda aynı deseni verir
const hash = (x: number, y: number, seed: number) => {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.7) * 43758.5453;
return n - Math.floor(n);
};
function pixelRect(
x: number,
y: number,
extra: { key: string; accent?: boolean; accentDelay?: number },
) {
const shared = {
x: x * CELL + INSET,
y: y * CELL + INSET,
width: DOT,
height: DOT,
rx: 2,
};
if (extra.accent) {
return (
<rect
key={extra.key}
{...shared}
className="animate-pulse fill-orange-500/70 motion-reduce:animate-none"
style={{
animationDuration: "3.6s",
animationDelay: `${extra.accentDelay ?? 0}s`,
}}
/>
);
}
const op = opacityFor(x, y);
return (
<rect
key={extra.key}
{...shared}
data-px=""
data-cx={x * CELL + CELL / 2}
data-cy={y * CELL + CELL / 2}
data-op={op}
fill="currentColor"
fillOpacity={op}
/>
);
}
/**
* CTA yüzeylerinin piksel zemini: büyüklü küçüklü kareler, masonry gibi
* hafif kaymış (Bilal, 24 Eyl 2026: "arka planda büyüklü küçüklü, masonry
* şeklinde"). Kabın **gerçek ölçüsüne** göre üretilir (ResizeObserver):
* sabit bir çizimi kaplayacak şekilde ölçeklemek yüksek kartlarda görünen
* kısmı boş banda düşürüyordu ("hiç doku yok"). Yalnız kenar çerçevesinde,
* orta tamamen boş; `haric` verilen öğenin (ör. 24'lük piksel tablosu)
* çevresi de boş kalır. Küçük (tek hücrelik) kare **yok** (Bilal, 24 Eyl
* 2026: "çok fazla piksel, küçük olanlar hiç olmasın"): 4×4 hücrelik makro
* hücrelere 2–4 hücrelik tek kare düşer ve makro hücre içinde kaydırılır:
* çakışmaz ama hizaya da girmez (§1.1 "doğal duruş"). Durağan zemin:
* imleç ısı fırçasına katılmaz.
* Sunucuda boş çizilir, ölçülünce belirir (dekor; aria-hidden).
*/
export function PixelMasonry({
seed = 7,
yogunluk = 1,
kenar = 0.3,
haric,
className,
}: {
seed?: number;
/** Doluluk çarpanı; 1 varsayılan, 0.6 seyrek */
yogunluk?: number;
/** Kenar çerçevesinin genişliği: kısa kenarın oranı (0.3 = %30) */
kenar?: number;
/** Çevresi boş bırakılacak öğe (kreatif, iri sayı) */
haric?: RefObject<HTMLElement | null>;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
const [olcu, setOlcu] = useState<{
w: number;
h: number;
hx: number;
hy: number;
hw: number;
hh: number;
} | null>(null);
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
const olc = () => {
const r = svg.getBoundingClientRect();
const hr = haric?.current?.getBoundingClientRect();
const sonraki = {
w: Math.round(r.width),
h: Math.round(r.height),
hx: hr ? Math.round(hr.left - r.left) : 0,
hy: hr ? Math.round(hr.top - r.top) : 0,
hw: hr ? Math.round(hr.width) : 0,
hh: hr ? Math.round(hr.height) : 0,
};
setOlcu((onceki) =>
onceki &&
onceki.w === sonraki.w &&
onceki.h === sonraki.h &&
onceki.hx === sonraki.hx &&
onceki.hy === sonraki.hy &&
onceki.hw === sonraki.hw &&
onceki.hh === sonraki.hh
? onceki
: sonraki,
);
};
olc();
const ro = new ResizeObserver(olc);
ro.observe(svg);
const h = haric?.current;
if (h) ro.observe(h);
return () => ro.disconnect();
}, [haric]);
const MAKRO = 4;
const PAY = 14; // hariç öğenin çevresinde bırakılan boşluk (px)
const pixels = useMemo(() => {
if (!olcu || olcu.w === 0 || olcu.h === 0) return null;
const { w, h, hx, hy, hw, hh } = olcu;
const cols = Math.ceil(w / CELL);
const rows = Math.ceil(h / CELL);
const kisa = Math.min(w, h);
const sonuc: ReactNode[] = [];
let accentIdx = 0;
const kare = (
x: number,
y: number,
n: number,
key: string,
accent: boolean,
) => {
const boy = n * CELL - 2 * INSET;
const shared = {
x: x * CELL + INSET,
y: y * CELL + INSET,
width: boy,
height: boy,
rx: 2 * n,
};
if (accent) {
return (
<rect
key={key}
{...shared}
className="animate-pulse fill-orange-500/70 motion-reduce:animate-none"
style={{
animationDuration: "3.6s",
animationDelay: `${(accentIdx++ % 8) * 0.45}s`,
}}
/>
);
}
// İri kare daha silik: yazının yanında bağırmasın
const op = opacityFor(x, y) * (n === 2 ? 0.9 : n === 3 ? 0.7 : 0.55);
return (
<rect key={key} {...shared} fill="currentColor" fillOpacity={op} />
);
};
const haricte = (px: number, py: number) =>
hw > 0 &&
px >= hx - PAY &&
px <= hx + hw + PAY &&
py >= hy - PAY &&
py <= hy + hh + PAY;
for (let my = 0; my < rows / MAKRO; my++) {
for (let mx = 0; mx < cols / MAKRO; mx++) {
const ox = mx * MAKRO;
const oy = my * MAKRO;
const cxPx = (ox + MAKRO / 2) * CELL;
const cyPx = (oy + MAKRO / 2) * CELL;
if (haricte(cxPx, cyPx)) continue;
// En yakın kenara uzaklık, kısa kenarın oranı olarak (0 kenar, 0.5 orta)
const k = Math.min(cxPx, w - cxPx, cyPx, h - cyPx) / kisa;
// Yalnız çerçevede, kenara doğru artan doluluk; orta boş
if (k >= kenar) continue;
const density = (0.06 + 0.5 * (1 - k / kenar) ** 1.6) * yogunluk;
if (hash(mx, my, seed) > density) continue;
const r = hash(mx, my, seed + 7);
// 2–4 hücre: orta boy sık, iri seyrek
const n = r < 0.5 ? 2 : r < 0.85 ? 3 : 4;
const serbest = MAKRO - n;
const kx = ox + Math.floor(hash(mx, my, seed + 11) * (serbest + 1));
const ky = oy + Math.floor(hash(mx, my, seed + 13) * (serbest + 1));
const accent = n === 2 && hash(mx, my, seed + 99) > 0.95;
sonuc.push(kare(kx, ky, n, `m-${mx}-${my}`, accent));
}
}
return sonuc;
}, [olcu, seed, yogunluk, kenar]);
return (
<svg
ref={svgRef}
viewBox={olcu ? `0 0 ${olcu.w} ${olcu.h}` : undefined}
preserveAspectRatio="xMinYMin slice"
aria-hidden="true"
focusable="false"
className={cn(
"h-full w-full transition-opacity duration-500 motion-reduce:transition-none",
pixels ? "opacity-100" : "opacity-0",
className,
)}
>
{pixels}
</svg>
);
}
/**
* Bölümler arasında sert border çizgileri yerine kullanılan, ortada
* yoğunlaşıp kenarlara doğru dağılan piksel ayraç. Hero'nun dot-matrix
* dilini bölüm geçişlerine taşır.
*/
export function PixelDivider({
seed = 5,
className,
}: {
seed?: number;
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef);
const COLS = 12;
const ROWS = 2;
// Deterministik çıktı — render başına yeniden üretme (PixelMasonry ile aynı)
const pixels = useMemo(() => {
const cx = (COLS - 1) / 2;
const sonuc: ReactNode[] = [];
let accentPlaced = false;
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLS; x++) {
// Az sayıda, iri piksel: ortada yoğun, kenara doğru seyrelen küçük küme
const dx = Math.abs(x - cx) / cx;
const rowFalloff = y === 0 ? 0.9 : 0.35;
const density = (1 - dx) ** 1.1 * rowFalloff;
if (hash(x, y, seed) > density) continue;
const accent = !accentPlaced && hash(x, y, seed + 99) > 0.8;
if (accent) accentPlaced = true;
sonuc.push(
pixelRect(x, y, {
key: `${x}-${y}`,
accent,
}),
);
}
}
return sonuc;
}, [seed]);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-auto w-full text-primary", className)}
>
{pixels}
</svg>
);
}
/** Alt sayfa başlıklarını içerikten ayıran kompakt piksel imzası. */
export function PagePixelDivider({
seed = 5,
className,
}: {
seed?: number;
className?: string;
}) {
return (
<div aria-hidden="true" className={cn("w-40", className)}>
<PixelDivider seed={seed} />
</div>
);
}
/** Küçük sabit süslerde (mark/korner) data-px öznitelikli rect. */
function fixedPixel(x: number, y: number, opacity: number) {
return (
<rect
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
data-px=""
data-cx={x + CELL / 2}
data-cy={y + CELL / 2}
data-op={opacity}
fill="currentColor"
fillOpacity={opacity}
/>
);
}
/** PixelMark pikseli: kendi kendine, sırası geldiğinde ısınıp sönen rect. */
function markPixel(x: number, y: number, opacity: number, delay: number) {
return (
<rect
x={x + INSET}
y={y + INSET}
width={DOT}
height={DOT}
rx={2}
className="animate-pixel-isi motion-reduce:animate-none"
fill="currentColor"
fillOpacity={opacity}
style={
{ "--px-op": opacity, animationDelay: `${delay}s` } as CSSProperties
}
/>
);
}
/**
* Eyebrow rozetlerinde kullanılan 2×2'lik minik piksel imzası.
* Pikseller imleç fırçasına katılmak yerine saat yönünde sırayla
* ısınıp söner; ikisi aynı fill/fillOpacity'yi sürdüğü için çakışır.
*/
export function PixelMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
focusable="false"
className={cn("size-3.5 shrink-0", className)}
>
{markPixel(0, 0, 0.35, 0)}
<rect
x={11.3}
y={1.3}
width={7.4}
height={7.4}
rx={2}
className="animate-pixel-isi-accent fill-orange-500 motion-reduce:animate-none"
fillOpacity={0.85}
style={{ animationDelay: "0.6s" }}
/>
{markPixel(10, 10, 0.18, 1.2)}
{markPixel(0, 10, 0.6, 1.8)}
</svg>
);
}
/** Kart köşelerine iliştirilen sessiz piksel kümesi. */
export function PixelCorner({ className }: { className?: string }) {
const svgRef = useRef<SVGSVGElement>(null);
usePixelHeat(svgRef, { radius: 28 });
return (
<svg
ref={svgRef}
viewBox="0 0 30 30"
aria-hidden="true"
focusable="false"
className={cn("size-7 text-primary", className)}
>
{fixedPixel(20, 0, 0.5)}
{fixedPixel(10, 0, 0.14)}
{fixedPixel(20, 10, 0.26)}
</svg>
);
}
/**
* Bir sıranın yerini tek satır piksel şeridinde gösteren sessiz gösterge
* (ör. URAP'ta 198 üniversite içinde 5.). Şerit soldan (1.) sağa
* (sonuncu) dilimlere bölünür; sıranın düştüğü dilim hero'daki şehirler
* gibi turuncu yanar, geri kalanı hero dokusunda durur ve imleç fırçasına
* katılır. Dekoratiftir: anlamı yanındaki metin taşır (renk tek başına
* anlam taşımaz), bu yüzden aria-hidden.
*/
export function PixelSira({
sira,
toplam,
hucre = 8,
zemin = "acik",
className,
}: {
sira: number;
toplam: number;
hucre?: number;
/** "koyu": renkli dolu zemin üstünde (ör. turuncu gösterge karesi) —
* işaret beyaz yanar, doku zeminin üstünde beyaz kalır. */
zemin?: "acik" | "koyu";
className?: string;
}) {
const svgRef = useRef<SVGSVGElement>(null);
// Isı fırçası turuncuya ısıtır; turuncu zeminde görünmez, orada kapalı.
usePixelHeat(svgRef, { radius: zemin === "koyu" ? 0 : 28 });
const yer = Math.min(
hucre - 1,
Math.max(0, Math.floor(((sira - 1) / toplam) * hucre)),
);
return (
<svg
ref={svgRef}
viewBox={`0 0 ${hucre * CELL} ${CELL}`}
aria-hidden="true"
focusable="false"
className={cn("h-3.5 w-auto shrink-0 text-primary", className)}
>
{Array.from({ length: hucre }, (_, i) =>
i === yer ? (
<rect
key={i}
x={i * CELL + INSET}
y={INSET}
width={DOT}
height={DOT}
rx={2}
className={zemin === "koyu" ? "fill-white" : "fill-orange-500"}
fillOpacity={zemin === "koyu" ? 1 : 0.85}
/>
) : (
<g key={i}>{fixedPixel(i * CELL, 0, opacityFor(i, 0) + 0.1)}</g>
),
)}
</svg>
);
}
/** Bölüm başlıklarının üstünde duran piksel imzalı eyebrow rozeti. */
export function SectionEyebrow({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-xs font-semibold text-primary",
className,
)}
>
<PixelMark />
{children}
</span>
);
}