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>
This commit is contained in:
bilalgursen
2026-09-24 12:38:47 +03:00
parent c90c186b86
commit 613d9e012a
17 changed files with 173 additions and 144 deletions

View File

@@ -73,7 +73,7 @@ export function AramaFunnelKarti({ onSec }: { onSec: () => void }) {
aria-label="Tercih listeni kur"
className="relative isolate mt-5 overflow-hidden rounded-2xl border border-blue-400/20 bg-slate-950 px-4 py-5 text-white sm:px-5"
>
<PikselZemin seed={61} ton="koyu" />
<PikselZemin seed={61} />
<div className="grid items-center gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-5">
<div className="min-w-0">
<TypingAnimation

View File

@@ -44,7 +44,7 @@ export function CtaBandi({
)}
{...props}
>
<PikselZemin seed={seed} ton="koyu" className={buyuk ? "-inset-y-10" : undefined} />
<PikselZemin seed={seed} className={buyuk ? "-inset-y-10" : undefined} />
{children}
</Etiket>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, type ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { PikselZemin } from "@/components/piksel-zemin";
import { PikselListe, PikselListeAciklama } from "@/components/piksel-liste";
@@ -13,9 +13,6 @@ import { PikselListe, PikselListeAciklama } from "@/components/piksel-liste";
* düğmeye ya da linke imleç/odak gelince 24 kare birden dolar — "listen
* böyle hazır olur". Yazılıp silinen satır yok (Bilal, 24 Eyl 2026).
*/
// Geniş ekranda doku yalnız genişliğin ilk %16'sında (metnin sol boşluğu)
const SOL_KENAR: [number, number] = [0.16, 1];
export function PikselCtaYuzeyi({
children,
className,
@@ -27,6 +24,9 @@ export function PikselCtaYuzeyi({
seed?: number;
}) {
const [tamamla, setTamamla] = useState(false);
// Piksel tablosunun çevresi dokuda boş kalır (Bilal, 24 Eyl 2026:
// "tablonun arkasına çok piksel yığılmış"); ölçü gerçek yerleşimden.
const listeRef = useRef<HTMLDivElement>(null);
const hedefMi = (t: EventTarget | null) =>
t instanceof Element && t.closest("a, button") != null;
@@ -42,23 +42,15 @@ export function PikselCtaYuzeyi({
className,
)}
>
{/* Telefon/tablet: liste üstte ortada, dokunun boş bandında. Geniş
ekranda liste sağ sütunda — orası tamamen boş kalır, doku yalnız
sol kenarda ve seyrek (Bilal, 24 Eyl 2026: "tablonun arkasına çok
piksel yığılmış"). */}
<PikselZemin seed={seed} ton="koyu" className="-inset-y-10 lg:hidden" />
<PikselZemin
seed={seed}
ton="koyu"
bosBant={SOL_KENAR}
yogunluk={0.7}
className="-inset-y-10 hidden lg:block"
/>
<PikselZemin seed={seed} haric={listeRef} className="-inset-y-10" />
<div className="grid items-center gap-8 px-5 py-10 sm:px-10 sm:py-12 lg:grid-cols-[minmax(0,1fr)_16rem] lg:gap-12">
<div className="flex min-w-0 flex-col items-center text-center lg:items-start lg:text-left">
{children}
</div>
<div className="order-first flex flex-col items-center lg:order-none">
<div
ref={listeRef}
className="order-first flex flex-col items-center lg:order-none"
>
<PikselListe
tamamla={tamamla}
className="w-full max-w-[9rem] sm:max-w-[11rem] lg:max-w-[14rem]"

View File

@@ -1,31 +1,36 @@
"use client";
import type { RefObject } from "react";
import { PixelMasonry } from "@/components/pixel-decor";
import { cn } from "@/lib/utils";
/**
* CTA yüzeylerinin piksel zemini (DESIGN.md §10.8) — tek kaynak. Bilal'in
* kararı (24 Eyl 2026): "piksel arka planları her CTA benzeri kısmın
* arkasında olmalı, masonry gibi büyüklü küçüklü." Kap `relative isolate
* overflow-hidden` olmalı; zemin ilk çocuk olarak konur.
* kararı (24 Eyl 2026): pikseller **yalnız CTA yüzeylerinde** (mavi bant,
* kapanış kartı, arama penceresi kartı); beyaz içerik kartlarında değil
* ("/sonuc'taki kartın arkasına da piksel koymuşsun; pikseller CTA'larda
* olacak sadece"). Beyaz kareler; kap `relative isolate overflow-hidden`
* olmalı, zemin ilk çocuk olarak konur.
*
* - `acik`: beyaz/açık kart — silik mavi kareler.
* - `koyu`: mavi bant ya da koyu kart — beyaz kareler.
* Doku kabın gerçek ölçüsüne göre üretilir; kenar çerçevesi yoğun, orta
* seyrek. Üstünde kreatif ya da iri sayı varsa `haric` ile o öğe verilir,
* çevresi boş kalır.
*/
export function PikselZemin({
seed = 7,
ton = "acik",
bosBant,
yogunluk,
kenar,
haric,
className,
}: {
/** Aynı ekrandaki iki yüzey farklı desen alsın */
seed?: number;
ton?: "acik" | "koyu";
/** Boş bırakılacak yatay bant (varsayılan orta %22–78); bkz. PixelMasonry */
bosBant?: [number, number];
/** Doluluk çarpanı */
/** Doluluk çarpanı (1 varsayılan) */
yogunluk?: number;
/** Kenar çerçevesinin genişliği, kısa kenarın oranı (0.3 varsayılan) */
kenar?: number;
/** Çevresi boş bırakılacak öğe */
haric?: RefObject<HTMLElement | null>;
/** Dikey taşma payı vb. (varsayılan `-inset-y-6`) */
className?: string;
}) {
@@ -33,12 +38,16 @@ export function PikselZemin({
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 -inset-y-6 -z-10",
ton === "acik" ? "text-primary/70" : "text-white",
"pointer-events-none absolute inset-x-0 -inset-y-6 -z-10 text-white",
className,
)}
>
<PixelMasonry seed={seed} bosBant={bosBant} yogunluk={yogunluk} />
<PixelMasonry
seed={seed}
yogunluk={yogunluk}
kenar={kenar}
haric={haric}
/>
</div>
);
}

View File

@@ -1,6 +1,14 @@
"use client";
import { useMemo, useRef, type CSSProperties, type ReactNode } from "react";
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";
@@ -74,41 +82,86 @@ function pixelRect(
/**
* 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"). İçeriğin oturduğu orta bant boş kalır; kareler kenarlara doğru
* yoğunlaşır. 3×3 hücrelik makro hücrelere 1–3 hücrelik tek kare düşer ve
* makro hücre içinde kaydırılır: kareler çakışmaz ama hizaya da girmez
* (§1.1 "doğal duruş"). Durağan zemin: imleç ısı fırçasına katılmaz
* (Bilal, 24 Eyl 2026: "üzerine gelindiğinde bir şey olmasın").
* ş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).
*/
// Varsayılan boş bant modül sabiti: her render'da yeni dizi olup useMemo'yu
// boşa düşürmesin (rendering-hoist-jsx)
const BOS_BANT_VARSAYILAN: [number, number] = [0.22, 0.78];
export function PixelMasonry({
seed = 7,
bosBant = BOS_BANT_VARSAYILAN,
yogunluk = 1,
kenar = 0.3,
haric,
className,
}: {
seed?: number;
/** Genişliğin bu oranları arasındaki bant boş kalır (içerik orada) */
bosBant?: [number, 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 COLS = 42;
const ROWS = 12;
const MAKRO = 3;
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)
// Çıktı seed'e göre deterministik; render başına yeniden üretme
const pixels = useMemo(() => {
const cx = (COLS - 1) / 2;
const cy = (ROWS - 1) / 2;
const maxD = Math.sqrt(cx * cx + cy * cy);
const BAND_MIN = COLS * bosBant[0];
const BAND_MAX = COLS * bosBant[1];
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;
@@ -141,55 +194,57 @@ export function PixelMasonry({
);
}
// İri kare daha silik: yazının yanında bağırmasın
const op = opacityFor(x, y) * (n === 1 ? 1 : n === 2 ? 0.8 : 0.6);
const op = opacityFor(x, y) * (n === 2 ? 0.9 : n === 3 ? 0.7 : 0.55);
return (
<rect key={key} {...shared} fill="currentColor" fillOpacity={op} />
);
};
for (let my = 0; my < ROWS / MAKRO; my++) {
for (let mx = 0; mx < COLS / MAKRO; mx++) {
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 mcx = ox + MAKRO / 2;
if (mcx > BAND_MIN && mcx < BAND_MAX) continue;
// Kenarlara doğru artan doluluk
const d = Math.sqrt((mcx - cx) ** 2 + (oy + MAKRO / 2 - cy) ** 2) / maxD;
const density = (0.3 + 0.6 * d * d) * yogunluk;
if (hash(mx, my, seed) > density) {
// Boş makro hücrede ara sıra tek küçük piksel: doku kopmasın
if (hash(mx, my, seed + 3) < 0.25 * yogunluk) {
const px = ox + Math.floor(hash(mx, my, seed + 4) * MAKRO);
const py = oy + Math.floor(hash(mx, my, seed + 5) * MAKRO);
sonuc.push(kare(px, py, 1, `k-${mx}-${my}`, false));
}
continue;
}
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);
const n = r < 0.5 ? 1 : r < 0.85 ? 2 : 3;
// 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 < 3 && hash(mx, my, seed + 99) > 0.94;
const accent = n === 2 && hash(mx, my, seed + 99) > 0.95;
sonuc.push(kare(kx, ky, n, `m-${mx}-${my}`, accent));
// Büyük karenin yanına küçük bir eş: büyüklü küçüklü ritim
if (n < 3 && hash(mx, my, seed + 17) < 0.45) {
const ex = kx + n < ox + MAKRO ? kx + n : kx - 1;
const ey = ky + n < oy + MAKRO ? ky + n : ky - 1;
if (ex >= ox && ey >= oy) sonuc.push(kare(ex, ey, 1, `e-${mx}-${my}`, false));
}
}
}
return sonuc;
}, [seed, bosBant, yogunluk]);
}, [olcu, seed, yogunluk, kenar]);
return (
<svg
viewBox={`0 0 ${COLS * CELL} ${ROWS * CELL}`}
preserveAspectRatio="xMidYMid slice"
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", className)}
className={cn(
"h-full w-full transition-opacity duration-500 motion-reduce:transition-none",
pixels ? "opacity-100" : "opacity-0",
className,
)}
>
{pixels}
</svg>