Files
kolaytercih/scripts/tasarim-denetim.ts
bilalgursen 2099f20ed1 feat(tasarim): form alanları hap, CTA bandı tek kaynak, masonry piksel zemin, çekmeceler §4.10
- Input/Select/Textarea: shadcn'in ince varsayılanı yerine üst çubuk
  haplarıyla aynı kalıp (h-12, rounded-full, slate-200, 16 px; md:text-sm
  yok); kullanım yerleri yalnız yerleşim sınıfı taşır. DESIGN.md §4.6
  tam sınıf dizisi; denetime ince-form-alani (hata) ve ham-input (uyarı)
- CtaBandi (mavi zemin + piksel doku) tek kaynak: /sonuc iki panel, katalog
  altı kart, "Kendi Listem" çekmecesi altı. Ana sayfa kapanışı koyu
  PikselCtaYuzeyi + 24'lük piksel liste kreatifiyle kalır (Bilal: "orası
  bayağı iyiydi"); yazılıp silinen satır kaldırıldı
- PixelMasonry: CTA arka planları büyüklü küçüklü, hafif kaymış kareler;
  eşit ızgaralı PixelField kaldırıldı
- Çekmece başlığındaki 12×2 piksel liste kaldırıldı (çok dolu); sayı +
  ilerleme çubuğu + risk dağılımı geri geldi
- "Saniyede gör" çekmecesi §4.10 düzeninde: ortalı DrawerTitle + bağlam,
  rozet ve çizgili "neler alacaksın" listesi yok; kapı modalındaki iki
  rozet kalktı; denetime modal-rozet (hata)
- Kapı panelinde 3 px odak halkası kırpılmıyor (px-px → p-1)
- Liste satırı: üniversite adı + tür/il/süre adın altında tek akan satır,
  telefonda text-xs (SATIR_ALT_GRUP); katalog listelerinde ad kendi
  satırında (SATIR_GRUP space-y-2). Ölçüm 375 px canlı bölüm sayfası:
  satır yüksekliğini metin değil alt alta iki 44 px düğme belirliyor

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

