feat(tasarim): görsel dilin tek kaynağı DESIGN.md, proje skill'i ve tasarım denetim betiği
All checks were successful
Deploy / deploy (push) Successful in 5m33s

- docs/tasarim/DESIGN.md: 9 bölümlük görsel dil belgesi (renk rolleri, tipografi,
  tek kaynak bileşenlerin sınıf dizileri, yerleşim, yap/yapma, brief şablonu)
- kolaytercih-tasarim skill'i: UI işinde tetiklenir, belgeye götürür, örnek verir;
  tasarimci ve yazilimci ajanlarına skills: ile önyüklenir
- scripts/tasarim-denetim.ts: 13 kural (Badge, CTA'da Sparkles, palet dışı ton,
  gradyan metin, parlama, hareket spam'i, çarpı, font); pnpm lint'e bağlandı
- ui-ux-pro-max genel kataloğu skills'ten .agents/skills-devre-disi/ altına taşındı
- AGENTS.md: DESIGN.md kuralı; rozet sınıfı koda göre düzeltildi (font-bricolage yoktu)
- arama-funnel-karti: palet dışı text-cyan-300 → text-orange-500

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-23 14:44:35 +03:00
parent a42a1a9920
commit dc7861737b
29 changed files with 506 additions and 13 deletions

215
scripts/tasarim-denetim.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* 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: "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)",
bul: regexKurali(
new RegExp(`\\b${RENK_ONEK}-(?:indigo|purple|fuchsia|pink|cyan|teal|lime|green|yellow)-\\d+`),
),
},
{
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: "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)",
bul: regexKurali(/\[#[0-9a-fA-F]{3,8}\]/),
},
{
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);