@moontra/moonui 6.22.0 → 6.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moontra/moonui",
3
- "version": "6.22.0",
3
+ "version": "6.24.0",
4
4
  "description": "Premium React component library with modern design and customization",
5
5
  "author": "MoonUI",
6
6
  "license": "MIT",
@@ -0,0 +1,601 @@
1
+ /**
2
+ * #544 + #545 — kırmızı/durum renklerinin DOLGU ve METİN rollerinde WCAG AA kontrastı.
3
+ *
4
+ * ══ #544 (free paket) ══════════════════════════════════════════════════════════
5
+ * `hsl(0 84% 60%)` = #ef4343 iki ayrı token'da (light `--error`, dark `--destructive`)
6
+ * DOLGU rolünde beyaza yakın metin taşıyor ve AA'yı geçmiyor:
7
+ * Badge destructive light #ffffff / #ef4343 → 3.78 ✗
8
+ * Button destructive dark #f8fafc / #ef4343 → 3.61 ✗
9
+ *
10
+ * ⚠️ TOKEN DEĞERLERİ DEĞİŞTİRİLMEDİ — ölçümle gerekçelendirildi (issue'nun "Yol B"si
11
+ * ve "Yol A"sının Badge ayağı REDDEDİLDİ). Dört ayrı test dosyası bu değerleri
12
+ * BİLEREK kilitliyor:
13
+ * · dark-token-contrast.test.ts — dark `--destructive` 60%, METİN rolü için seçildi
14
+ * (kart üstünde 4.69); dolgu rolündeki 3.61 orada BELGELENMİŞ bir uzlaşmadır ve
15
+ * 3.0 tabanıyla kilitli. Token'ı koyulaştırmak 43 dosyalık `text-destructive`
16
+ * metin rolünü kırar.
17
+ * · progress-warning-error-contrast.test.ts + slider-warning-error-contrast.test.ts
18
+ * (free) + pro ikizi — `--error` light = [239,67,67] ve `--error`in `.dark`
19
+ * bloğunda EZİLMEMESİ, #424/#442'nin kök-neden anlatısının DAYANAĞI.
20
+ * Bu yüzden düzeltme #424/#442/#454'ün kurduğu presedansın şeklini izler:
21
+ * **token'a değil VARYANTA dokun, skalanın bir kademesine in.**
22
+ *
23
+ * TON SEÇİMİ ÖLÇÜMLE YAPILDI (brief: "AA'yı sağlayan EN KÜÇÜK ayar"):
24
+ * aday hex beyazla #f8fafc ile bugünkü #ef4343'e ΔE*ab
25
+ * error-500 #ef4444 3.76 ✗ 3.60 ✗ —
26
+ * error-600 #dc2626 4.83 ✓ 4.62 ✓ 10.3 ← SEÇİLDİ (en küçük ayar)
27
+ * error-700 #b91c1c 6.47 ✓ 6.18 ✓ 16.2
28
+ * --destructive light #e11313 4.88 ✓ 4.66 ✓ 18.8 ← issue'nun önerisi, 2× daha büyük değişim
29
+ * Hue üçünde de 0° → "uyarı kırmızısı" kimliği korunur (issue §6 kabul kriteri).
30
+ *
31
+ * ══ #545 (docs canlı demo kodu) ════════════════════════════════════════════════
32
+ * Aynı sınıf kusur `src/docs-config/`te ham Tailwind literali olarak duruyor.
33
+ * ⚠️ NAİF DÜZELTMENİN TUZAĞI ÖLÇÜLDÜ: `-600 → -700` tek başına LIGHT'ı düzeltip
34
+ * DARK'ı KIRIYOR (dark ikizi olmayan yerlerde):
35
+ * text-green-600 koyu kart #111827 → 5.38 ✓ ama success-700 → 3.54 ✗
36
+ * text-yellow-600 koyu kart #111827 → 6.04 ✓ ama warning-700 → 3.60 ✗
37
+ * Bu yüzden ton yükseltilen HER yere dark ikizi (-400) eklenir; dark ikizi ZATEN
38
+ * VAR ve geçiyorsa (`dark:text-red-300` → 9.41) DOKUNULMAZ.
39
+ *
40
+ * ⚠️ ZEMİN HER ÖLÇÜMDE AÇIKÇA YAZILIR. `text-red-600` beyaza karşı 4.83 ile GEÇER
41
+ * ama gerçek demo zemini `bg-red-50` (#fef2f2) ve orada 4.41 ile KALIR — bu issue'nun
42
+ * kendi kaydettiği ölçüm dersi.
43
+ */
44
+ import * as React from 'react'
45
+ import { render } from '@testing-library/react'
46
+ import '@testing-library/jest-dom'
47
+ import * as fs from 'fs'
48
+ import * as path from 'path'
49
+ import { Badge } from '../components/ui/badge'
50
+ import { Button } from '../components/ui/button'
51
+
52
+ const PAKET = path.resolve(__dirname, '..')
53
+ const DEPO = path.resolve(__dirname, '../../../..')
54
+ const tokens = fs.readFileSync(path.join(PAKET, 'styles/tokens.css'), 'utf8')
55
+ const badgeSrc = fs.readFileSync(path.join(PAKET, 'components/ui/badge.tsx'), 'utf8')
56
+ const buttonSrc = fs.readFileSync(path.join(PAKET, 'components/ui/button.tsx'), 'utf8')
57
+
58
+ type RGB = [number, number, number]
59
+ type Blok = 'light' | 'dark'
60
+
61
+ // ─── Renk aritmetiği (switch-variant-contrast.test.tsx ile birebir aynı yöntem) ───
62
+
63
+ function tokenOku(ad: string, blok: Blok): string | null {
64
+ const kaynak =
65
+ blok === 'dark' ? tokens.slice(tokens.indexOf('.dark')) : tokens.slice(0, tokens.indexOf('.dark'))
66
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
67
+ }
68
+
69
+ function hslToRgb(h: number, s: number, l: number): RGB {
70
+ h /= 360
71
+ s /= 100
72
+ l /= 100
73
+ const f = (n: number) => {
74
+ const k = (n + h * 12) % 12
75
+ const a = s * Math.min(l, 1 - l)
76
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
77
+ }
78
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
79
+ }
80
+
81
+ const hslTokenRgb = (v: string): RGB => {
82
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
83
+ return hslToRgb(h, s, l)
84
+ }
85
+ /** Skala token'ları ("--error-600: 220 38 38") HSL DEĞİL, RGB üçlüsüdür */
86
+ const rgbTokenRgb = (v: string): RGB => {
87
+ const p = v.split(/\s+/).map((x) => parseFloat(x))
88
+ return [p[0], p[1], p[2]]
89
+ }
90
+ /** Semantik token'ın ilgili temadaki değeri — `.dark` ezmesi YOKSA light'a düşer */
91
+ const semantikRgb = (ad: string, blok: Blok): RGB =>
92
+ hslTokenRgb(tokenOku(ad, blok) ?? tokenOku(ad, 'light')!)
93
+ /** Skala kademeleri tema-BAĞIMSIZ: `.dark` bloğu onları ezmiyor (preset yorumu) */
94
+ const skalaRgb = (ad: string): RGB => rgbTokenRgb(tokenOku(ad, 'light')!)
95
+
96
+ function bagilParlaklik([r, g, b]: RGB): number {
97
+ const c = (v: number) => {
98
+ const x = v / 255
99
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
100
+ }
101
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
102
+ }
103
+ function kontrast(a: RGB, b: RGB): number {
104
+ const la = bagilParlaklik(a)
105
+ const lb = bagilParlaklik(b)
106
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
107
+ return (hi + 0.05) / (lo + 0.05)
108
+ }
109
+ /** Yarı saydam bir rengi opak zemine bindir — `bg-error/90` gibi sınıflar için ŞART */
110
+ const komposit = (ust: RGB, alt: RGB, alfa: number): RGB =>
111
+ ust.map((c, i) => Math.round(c * alfa + alt[i] * (1 - alfa))) as RGB
112
+
113
+ const hex = ([r, g, b]: RGB): string =>
114
+ '#' + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
115
+
116
+ // ─── Zeminler — HER ÖLÇÜMDE ADIYLA ANILIR ───────────────────────────────────────
117
+ const BEYAZ: RGB = [255, 255, 255]
118
+ /** Docs kabuğunda ölçülen GERÇEK koyu kart (#284/#368/#448/#454 konvansiyonu) */
119
+ const KART_GERCEK_DARK: RGB = [17, 24, 39]
120
+
121
+ const AA_METIN = 4.5
122
+ const AA_METIN_DISI = 3.0
123
+
124
+ // ─── Varyant sınıf dizgesini KAYNAKTAN çöz (renkler VARSAYILMAZ) ────────────────
125
+
126
+ /** cva `variant` bloğundaki dizi biçimli varyant girdisini birleşik sınıf dizgesi olarak ver */
127
+ function varyantSinifi(kaynak: string, varyant: string): string {
128
+ const m = kaynak.match(new RegExp(`\\n\\s*${varyant}:\\s*\\[([\\s\\S]*?)\\n\\s*\\]`))
129
+ if (!m) throw new Error(`${varyant} varyantı bulunamadı`)
130
+ return (m[1].match(/"([^"]*)"/g) ?? []).map((s) => s.slice(1, -1)).join(' ')
131
+ }
132
+
133
+ /** `bg-error-600` / `bg-error` / `bg-error/90` / `text-white` → RGB (alfa varsa zemine bindirir) */
134
+ function sinifRengi(sinif: string, blok: Blok, zemin: RGB): RGB {
135
+ // ⚠️ `+` ŞART: `dark:hover:bg-error-700` gibi ÇOK önekli sınıflarda tek tur
136
+ // soyma yetmez ve sınıf "çözülemez" diye PATLAR — testi kontrast ölçmeden
137
+ // kırmızı gösterir (yanlış sebeple kırmızı = geçersiz ölçüm).
138
+ const g = sinif.replace(/^(?:(?:dark|hover|active|focus-visible|group-hover):)+/, '')
139
+ const m = g.match(/^(?:bg|text|border|ring)-([a-z-]+?)(?:-(\d{2,3}))?(?:\/(\d{1,3}))?$/)
140
+ if (!m) throw new Error(`çözülemeyen sınıf: ${sinif}`)
141
+ const [, ad, kademe, alfa] = m
142
+ if (ad === 'white') return alfa ? komposit(BEYAZ, zemin, Number(alfa) / 100) : BEYAZ
143
+ const taban = kademe ? skalaRgb(`${ad}-${kademe}`) : semantikRgb(ad, blok)
144
+ return alfa ? komposit(taban, zemin, Number(alfa) / 100) : taban
145
+ }
146
+
147
+ /** Bir varyantın ilgili temadaki ETKİN dolgusu — `dark:` önekli sınıf dark'ta kazanır */
148
+ function dolgu(kaynak: string, varyant: string, blok: Blok, zemin: RGB, onek = ''): RGB {
149
+ const adaylar = varyantSinifi(kaynak, varyant)
150
+ .split(/\s+/)
151
+ .filter((s) => new RegExp(`^(?:dark:)?${onek}bg-`).test(s))
152
+ const dark = adaylar.find((s) => s.startsWith('dark:'))
153
+ const light = adaylar.find((s) => !s.startsWith('dark:'))
154
+ const secilen = blok === 'dark' ? (dark ?? light) : light
155
+ if (!secilen) throw new Error(`${varyant}/${blok}${onek ? ` (${onek})` : ''}: dolgu sınıfı yok`)
156
+ return sinifRengi(secilen, blok, zemin)
157
+ }
158
+
159
+ /** Varyantın metin rengi (`text-*`), `bg-*` HARİÇ */
160
+ function metin(kaynak: string, varyant: string, blok: Blok, zemin: RGB): RGB {
161
+ const adaylar = varyantSinifi(kaynak, varyant)
162
+ .split(/\s+/)
163
+ .filter((s) => /^(?:dark:)?text-/.test(s))
164
+ const dark = adaylar.find((s) => s.startsWith('dark:'))
165
+ const light = adaylar.find((s) => !s.startsWith('dark:'))
166
+ const secilen = blok === 'dark' ? (dark ?? light) : light
167
+ if (!secilen) throw new Error(`${varyant}/${blok}: metin sınıfı yok`)
168
+ return sinifRengi(secilen, blok, zemin)
169
+ }
170
+
171
+ /** Dolgu opaksa zeminden bağımsızdır; yarı saydamsa zemine göre değişir → ikisini de dene */
172
+ function dolguMetinOrani(kaynak: string, varyant: string, blok: Blok, zemin: RGB) {
173
+ const d = dolgu(kaynak, varyant, blok, zemin)
174
+ return { oran: kontrast(d, metin(kaynak, varyant, blok, d)), dolguHex: hex(d) }
175
+ }
176
+
177
+ // ════════════════════════════════════════════════════════════════════════════════
178
+ describe('#544/#545 — hesaplayıcı çapaları (sessiz-yeşil koruması)', () => {
179
+ it('ÇAPA: tokens.css `--info-subtle` çifti belgelenen ~9.5 oranını veriyor', () => {
180
+ const oran = kontrast(semantikRgb('info-subtle', 'light'), semantikRgb('info-subtle-foreground', 'light'))
181
+ expect(oran).toBeGreaterThan(9.3)
182
+ expect(oran).toBeLessThan(9.7)
183
+ })
184
+
185
+ it('ÇAPA: siyah↔beyaz tam 21.00', () => {
186
+ expect(kontrast([0, 0, 0], BEYAZ)).toBeCloseTo(21, 2)
187
+ })
188
+
189
+ it('ÇAPA: hesaplayıcı, issue #544 gövdesindeki GERÇEK TARAYICI ölçümlerini üretiyor', () => {
190
+ // Bu tutmuyorsa aşağıdaki tüm oranlar anlamsızdır.
191
+ // Badge light: zemin YOK (opak dolgu) — çift doğrudan #ffffff / #ef4343
192
+ expect(kontrast(semantikRgb('error', 'light'), BEYAZ)).toBeCloseTo(3.78, 2)
193
+ // Button dark: #f8fafc / #ef4343
194
+ expect(
195
+ kontrast(semantikRgb('destructive', 'dark'), semantikRgb('destructive-foreground', 'dark'))
196
+ ).toBeCloseTo(3.61, 2)
197
+ })
198
+
199
+ it('NEGATİF KONTROL: eşik mantığı ayrım yapıyor — eski değerler GERÇEKTEN kalıyor', () => {
200
+ // "Her şey yeşil" tuzağına karşı: ölçüm başarısızlığı tespit edebiliyor mu?
201
+ expect(kontrast(semantikRgb('error', 'light'), BEYAZ)).toBeLessThan(AA_METIN)
202
+ expect(kontrast(skalaRgb('error-500'), BEYAZ)).toBeLessThan(AA_METIN)
203
+ // ve gerçekten geçen bir değeri geçmiş sayıyor
204
+ expect(kontrast(skalaRgb('error-700'), BEYAZ)).toBeGreaterThan(AA_METIN)
205
+ })
206
+
207
+ it('ÇAPA: skala token’ları tema-BAĞIMSIZ — `.dark` bloğu onları EZMİYOR', () => {
208
+ // `bg-error-600`ün tek sınıfla iki temayı birden kapatmasının YAPISAL dayanağı.
209
+ // Biri `.dark` bloğuna `--error-600` eklerse bu kırmızı olur.
210
+ for (const k of [400, 600, 700, 800]) expect(tokenOku(`error-${k}`, 'dark')).toBeNull()
211
+ })
212
+ })
213
+
214
+ // ════════════════════════════════════════════════════════════════════════════════
215
+ describe('#544 — KAPSAM KİLİDİ: token değerleri DEĞİŞMEDİ', () => {
216
+ // Bu issue token'a DOKUNMAZ. Biri token'ı oynatırsa dört ayrı dosyadaki
217
+ // kök-neden anlatısı sessizce geçersizleşir; burası o kararı görünür kılar.
218
+ it.each([
219
+ ['error', 'light', '0 84% 60%'],
220
+ ['destructive', 'light', '0 84.2% 48%'],
221
+ ['destructive', 'dark', '0 84% 60%'],
222
+ ])('--%s (%s) sabit: %s', (ad, blok, beklenen) => {
223
+ expect(tokenOku(ad, blok as Blok)).toBe(beklenen)
224
+ })
225
+
226
+ it('`--error` hâlâ `.dark` bloğunda EZİLMİYOR (#424/#442’nin kök-neden dayanağı)', () => {
227
+ expect(tokenOku('error', 'dark')).toBeNull()
228
+ })
229
+ })
230
+
231
+ describe('#544 — KABUL KRİTERİ: Badge destructive dolgusu ≥ 4.5:1', () => {
232
+ // Badge `text-white` taşır. Dolgu bugün YARI SAYDAM (`dark:bg-error/90`) olduğu
233
+ // için dark ölçümü ZEMİNE bağlı — iki gerçek zeminde birden ölçülür.
234
+ it('LIGHT — zemin YOK (opak dolgu), çift: dolgu ↔ text-white', () => {
235
+ const { oran, dolguHex } = dolguMetinOrani(badgeSrc, 'destructive', 'light', BEYAZ)
236
+ expect(`${dolguHex}: ${oran >= AA_METIN ? 'geçer' : oran.toFixed(2)}`).toBe(`${dolguHex}: geçer`)
237
+ })
238
+
239
+ it.each([
240
+ ['koyu sayfa --background', 'dark'],
241
+ ['gerçek koyu kart #111827', 'kart'],
242
+ ])('DARK — zemin: %s', (zeminAdi, tur) => {
243
+ const zemin = tur === 'kart' ? KART_GERCEK_DARK : semantikRgb('background', 'dark')
244
+ const { oran, dolguHex } = dolguMetinOrani(badgeSrc, 'destructive', 'dark', zemin)
245
+ expect(`[${zeminAdi}] ${dolguHex}: ${oran >= AA_METIN ? 'geçer' : oran.toFixed(2)}`).toBe(
246
+ `[${zeminAdi}] ${dolguHex}: geçer`
247
+ )
248
+ })
249
+
250
+ it('HOVER durumu da AA taşıyor (düzeltme tek duruma sıkışmasın)', () => {
251
+ for (const blok of ['light', 'dark'] as const) {
252
+ const zemin = blok === 'dark' ? KART_GERCEK_DARK : BEYAZ
253
+ const d = dolgu(badgeSrc, 'destructive', blok, zemin, 'hover:')
254
+ const oran = kontrast(d, metin(badgeSrc, 'destructive', blok, d))
255
+ expect(`${blok} hover ${hex(d)}: ${oran >= AA_METIN ? 'geçer' : oran.toFixed(2)}`).toBe(
256
+ `${blok} hover ${hex(d)}: geçer`
257
+ )
258
+ }
259
+ })
260
+ })
261
+
262
+ describe('#544 — KABUL KRİTERİ: Button destructive dolgusu ≥ 4.5:1', () => {
263
+ it('KONTROL GRUBU — LIGHT zaten geçiyordu (4.66) ve DOKUNULMADI', () => {
264
+ // Kırmızı fazda BİLEREK yeşil kalan test. İki işi var:
265
+ // (1) düzeneğin topluca kırmızı olmadığını gösterir,
266
+ // (2) #442'nin "geçen temayı oynatma, regresyon yüzeyini sıfır tut" kuralını kilitler.
267
+ const { oran } = dolguMetinOrani(buttonSrc, 'destructive', 'light', BEYAZ)
268
+ expect(oran).toBeGreaterThanOrEqual(AA_METIN)
269
+ expect(oran).toBeCloseTo(4.66, 1)
270
+ expect(varyantSinifi(buttonSrc, 'destructive')).toContain('bg-destructive text-destructive-foreground')
271
+ })
272
+
273
+ it('DARK — zemin YOK (opak dolgu), çift: dolgu ↔ text-destructive-foreground', () => {
274
+ const { oran, dolguHex } = dolguMetinOrani(buttonSrc, 'destructive', 'dark', KART_GERCEK_DARK)
275
+ expect(`${dolguHex}: ${oran >= AA_METIN ? 'geçer' : oran.toFixed(2)}`).toBe(`${dolguHex}: geçer`)
276
+ })
277
+
278
+ it('HOVER + ACTIVE durumları da AA taşıyor (dark)', () => {
279
+ for (const onek of ['hover:', 'active:']) {
280
+ const d = dolgu(buttonSrc, 'destructive', 'dark', KART_GERCEK_DARK, onek)
281
+ const oran = kontrast(d, metin(buttonSrc, 'destructive', 'dark', d))
282
+ expect(`${onek}${hex(d)}: ${oran >= AA_METIN ? 'geçer' : oran.toFixed(2)}`).toBe(
283
+ `${onek}${hex(d)}: geçer`
284
+ )
285
+ }
286
+ })
287
+ })
288
+
289
+ describe('#544 — kimlik korunumu: “uyarı kırmızısı” algısı kayboldu mu?', () => {
290
+ it('seçilen dolgu hâlâ SAF KIRMIZI (hue 0°, issue §6 görsel kabul kriteri)', () => {
291
+ const hue = ([r, g, b]: RGB) => {
292
+ const mx = Math.max(r, g, b)
293
+ const mn = Math.min(r, g, b)
294
+ if (mx === mn) return 0
295
+ const d = mx - mn
296
+ const h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4
297
+ return Math.round((h * 60 + 360) % 360)
298
+ }
299
+ expect(hue(dolgu(badgeSrc, 'destructive', 'light', BEYAZ))).toBe(0)
300
+ expect(hue(dolgu(buttonSrc, 'destructive', 'dark', KART_GERCEK_DARK))).toBe(0)
301
+ })
302
+ })
303
+
304
+ describe('#544 — REGRESYON BEKÇİSİ: dokunulmayan varyantlar bit-bit aynı', () => {
305
+ // Ölçülen taban (2026-08-21, düzeltme ÖNCESİ). Bu dizgeler değişirse
306
+ // düzeltme kapsam dışına taşmış demektir.
307
+ it('Badge: destructive DIŞINDAKİ varyant dizgeleri değişmedi', () => {
308
+ // NOT: Badge'in VARSAYILAN varyantının adı `default` değil `primary`.
309
+ expect(varyantSinifi(badgeSrc, 'primary')).toContain('bg-primary text-primary-foreground')
310
+ expect(varyantSinifi(badgeSrc, 'secondary')).toContain('bg-secondary text-secondary-foreground')
311
+ expect(varyantSinifi(badgeSrc, 'outline')).toContain('bg-transparent text-foreground')
312
+ expect(varyantSinifi(badgeSrc, 'success')).toContain('bg-success text-success-foreground')
313
+ expect(varyantSinifi(badgeSrc, 'warning')).toContain('bg-warning text-warning-foreground')
314
+ })
315
+
316
+ it('Button: destructive DIŞINDAKİ varyant dizgeleri değişmedi', () => {
317
+ expect(varyantSinifi(buttonSrc, 'ghost')).toContain('text-gray-700')
318
+ expect(varyantSinifi(buttonSrc, 'success')).toContain('bg-success text-success-foreground')
319
+ expect(varyantSinifi(buttonSrc, 'outline')).toContain('border border-input')
320
+ })
321
+
322
+ it('KAPSAM KİLİDİ: `--error`/`--destructive` DEFAULT token’ının diğer tüketicileri etkilenmedi', () => {
323
+ // Token değişmediği için `bg-error` yazan 35 başka yüzey ve `text-destructive`
324
+ // yazan 94 yüzey bit-bit aynı kalır. Switch bunun canlı kanıtı.
325
+ const switchSrc = fs.readFileSync(path.join(PAKET, 'components/ui/switch.tsx'), 'utf8')
326
+ expect(switchSrc).toContain('data-[state=checked]:bg-error')
327
+ expect(switchSrc).not.toContain('bg-error-600')
328
+ })
329
+ })
330
+
331
+ describe('#544 — render sözleşmesi (<Badge /> / <Button />)', () => {
332
+ const sinif = (jsx: React.ReactElement, sec: string) => {
333
+ const { container } = render(jsx)
334
+ return (container.querySelector(sec) as HTMLElement).className
335
+ }
336
+
337
+ it('Badge destructive ARTIK yarı saydam `dark:bg-error/90` taşımıyor', () => {
338
+ // Yarı saydam dolgu, kontrastı ZEMİNE bağımlı kılıyordu (gerçek kartta 4.43).
339
+ const c = sinif(<Badge variant="destructive">x</Badge>, 'div')
340
+ expect(c).not.toContain('dark:bg-error/90')
341
+ expect(c).toContain('text-white')
342
+ })
343
+
344
+ it('Button destructive dark dolgusu opak bir skala kademesi', () => {
345
+ const c = sinif(<Button variant="destructive">x</Button>, 'button')
346
+ expect(c).not.toContain('dark:bg-destructive ')
347
+ expect(c).toContain('text-destructive-foreground')
348
+ })
349
+
350
+ it('TEK dolgu utility’si per durum — Tailwind sıra belirsizliği YOK', () => {
351
+ // `dark:bg-destructive dark:bg-error-600` ikisi birden yazılırsa hangisinin
352
+ // kazanacağı Tailwind'in İÇ sıralamasına kalır (sınıf dizgesi sırasına DEĞİL).
353
+ for (const [kaynak, ad] of [[badgeSrc, 'badge'], [buttonSrc, 'button']] as const) {
354
+ const siniflar = varyantSinifi(kaynak, 'destructive').split(/\s+/)
355
+ for (const onek of ['', 'dark:', 'hover:', 'dark:hover:', 'active:', 'dark:active:']) {
356
+ const n = siniflar.filter((s) => new RegExp(`^${onek}bg-(?!.*shadow)`).test(s)).length
357
+ expect(`${ad} "${onek}bg-*" adedi: ${n}`).toBe(`${ad} "${onek}bg-*" adedi: ${n > 1 ? 1 : n}`)
358
+ }
359
+ }
360
+ })
361
+ })
362
+
363
+ // ════════════════════════════════════════════════════════════════════════════════
364
+ // #545 — docs canlı demo kodundaki ham renk literalleri
365
+ // ════════════════════════════════════════════════════════════════════════════════
366
+
367
+ const twColors = require('tailwindcss/colors')
368
+ const hx = (s: string): RGB => [
369
+ parseInt(s.slice(1, 3), 16),
370
+ parseInt(s.slice(3, 5), 16),
371
+ parseInt(s.slice(5, 7), 16),
372
+ ]
373
+
374
+ const CONFIGLER = {
375
+ 'click-animations': 'src/docs-config/click-animations.config.tsx',
376
+ 'error-boundary': 'src/docs-config/error-boundary.config.tsx',
377
+ 'pinch-zoom': 'src/docs-config/pinch-zoom.config.tsx',
378
+ } as const
379
+ const oku = (rel: string) => fs.readFileSync(path.join(DEPO, rel), 'utf8')
380
+
381
+ /** Bir sınıfın kaç kez geçtiği — "kaç düğüm" iddiasını SAYIYLA kilitler */
382
+ const sayac = (src: string, sinif: string) =>
383
+ (src.match(new RegExp(`(?<![\\w-])${sinif.replace(/[/]/g, '\\/')}(?![\\w-])`, 'g')) ?? []).length
384
+
385
+ describe('#545 — hesaplayıcı çapası: issue’nun tarayıcı ölçümleri birebir üretiliyor', () => {
386
+ it('Tailwind paleti beklenen sürümden ve beklenen hex’lerde', () => {
387
+ // Palet sürümü kayarsa aşağıdaki tüm oranlar kayar; sessiz-yeşil koruması.
388
+ expect(twColors.yellow[500]).toBe('#eab308')
389
+ expect(twColors.green[600]).toBe('#16a34a')
390
+ expect(twColors.red[600]).toBe('#dc2626')
391
+ expect(twColors.red[50]).toBe('#fef2f2')
392
+ })
393
+
394
+ it('altı ripple butonu: issue’daki 1.91-3.95 aralığı yeniden üretiliyor (zemin: opak dolgu ↔ #ffffff metin)', () => {
395
+ const beklenen: Record<string, number> = {
396
+ yellow: 1.92, green: 2.28, cyan: 2.43, blue: 3.68, red: 3.76, purple: 3.96,
397
+ }
398
+ for (const [aile, oran] of Object.entries(beklenen)) {
399
+ expect(kontrast(hx(twColors[aile][500]), BEYAZ)).toBeCloseTo(oran, 1)
400
+ }
401
+ })
402
+
403
+ it('ZEMİN DERSİ: `text-red-600` BEYAZA karşı geçer, GERÇEK zemininde (bg-red-50) KALIR', () => {
404
+ // Issue'nun kaydettiği ölçüm hatası tam buydu; burası onu kalıcı kılar.
405
+ expect(kontrast(hx(twColors.red[600]), BEYAZ)).toBeCloseTo(4.83, 2)
406
+ expect(kontrast(hx(twColors.red[600]), hx(twColors.red[50]))).toBeCloseTo(4.41, 2)
407
+ expect(kontrast(hx(twColors.red[600]), hx(twColors.red[50]))).toBeLessThan(AA_METIN)
408
+ })
409
+
410
+ it('`text-green-600` beyaz zeminde 3.29 (issue’nun hiç ölçmediği kalem)', () => {
411
+ expect(kontrast(hx(twColors.green[600]), BEYAZ)).toBeCloseTo(3.30, 1)
412
+ })
413
+
414
+ it('semantik skala sınıfları ÖLÜ DEĞİL — preset onları açıyor ve hex’ler ham palete eşit', () => {
415
+ // #307/#529 ölü-sınıf tuzağı: `text-success-700` CSS üretmiyorsa düzeltme
416
+ // sessizce hiçbir şey yapmaz. Token tanımı + ham palet eşitliği ikisi de kanıt.
417
+ const preset = fs.readFileSync(path.resolve(PAKET, '../tailwind-preset.js'), 'utf8')
418
+ for (const [aile, ham] of [['error', 'red'], ['success', 'green'], ['warning', 'yellow']] as const) {
419
+ for (const k of [400, 700]) {
420
+ expect(preset).toMatch(new RegExp(`\\b${aile}:\\s*\\{[^}]*\\b${k}:\\s*"rgb\\(var\\(--${aile}-${k}\\)`, 's'))
421
+ expect(hex(skalaRgb(`${aile}-${k}`))).toBe(twColors[ham][k])
422
+ }
423
+ }
424
+ })
425
+ })
426
+
427
+ describe('#545 — KABUL KRİTERİ: kalan ham literal SAYISI sıfır', () => {
428
+ it('click-animations: altı `bg-{aile}-500` ripple butonu KALMADI (bugün 6)', () => {
429
+ const src = oku(CONFIGLER['click-animations'])
430
+ const kalan = ['blue', 'purple', 'green', 'red', 'yellow', 'cyan']
431
+ .map((a) => [a, sayac(src, `bg-${a}-500`)] as const)
432
+ .filter(([, n]) => n > 0)
433
+ expect(kalan).toEqual([])
434
+ })
435
+
436
+ it('error-boundary: METİN rolündeki `-600` tonları KALMADI (ikonlar bilerek DURUYOR)', () => {
437
+ /**
438
+ * ⚠️ ÖLÇÜME DAYALI KAPSAM — "hepsini dönüştür" YANLIŞ olurdu.
439
+ * `text-red-600`/`text-green-600`ün ikon rolündeki örnekleri 3.0 (WCAG 1.4.11,
440
+ * metin-dışı) eşiğini İKİ TEMADA da geçiyor ve dark ikizleri YOK:
441
+ * AlertTriangle text-red-600 → light[bg-red-50] 4.41 ✓ · dark[red-950/20] 3.70 ✓
442
+ * CheckCircle text-green-600 → light[beyaz] 3.30 ✓ · dark[kart] 5.38 ✓
443
+ * Bunları -700'e çekmek dark'ı 2.76'ya düşürüp EŞİĞİN ALTINA indirirdi — yani
444
+ * bir ihlali başkasıyla takas ederdi. Metin rolündekiler (4.5 eşiği) dönüştü.
445
+ */
446
+ const src = oku(CONFIGLER['error-boundary'])
447
+ expect(sayac(src, 'text-yellow-600')).toBe(0) // hem ikon hem metin ihlaldi (2.84)
448
+ // Kalan `-600`lar YALNIZ ikon üzerinde olmalı
449
+ const kalanKirmizi = [...src.matchAll(/text-red-600/g)].map((m) =>
450
+ src.slice(Math.max(0, m.index! - 90), m.index!)
451
+ )
452
+ expect(kalanKirmizi).toHaveLength(2) // :109 canlı + :475 code ikizi
453
+ for (const once of kalanKirmizi) expect(once).toContain('<AlertTriangle')
454
+ const kalanYesil = [...src.matchAll(/text-green-600/g)].map((m) =>
455
+ src.slice(Math.max(0, m.index! - 90), m.index!)
456
+ )
457
+ expect(kalanYesil).toHaveLength(1) // :158 ikon
458
+ expect(kalanYesil[0]).toContain('<CheckCircle')
459
+ })
460
+
461
+ it('pinch-zoom: `text-green-600` KALMADI (bugün 2 — biri canlı JSX, biri code bloğu)', () => {
462
+ expect(sayac(oku(CONFIGLER['pinch-zoom']), 'text-green-600')).toBe(0)
463
+ })
464
+
465
+ it('moonui-pro error-boundary: dark ikizi OLMAYAN `text-red-600` KALMADI (latent kalem)', () => {
466
+ const src = fs.readFileSync(
467
+ path.join(DEPO, 'packages/moonui-pro/src/components/error-boundary/index.tsx'),
468
+ 'utf8'
469
+ )
470
+ expect(src).not.toMatch(/className="p-4 text-center text-red-600"/)
471
+ })
472
+ })
473
+
474
+ describe('#545 — KABUL KRİTERİ: yeni tonlar İKİ TEMADA da eşiği geçiyor', () => {
475
+ it('altı ripple butonu ≥4.5 (zemin: opak dolgu ↔ #ffffff metin, tema-bağımsız)', () => {
476
+ const src = oku(CONFIGLER['click-animations'])
477
+ for (const aile of ['blue', 'purple', 'green', 'red', 'yellow', 'cyan']) {
478
+ const m = src.match(new RegExp(`bg-${aile}-(\\d{3})`))
479
+ expect(`${aile}: sınıf var`).toBe(m ? `${aile}: sınıf var` : `${aile}: sınıf YOK`)
480
+ const oran = kontrast(hx(twColors[aile][Number(m![1])]), BEYAZ)
481
+ expect(`${aile}-${m![1]}: ${oran >= AA_METIN ? 'geçer' : oran.toFixed(2)}`).toBe(
482
+ `${aile}-${m![1]}: geçer`
483
+ )
484
+ }
485
+ })
486
+
487
+ it('DARK TUZAĞI: ton yükseltilen her metin sınıfının `dark:` ikizi VAR', () => {
488
+ // Ölçüldü: success-700 koyu kartta 3.54, warning-700 3.60 → ikiz olmadan
489
+ // LIGHT düzelirken DARK kırılır. Bu test o takası imkânsız kılar.
490
+ const hedefler: Array<[string, string]> = [
491
+ [CONFIGLER['error-boundary'], 'text-success-700'],
492
+ [CONFIGLER['error-boundary'], 'text-warning-700'],
493
+ [CONFIGLER['pinch-zoom'], 'text-success-700'],
494
+ ]
495
+ for (const [rel, sinif] of hedefler) {
496
+ const src = oku(rel)
497
+ const n = sayac(src, sinif)
498
+ expect(`${rel} ${sinif} adedi > 0`).toBe(`${rel} ${sinif} adedi > ${n > 0 ? 0 : 'YOK'}`)
499
+ // her geçtiği yerde hemen ardından dark ikizi gelmeli
500
+ const ikizsiz = [...src.matchAll(new RegExp(`${sinif}(?![\\w-])(\\s+dark:text-\\S+)?`, 'g'))]
501
+ .filter((m) => !m[1])
502
+ expect(`${rel} ${sinif} ikizsiz: ${ikizsiz.length}`).toBe(`${rel} ${sinif} ikizsiz: 0`)
503
+ }
504
+ })
505
+
506
+ it('yeni tonların ölçülen oranları — zemin ADIYLA', () => {
507
+ const olcumler: Array<[string, RGB, RGB, number]> = [
508
+ ['error-700 · zemin bg-red-50 #fef2f2', hx(twColors.red[700]), hx(twColors.red[50]), AA_METIN],
509
+ ['success-700 · zemin kart #ffffff', hx(twColors.green[700]), BEYAZ, AA_METIN],
510
+ ['success-400 · zemin koyu kart #111827', hx(twColors.green[400]), KART_GERCEK_DARK, AA_METIN],
511
+ ['warning-700 · zemin bg-yellow-50 #fefce8', hx(twColors.yellow[700]), hx(twColors.yellow[50]), AA_METIN],
512
+ ['warning-400 · zemin koyu kart #111827', hx(twColors.yellow[400]), KART_GERCEK_DARK, AA_METIN],
513
+ ['error-400 · zemin koyu kart #111827', hx(twColors.red[400]), KART_GERCEK_DARK, AA_METIN],
514
+ ]
515
+ for (const [ad, renk, zemin, esik] of olcumler) {
516
+ const o = kontrast(renk, zemin)
517
+ expect(`${ad}: ${o >= esik ? 'geçer' : o.toFixed(2)}`).toBe(`${ad}: geçer`)
518
+ }
519
+ })
520
+ })
521
+
522
+ describe('#545 — `code:` ↔ canlı `component:` TUTARLILIĞI', () => {
523
+ /** Satırın code: şablon-literali içinde mi olduğunu backtick tekliğinden çöz */
524
+ const codeBlogundaMi = (src: string, idx: number) =>
525
+ ((src.slice(0, idx).match(/`/g) ?? []).length) % 2 === 1
526
+
527
+ it('error-boundary: düzeltilen sınıf HEM canlı JSX’te HEM code bloğunda var', () => {
528
+ const src = oku(CONFIGLER['error-boundary'])
529
+ for (const sinif of ['text-error-700', 'text-warning-700']) {
530
+ const yerler = [...src.matchAll(new RegExp(`${sinif}(?![\\w-])`, 'g'))].map((m) => m.index!)
531
+ const canli = yerler.filter((i) => !codeBlogundaMi(src, i)).length
532
+ const kod = yerler.filter((i) => codeBlogundaMi(src, i)).length
533
+ expect(`${sinif} canlı:${canli > 0} kod:${kod > 0}`).toBe(`${sinif} canlı:true kod:true`)
534
+ }
535
+ })
536
+
537
+ it('pinch-zoom: iki `In Stock` (canlı + code) AYNI sınıfı taşıyor', () => {
538
+ const src = oku(CONFIGLER['pinch-zoom'])
539
+ const yerler = [...src.matchAll(/<span className="([^"]*)">In Stock<\/span>/g)].map((m) => m[1])
540
+ expect(yerler).toHaveLength(2)
541
+ expect(new Set(yerler).size).toBe(1)
542
+ expect(yerler[0]).toContain('text-success-700')
543
+ })
544
+ })
545
+
546
+ describe('#545 — REGRESYON BEKÇİSİ: kapsam dışına taşma yok', () => {
547
+ it('geçen tonlara DOKUNULMADI (ölçülen: eşiğin üstündeler)', () => {
548
+ const src = oku(CONFIGLER['error-boundary'])
549
+ // -800 başlıklar (#991b1b, bg-red-50 üstünde 7.6) korunur
550
+ expect(sayac(src, 'text-red-800')).toBeGreaterThan(0)
551
+ // ikon rolündeki `text-red-600` (bg-red-50 üstünde 4.41 ≥ 3.0) metin eşiğine
552
+ // tabi DEĞİL; dönüştürmemek bilinçli bir karardır
553
+ expect(kontrast(hx(twColors.red[600]), hx(twColors.red[50]))).toBeGreaterThanOrEqual(AA_METIN_DISI)
554
+ })
555
+
556
+ it('vokabüler dönüşümü PİKSEL-BEDAVA: `error-300` = ham `red-300`', () => {
557
+ // `dark:text-red-300` → `dark:text-error-300` görünümü DEĞİŞTİRMEZ; yalnız
558
+ // ham paletten semantik skalaya geçer. Eşit olmasalardı sessiz bir renk
559
+ // kayması olurdu — bu yüzden ölçülür, varsayılmaz.
560
+ expect(hex(skalaRgb('error-300'))).toBe(twColors.red[300])
561
+ expect(sayac(oku(CONFIGLER['error-boundary']), 'dark:text-error-300')).toBeGreaterThan(0)
562
+ })
563
+
564
+ it('moonui-pro: dark ikizi OLAN ikon (`:256`) bilerek DOKUNULMADI', () => {
565
+ // light[bg-red-100] 3.95 ✓ · dark[red-900/20] 6.06 ✓ — ikon eşiği 3.0.
566
+ const src = fs.readFileSync(
567
+ path.join(DEPO, 'packages/moonui-pro/src/components/error-boundary/index.tsx'),
568
+ 'utf8'
569
+ )
570
+ expect(src).toContain('text-red-600 dark:text-red-400')
571
+ })
572
+
573
+ it('RATCHET: kalan docs sayfalarında ham `bg-{aile}-500 text-white` deseni ARTMADI', () => {
574
+ /**
575
+ * ⚠️ ÖLÇÜLEN GERÇEK (2026-08-21) — issue #545'in kapsamından GENİŞ:
576
+ * bu desen 8 dosyada 37 kez geçiyor, yalnız click-animations'ta 6.
577
+ * #545 üç sayfayla sınırlı olduğu için diğer 7 dosya BİLEREK dokunulmadan
578
+ * bırakıldı; burası onları DONDURUR (yeni ihlal eklenirse kırmızı olur)
579
+ * ve click-animations'ın sıfırlandığını ayrıca kilitler.
580
+ */
581
+ const TABAN: Record<string, number> = {
582
+ 'bounce-effect.config.tsx': 16,
583
+ 'button.config.tsx': 1,
584
+ 'confetti.config.tsx': 1,
585
+ 'focus-transitions.config.tsx': 6,
586
+ 'glow-card.config.tsx': 1,
587
+ 'slider.config.tsx': 4,
588
+ 'sparkles.config.tsx': 2,
589
+ // click-animations.config.tsx: 6 → 0 (bu issue'nun kapsamı)
590
+ }
591
+ const dizin = path.join(DEPO, 'src/docs-config')
592
+ const bugun: Record<string, number> = {}
593
+ for (const f of fs.readdirSync(dizin).filter((x) => x.endsWith('.config.tsx'))) {
594
+ const src = fs.readFileSync(path.join(dizin, f), 'utf8')
595
+ const n = (src.match(/bg-(?:blue|purple|green|red|yellow|cyan|orange|pink|indigo)-500 text-white/g) ?? [])
596
+ .length
597
+ if (n > 0) bugun[f] = n
598
+ }
599
+ expect(bugun).toEqual(TABAN)
600
+ })
601
+ })
@@ -41,9 +41,13 @@ describe('Badge Component', () => {
41
41
  })
42
42
 
43
43
  it('renders destructive variant correctly', () => {
44
+ // #544: dolgu `bg-error` (--error = #ef4343, beyaz metinle 3.78 ✗) yerine
45
+ // `error` skalasının -600 kademesine (#dc2626, 4.83 ✓) bağlandı. Token
46
+ // DEĞİŞMEDİ; yalnız bu varyantın dolgusu AA'yı geçen en açık kademeye indi.
47
+ // Kontrast değişmezleri: packages/moonui/src/__tests__/issue-544-kontrast.test.tsx
44
48
  render(<Badge variant="destructive">Destructive</Badge>)
45
49
  const badge = screen.getByText('Destructive').parentElement!
46
- expect(badge).toHaveClass('bg-error')
50
+ expect(badge).toHaveClass('bg-error-600')
47
51
  expect(badge).toHaveClass('text-white')
48
52
  })
49
53
 
@@ -419,12 +419,36 @@ describe('#454 — kaynak sözleşmesi (switch.tsx)', () => {
419
419
  expect(kaynak).toContain('export { Switch, switchVariants }')
420
420
  })
421
421
 
422
- it('`bg-secondary-500` / `bg-warning-700` YAZILMADI — preset o sınıfları üretmiyor (#307 ölü-sınıf tuzağı)', () => {
422
+ it('`bg-secondary-500` YAZILMADI — preset secondary skalasını hâlâ açmıyor (#307 ölü-sınıf tuzağı)', () => {
423
423
  const preset = fs.readFileSync(path.resolve(KOK, '../tailwind-preset.js'), 'utf8')
424
424
  expect(preset).not.toMatch(/"?secondary"?:\s*\{[^}]*\b500:/s)
425
- expect(preset).not.toMatch(/\bwarning:\s*\{[^}]*\b700:/s)
426
425
  expect(TRACK_CVA).not.toContain('bg-secondary-500')
426
+ })
427
+
428
+ /**
429
+ * #524 GÜNCELLEMESİ — bu iddianın YARISI artık geçerli değil.
430
+ *
431
+ * Bu test eskiden `bg-warning-700`ün de "ölü sınıf" olduğunu kilitliyordu:
432
+ * preset durum ailelerine sayısal skala AÇMIYORDU, o yüzden switch/slider/progress
433
+ * üçü de bağımsız olarak aynı geçici çözümü (`bg-[rgb(var(--warning-700))]`
434
+ * arbitrary-value formu) icat etmişti.
435
+ *
436
+ * #524'te preset `success`/`warning`/`error` ailelerine 50-950 skalasını bağladı
437
+ * (dört Pro component'in ham `text-green-500` literaline düşmesinin kök nedeni
438
+ * buydu). Yani `bg-warning-700` ARTIK GERÇEK bir sınıf — "ölü sınıf" gerekçesi
439
+ * düştü.
440
+ *
441
+ * `switch.tsx` BİLEREK dokunulmadan bırakıldı: arbitrary-value formu birebir aynı
442
+ * rengi üretir (`rgb(var(--warning-700))`), dolayısıyla render sözleşmesi
443
+ * değişmez ve #524'ün diff'i ilgisiz component'lere yayılmaz. Sadeleştirme
444
+ * (switch + slider + progress → `bg-warning-700`) ayrı bir iş olarak raporlandı.
445
+ */
446
+ it('#524 sonrası: preset `warning-700`ü AÇIYOR, switch.tsx arbitrary-value formunu KORUYOR', () => {
447
+ const preset = fs.readFileSync(path.resolve(KOK, '../tailwind-preset.js'), 'utf8')
448
+ expect(preset).toMatch(/\bwarning:\s*\{[^}]*\b700:\s*"rgb\(var\(--warning-700\)/s)
449
+ // Kaynak sözleşmesi DEĞİŞMEDİ — geçici çözüm yerinde, render aynı rengi basıyor
427
450
  expect(TRACK_CVA).not.toContain('bg-warning-700')
451
+ expect(TRACK_CVA).toContain('bg-[rgb(var(--warning-700))]')
428
452
  })
429
453
  })
430
454