304 lines
11 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.
/**
* Tasarım dili denetimi — docs/tasarim/DESIGN.md kurallarının makineyle
* yakalanabilen kısmı. Ajan/insan fark etmez: `pnpm tasarim:denetim`.
*
* Çıkış kodu: hata varsa 1, yoksa 0. Uyarılar çıkış kodunu etkilemez.
* Kapsam: src/**\/*.tsx. Hariç: src/components/ui (shadcn tabanı) ve
* src/components/dev (yalnızca geliştirici paneli) — bazı kurallarda.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
type Seviye = "hata" | "uyari";
type Bulgu = {
dosya: string;
satir: number;
kural: string;
seviye: Seviye;
mesaj: string;
parca: string;
};
type Kural = {
ad: string;
seviye: Seviye;
mesaj: string;
/** ui/dev klasörleri de taransın mı */
uiDahil?: boolean;
/** Tam yolu eşleşen dosyalar kuraldan muaf */
muaf?: RegExp;
bul: (icerik: string) => Array<{ index: number; parca: string }>;
};
const KOK = process.cwd();
const KAYNAK = join(KOK, "src");
const UI_VEYA_DEV = /src\/components\/(ui|dev)\//;
function tsxDosyalari(dizin: string): string[] {
const sonuc: string[] = [];
for (const ad of readdirSync(dizin)) {
const yol = join(dizin, ad);
if (statSync(yol).isDirectory()) sonuc.push(...tsxDosyalari(yol));
else if (yol.endsWith(".tsx")) sonuc.push(yol);
}
return sonuc;
}
function satirNo(icerik: string, index: number): number {
let n = 1;
for (let i = 0; i < index && i < icerik.length; i++) if (icerik[i] === "\n") n++;
return n;
}
function satirParcasi(icerik: string, index: number): string {
const bas = icerik.lastIndexOf("\n", index) + 1;
let son = icerik.indexOf("\n", index);
if (son === -1) son = icerik.length;
return icerik.slice(bas, son).trim().slice(0, 110);
}
/** Basit regex kuralı: her eşleşme bir bulgu. */
function regexKurali(re: RegExp) {
return (icerik: string) => {
const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
const sonuc: Array<{ index: number; parca: string }> = [];
for (const m of icerik.matchAll(g)) sonuc.push({ index: m.index ?? 0, parca: m[0] });
return sonuc;
};
}
/** <Etiket …>…</Etiket> bloklarının içinde `ic` deseni varsa bulgu. */
function blokIcinde(etiketler: string[], ic: RegExp) {
return (icerik: string) => {
const sonuc: Array<{ index: number; parca: string }> = [];
for (const etiket of etiketler) {
const blok = new RegExp(`<${etiket}\\b[\\s\\S]*?<\\/${etiket}>`, "g");
for (const m of icerik.matchAll(blok)) {
const icM = ic.exec(m[0]);
if (icM) sonuc.push({ index: (m.index ?? 0) + icM.index, parca: icM[0] });
}
}
return sonuc;
};
}
const RENK_ONEK = "(?:bg|text|border|ring|fill|stroke|from|to|via|divide|outline|placeholder|decoration|accent|caret|shadow)";
const KURALLAR: Kural[] = [
{
ad: "badge-import",
seviye: "hata",
mesaj: "Rozet için @/components/ui/badge değil SectionEyebrow kullanılır (DESIGN.md §4.2)",
bul: regexKurali(/from\s+["']@\/components\/ui\/badge["']/),
},
{
ad: "elle-rozet",
seviye: "hata",
mesaj: "Elle yazılmış rozet; SectionEyebrow'u içe aktar (DESIGN.md §4.2)",
muaf: /src\/components\/pixel-decor\.tsx$/,
bul: regexKurali(/rounded-full border border-slate-200 bg-white px-3\.5 py-1\.5/),
},
{
ad: "modal-rozet",
seviye: "hata",
mesaj: "Çekmece/modal içinde SectionEyebrow yok; başlık + tek cümle bağlam (DESIGN.md §4.10)",
bul: blokIcinde(["DrawerContent", "DialogContent"], /<SectionEyebrow\b/),
},
{
ad: "cta-yildiz",
seviye: "hata",
mesaj: "CTA butonunda Sparkles/yıldız ikonu olmaz; metin + sondaki ArrowRight (DESIGN.md §4.1)",
bul: blokIcinde(["Button", "Link"], /<Sparkles\b/),
},
{
ad: "yabanci-notr",
seviye: "hata",
mesaj: "Nötr renk yalnızca slate; gray/zinc/neutral/stone yazılmaz (DESIGN.md §2)",
bul: regexKurali(new RegExp(`\\b${RENK_ONEK}-(?:gray|zinc|neutral|stone)-\\d+`)),
},
{
ad: "yabanci-renk",
seviye: "hata",
mesaj: "Paletin dışında ton; primary mavi / orange-500 / slate / risk renkleri kullanılır (DESIGN.md §2)",
// İstisna (DESIGN.md §2 "Gösterge karesi"): yalnız dolu bg-indigo-500 ve
// bg-green-600 zeminleri serbest; başka ton/opaklık/özellik yine hata.
bul: regexKurali(
new RegExp(
`\\b${RENK_ONEK}-(?:indigo|purple|fuchsia|pink|cyan|teal|lime|green|yellow)-\\d+(?!\\d)(?!(?<=\\bbg-(?:indigo-500|green-600))(?![\\w/-]))`,
),
),
},
{
ad: "gradyan-metin",
seviye: "hata",
mesaj: "Gradyan metin yok (DESIGN.md §2)",
bul: regexKurali(/\b(?:bg-clip-text|text-transparent)\b/),
},
{
ad: "parlama-golgesi",
seviye: "hata",
mesaj: "Parlama (glow) gölgesi yok; shadow-sm / hover:shadow-lg shadow-primary/5 (DESIGN.md §4.4)",
bul: regexKurali(/shadow-\[0_0_[1-9]\d*px/),
},
{
ad: "hareket-spam",
seviye: "hata",
mesaj:
"whileHover scale / aos / bounce yok; Reveal, piksel ısısı, kt-unblur (DESIGN.md §7). animate-ping yalnızca canlı göstergesi noktasında",
bul: regexKurali(/whileHover=\{\{\s*scale|data-aos=|\banimate-bounce\b/),
},
{
ad: "kapatma-carpisi",
seviye: "hata",
mesaj: "showCloseButton={false} verilen dosyada kanonik çarpı sınıf seti yok (DESIGN.md §4.3)",
bul: (icerik) => {
const m = /showCloseButton=\{false\}/.exec(icerik);
if (!m) return [];
const kanonik =
/size-9 cursor-pointer items-center justify-center rounded-full border border-slate-200/.test(icerik);
return kanonik ? [] : [{ index: m.index, parca: m[0] }];
},
},
{
ad: "ince-form-alani",
seviye: "hata",
mesaj: "Input/SelectTrigger/Textarea üstüne yükseklik, köşe ya da zemin yazılmaz; hap varsayılanı kullanılır (DESIGN.md §4.6)",
bul: (icerik) => {
const sonuc: Array<{ index: number; parca: string }> = [];
// Açılış etiketinden ilk > işaretine kadar olan blok (self-closing dahil)
const acilis = /<(?:Input|SelectTrigger|Textarea)\b[^>]*?>/g;
const yasak =
/\b(?:(?:sm|md|lg):)?(?:h-(?:7|8|9|10)|rounded-(?:sm|md|lg|xl)|bg-background|border-input)\b/;
for (const m of icerik.matchAll(acilis)) {
const y = yasak.exec(m[0]);
if (y) sonuc.push({ index: (m.index ?? 0) + y.index, parca: y[0] });
}
return sonuc;
},
},
{
ad: "ham-input",
seviye: "uyari",
mesaj: "Kenarlıklı ham <input> yerine Input bileşeni; kenarlıksız ham input yalnız hap kabının içinde (DESIGN.md §4.6)",
bul: (icerik) => {
const sonuc: Array<{ index: number; parca: string }> = [];
for (const m of icerik.matchAll(/<input\b[^>]*?>/g)) {
if (/type=["'](?:hidden|checkbox|radio|range|file)["']/.test(m[0])) continue;
const k = /\bborder(?:-[a-z0-9/]+)?\b/.exec(m[0]);
if (k) sonuc.push({ index: (m.index ?? 0) + k.index, parca: k[0] });
}
return sonuc;
},
},
{
ad: "yabanci-font",
seviye: "hata",
mesaj: "Yalnızca font-heading / font-sans / font-bricolage / font-mono (DESIGN.md §3)",
bul: regexKurali(/\bfont-(?:serif|\[)|["'](?:Inter|Roboto|Arial|Helvetica)["']/),
},
{
ad: "dark-sinif",
seviye: "uyari",
mesaj: "Üründe karanlık mod yok; dark: sınıfı gereksiz (DESIGN.md §1)",
bul: regexKurali(/(?<=[\s"'`])dark:[a-z]/),
},
{
ad: "hex-renk",
seviye: "uyari",
mesaj: "Sınıf içinde hex renk; token ya da Tailwind tonu kullan (DESIGN.md §2)",
// Rehber kapak görseli OG görseliyle aynı sabit paleti paylaşır (satori
// tarafında Tailwind token'ı çözülmez); bilinçli istisna.
muaf: /src\/features\/rehber\/components\/rehber-kapak\.tsx$/,
bul: regexKurali(/\[#[0-9a-fA-F]{3,8}\]/),
},
{
ad: "minik-yazi",
seviye: "uyari",
mesaj: "12 px altı yazı yok; en küçük text-xs, yalnız ince yazıda (DESIGN.md §3 alt sınırlar)",
bul: regexKurali(/\btext-\[(?:[0-9]|1[01])px\]/),
},
{
ad: "buyuk-harf-etiket",
seviye: "uyari",
mesaj: "Büyük harfli minik etiket yok; büyük-küçük harfli text-sm font-semibold (DESIGN.md §3 alt sınırlar)",
bul: regexKurali(/\buppercase\b[^"'`\n]*\btracking-(?:wide|wider|widest)\b|\btracking-(?:wide|wider|widest)\b[^"'`\n]*\buppercase\b/),
},
{
ad: "mavi-hover",
seviye: "uyari",
mesaj: "Hover rengi tek turuncu (orange-500); liste satırındaki ad linkiyse istisna, mavi kalır (DESIGN.md §2.1)",
// §2.1 istisnası: §4.15 kanonik liste satırı adı (sarar, text-base
// font-semibold leading-snug + hover:text-primary hover:underline) mavi
// kalır. Eski truncate/text-sm adlar istisnaya girmez — önce §4.15'e
// çevrilir. Satırın tamamı link olduğunda group-hover çifti de sayılır.
// (Bilal'in kararı, 24 Eyl 2026: istisna uyarı vermesin.)
bul: (icerik) =>
regexKurali(/\b(?:group-)?hover:(?:text|border|shadow)-primary\b/)(icerik).filter((e) => {
const bas = icerik.lastIndexOf("\n", e.index) + 1;
const son = icerik.indexOf("\n", e.index);
const satir = icerik.slice(bas, son === -1 ? undefined : son);
return !(
/\btext-base font-semibold leading-snug\b/.test(satir) &&
/\b(?:group-)?hover:text-primary (?:group-)?hover:underline\b/.test(satir)
);
}),
},
{
ad: "iri-sayi-tabular",
seviye: "uyari",
mesaj: "Cal Sans iri sayıda tabular-nums 'daktilo' gibi durur; yalnız font-sans tablo sütunlarında (DESIGN.md §3)",
bul: regexKurali(/\bfont-heading\b[^"'`\n]*\btabular-nums\b|\btabular-nums\b[^"'`\n]*\bfont-heading\b/),
},
{
ad: "samimiyetsiz-unlem",
seviye: "uyari",
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: "emoji-ikon",
seviye: "uyari",
mesaj: "Emoji ikon olarak kullanılmaz; lucide ikon (DESIGN.md §4.7)",
bul: (icerik) =>
// Yorum satırındaki işaretler (// ✗ gibi) ikon değildir
regexKurali(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]\uFE0F?/u)(icerik).filter(
(e) => !/^(?:\/\/|\*|\/\*)/.test(satirParcasi(icerik, e.index)),
),
},
];
const bulgular: Bulgu[] = [];
for (const yol of tsxDosyalari(KAYNAK)) {
const gorece = relative(KOK, yol);
const icerik = readFileSync(yol, "utf8");
for (const k of KURALLAR) {
if (!k.uiDahil && UI_VEYA_DEV.test(gorece)) continue;
if (k.muaf && k.muaf.test(gorece)) continue;
for (const e of k.bul(icerik)) {
bulgular.push({
dosya: gorece,
satir: satirNo(icerik, e.index),
kural: k.ad,
seviye: k.seviye,
mesaj: k.mesaj,
parca: satirParcasi(icerik, e.index),
});
}
}
}
bulgular.sort((a, b) =>
a.seviye !== b.seviye ? (a.seviye === "hata" ? -1 : 1) : a.dosya.localeCompare(b.dosya) || a.satir - b.satir,
);
for (const b of bulgular) {
const etiket = b.seviye === "hata" ? "HATA " : "uyarı";
console.log(`${b.dosya}:${b.satir} [${etiket}] ${b.kural} — ${b.mesaj}\n ${b.parca}`);
}
const hata = bulgular.filter((b) => b.seviye === "hata").length;
const uyari = bulgular.length - hata;
console.log(`\nTasarım denetimi: ${hata} hata, ${uyari} uyarı (${KURALLAR.length} kural, docs/tasarim/DESIGN.md)`);
process.exit(hata > 0 ? 1 : 0);