@moontra/moonui 6.28.0 → 6.29.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.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * #715 — button: gereksiz `dark:` tekrarı tüketici override'ını eziyordu (1.28:1)
3
+ * + `destructive`/`primary`/`success` hover/active alfa kompoziti
4
+ * AA altındaydı.
5
+ *
6
+ * ══ Dal 1 — `dark:` tekrarı × twMerge × özgüllük ═══════════════════════════════
7
+ * `cn()` = `twMerge(clsx(...))`. Tüketici `className="text-white"` yazdığında
8
+ * twMerge ÖNEKSİZ `text-primary-foreground`u eler — ama `dark:text-primary-foreground`
9
+ * AYRI bir modifier grubu olduğu için ELENMEZ ve koyu temada özgüllükle kazanır:
10
+ * .dark\:text-primary-foreground:is(.dark *) → (0,2,0)
11
+ * text-white → (0,1,0)
12
+ * Dark `--primary-foreground` = `222.2 47.4% 1.2%` = rgb(2,2,5) → ölçülen 1.28:1.
13
+ *
14
+ * ⚠️ AYRIM (bu testin ASIL işi): "tüm `dark:` satırlarını sil" YANLIŞ olurdu.
15
+ * · `dark:text-primary-foreground` → AYNI token'ın tekrarı, token `.dark` bloğunda
16
+ * zaten farklı değer alıyor (tokens.css:137 ↔ :546) → KALDIRILDI.
17
+ * · `dark:bg-error-600` → FARKLI bir token'a geçiyor (#544) → KORUNDU.
18
+ * · `dark:hover:bg-error-700` → öneksiziyle AYNI değeri taşır ama SIRALAMA
19
+ * GUARD'ıdır (aşağıdaki "ordering" testi bunu kanıtla kilitler) → KORUNDU.
20
+ *
21
+ * ══ Dal 2 — durum-bağımlı dallar ═══════════════════════════════════════════════
22
+ * #544 yalnız RESTING ölçmüştü. Açık temada `/90` ve `/80` zemini BEYAZLA
23
+ * karıştırıp rengi AÇIYOR (metin sabit `--destructive-foreground` #f8fafc):
24
+ * resting bg-destructive #e11313 → 4.66 ✓
25
+ * hover bg-destructive/90 #e42b2b → 4.29 ✗ (A11-V axe: 4.28 serious)
26
+ * active bg-destructive/80 #e74242 → 3.82 ✗ (hiç kimse ölçmemişti)
27
+ * Koyu dalın ZATEN kullandığı çözüm açık dala da uygulandı: alfa yerine opak
28
+ * `error` kademesi → hover -700 (6.18), active -800 (7.94).
29
+ *
30
+ * ══ Dal 3 — aynı kusur `primary` ve `success`te ═════════════════════════════════
31
+ * Dal 2 yalnız `destructive`i onardı; ölçüm genişletilince ikisi daha çıktı:
32
+ * primary hover /90 #2876f5 → 4.01 ✗ | active /80 #4085f6 → 3.40 ✗
33
+ * success hover /90 #2a8f4f → 3.90 ✗ | active /80 #419b62 → 3.30 ✗
34
+ * AYNI kusur, FARKLI çözüm — çünkü skala aileleri farklı:
35
+ * · `--success-NNN` `--success` ile aynı hue → Dal 2'nin reçetesi aynen geçerli:
36
+ * hover `bg-success-800` (6.81), active `bg-success-900` (8.71).
37
+ * · `--primary-NNN` ise SKY paleti (`--primary` BLUE) VE preset onları hiçbir
38
+ * utility'ye bağlamıyor → `bg-primary-700` hiç CSS üretmeyen ölü sınıf (#713).
39
+ * Bu yüzden `primary` koyu tonu `color-mix` ile TOKEN'IN KENDİSİNDEN türetir;
40
+ * tüketici `--primary`yi override edince hover/active kendiliğinden uyar.
41
+ * Koyu tema İKİSİNDE DE zaten geçiyordu → ayrıca ölçülüp regresyon kilidi kondu.
42
+ *
43
+ * ⚠️ RENKLER VARSAYILMAZ: tüm değerler `tokens.css`ten, tüm sınıflar `button.tsx`
44
+ * kaynağından okunur. Test, koda değil ÖLÇÜME dayanır (#544 ile aynı yöntem).
45
+ *
46
+ * ⚠️ jsdom Tailwind kaskadını çözmez → burada `getComputedStyle` ile RENK ölçülmez.
47
+ * Ölçülen şey MEKANİZMA: (a) hangi sınıflar sağ kalıyor (gerçek `cn()`/twMerge ile),
48
+ * (b) sağ kalan sınıfların token değerleriyle WCAG oranı. Gerçek tarayıcı doğrulaması
49
+ * bu dosyanın kapsamı DIŞINDADIR ve raporda ayrıca belirtilir.
50
+ */
51
+ import * as React from 'react'
52
+ import { render } from '@testing-library/react'
53
+ import '@testing-library/jest-dom'
54
+ import * as fs from 'fs'
55
+ import * as path from 'path'
56
+ import { Button } from '../components/ui/button'
57
+
58
+ const PKG = path.resolve(__dirname, '..')
59
+ const DEPO = path.resolve(__dirname, '../../../..')
60
+ const tokens = fs.readFileSync(path.join(PKG, 'styles/tokens.css'), 'utf8')
61
+ const freeSrc = fs.readFileSync(path.join(PKG, 'components/ui/button.tsx'), 'utf8')
62
+ const proSrc = fs.readFileSync(
63
+ path.join(DEPO, 'packages/moonui-pro/src/components/ui/button.tsx'),
64
+ 'utf8'
65
+ )
66
+
67
+ type RGB = [number, number, number]
68
+ type Blok = 'light' | 'dark'
69
+
70
+ // ─── Renk aritmetiği (#544 testiyle birebir aynı yöntem) ────────────────────────
71
+
72
+ function tokenOku(name: string, blok: Blok): string | null {
73
+ const source =
74
+ blok === 'dark' ? tokens.slice(tokens.indexOf('.dark')) : tokens.slice(0, tokens.indexOf('.dark'))
75
+ return source.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1].trim() ?? null
76
+ }
77
+
78
+ function hslToRgb(h: number, s: number, l: number): RGB {
79
+ h /= 360
80
+ s /= 100
81
+ l /= 100
82
+ const f = (n: number) => {
83
+ const k = (n + h * 12) % 12
84
+ const a = s * Math.min(l, 1 - l)
85
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
86
+ }
87
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
88
+ }
89
+
90
+ const hslTokenRgb = (v: string): RGB => {
91
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
92
+ return hslToRgb(h, s, l)
93
+ }
94
+ /** Skala token'ları ("--error-600: 220 38 38") HSL DEĞİL, RGB üçlüsüdür */
95
+ const rgbTokenRgb = (v: string): RGB => {
96
+ const p = v.split(/\s+/).map((x) => parseFloat(x))
97
+ return [p[0], p[1], p[2]]
98
+ }
99
+ const semanticRgb = (name: string, blok: Blok): RGB =>
100
+ hslTokenRgb(tokenOku(name, blok) ?? tokenOku(name, 'light')!)
101
+ /** Skala kademeleri tema-BAĞIMSIZ: `.dark` bloğu onları ezmiyor */
102
+ const scaleRgb = (name: string): RGB => rgbTokenRgb(tokenOku(name, 'light')!)
103
+
104
+ function relativeLuminance([r, g, b]: RGB): number {
105
+ const c = (v: number) => {
106
+ const x = v / 255
107
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
108
+ }
109
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
110
+ }
111
+ function kontrast(a: RGB, b: RGB): number {
112
+ const la = relativeLuminance(a)
113
+ const lb = relativeLuminance(b)
114
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
115
+ return (hi + 0.05) / (lo + 0.05)
116
+ }
117
+ /** Yarı saydam rengi opak zemine bindir — `bg-destructive/90` gibi sınıflar için ŞART */
118
+ const komposit = (ust: RGB, alt: RGB, alfa: number): RGB =>
119
+ ust.map((c, i) => Math.round(c * alfa + alt[i] * (1 - alfa))) as RGB
120
+
121
+ const hex = ([r, g, b]: RGB): string =>
122
+ '#' + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, '0')).join('')
123
+
124
+ // ─── Zeminler — HER ÖLÇÜMDE ADIYLA ANILIR ───────────────────────────────────────
125
+ /** Açık temada butonun ALTINDAKİ yüzey. A11-V'nin axe ölçümünü aldığı zemin. */
126
+ const WHITE: RGB = [255, 255, 255]
127
+ /** Docs kabuğunda ölçülen GERÇEK koyu kart (#284/#368/#448/#454 konvansiyonu) */
128
+ const CARD_ACTUAL_DARK: RGB = [17, 24, 39]
129
+
130
+ const AA_TEXT = 4.5
131
+
132
+ // ─── Varyant sınıflarını KAYNAKTAN çöz ─────────────────────────────────────────
133
+
134
+ /** cva `variant` bloğundaki dizi biçimli girdiyi birleşik sınıf dizgesi olarak ver */
135
+ function variantClass(source: string, variantVal: string): string {
136
+ const m = source.match(new RegExp(`\\n\\s*${variantVal}:\\s*\\[([\\s\\S]*?)\\n\\s*\\]`))
137
+ if (!m) throw new Error(`${variantVal} varyantı bulunamadı`)
138
+ return (m[1].match(/"([^"]*)"/g) ?? []).map((s) => s.slice(1, -1)).join(' ')
139
+ }
140
+ const variantClasses = (source: string, v: string): string[] =>
141
+ variantClass(source, v).split(/\s+/).filter(Boolean)
142
+
143
+ const VARYANTLAR = [
144
+ 'primary', 'secondary', 'outline', 'ghost', 'destructive',
145
+ 'success', 'link', 'gradient', 'glow', 'soft', 'glass', 'neon',
146
+ ] as const
147
+
148
+ const BLACK: RGB = [0, 0, 0]
149
+
150
+ /**
151
+ * Dal 3 — `bg-[color:color-mix(in_srgb,hsl(var(--primary))_85%,black)]`.
152
+ *
153
+ * `color-mix(in srgb, …)` gama-KODLU sRGB koordinatlarında interpolasyon yapar
154
+ * (CSS Color 4) → `komposit()`in kanal-bazlı lerp'i ile BİREBİR aynı aritmetik.
155
+ * Alfadan farkı: ikinci renk SAYFA ZEMİNİ değil siyah, yani sonuç opak ve
156
+ * kontrast artık altındaki yüzeye BAĞIMLI DEĞİL. Kusurun kökü buydu.
157
+ */
158
+ const COLOR_MIX_RE =
159
+ /^bg-\[color:color-mix\(in_srgb,hsl\(var\(--([a-z-]+)\)\)_(\d{1,3})%,black\)\]$/
160
+
161
+ /** `bg-error-700` / `bg-destructive` / `bg-destructive/90` / `bg-[color:color-mix(…)]` → RGB */
162
+ function classColor(cls: string, blok: Blok, background: RGB): RGB {
163
+ const g = cls.replace(/^(?:(?:dark|hover|active|focus-visible|group-hover):)+/, '')
164
+ const mix = g.match(COLOR_MIX_RE)
165
+ if (mix) return komposit(semanticRgb(mix[1], blok), BLACK, Number(mix[2]) / 100)
166
+ const m = g.match(/^(?:bg|text|border|ring)-([a-z-]+?)(?:-(\d{2,3}))?(?:\/(\d{1,3}))?$/)
167
+ if (!m) throw new Error(`çözülemeyen sınıf: ${cls}`)
168
+ const [, name, kademe, alfa] = m
169
+ if (name === 'white') return alfa ? komposit(WHITE, background, Number(alfa) / 100) : WHITE
170
+ const base = kademe ? scaleRgb(`${name}-${kademe}`) : semanticRgb(name, blok)
171
+ return alfa ? komposit(base, background, Number(alfa) / 100) : base
172
+ }
173
+
174
+ /**
175
+ * Bir varyantın ilgili tema+durumdaki ETKİN dolgusu.
176
+ * `dark:` önekli sınıf koyu temada kazanır (Tailwind `dark:`i `hover:`ten SONRA
177
+ * yazar; ayrıca `dark:hover:` (0,3,0) `dark:` (0,2,0)'ı yener).
178
+ */
179
+ function fill(source: string, variantVal: string, blok: Blok, background: RGB, onek = ''): RGB {
180
+ const adaylar = variantClasses(source, variantVal).filter((c) =>
181
+ new RegExp(`^${blok === 'dark' ? '(?:dark:)?' : '(?!dark:)'}${onek}bg-`).test(c)
182
+ )
183
+ const secili =
184
+ blok === 'dark'
185
+ ? adaylar.find((c) => c.startsWith(`dark:${onek}bg-`)) ?? adaylar[0]
186
+ : adaylar[0]
187
+ if (!secili) throw new Error(`${variantVal}/${blok}/"${onek}" için dolgu sınıfı yok`)
188
+ return classColor(secili, blok, background)
189
+ }
190
+
191
+ /** Varyantın ilgili temadaki ETKİN metin rengi */
192
+ function textColor(source: string, variantVal: string, blok: Blok, background: RGB): RGB {
193
+ const adaylar = variantClasses(source, variantVal).filter((c) =>
194
+ /^(?:dark:)?text-/.test(c) && !/^(?:dark:)?(?:hover|active|focus-visible):/.test(c)
195
+ )
196
+ const secili =
197
+ blok === 'dark' ? adaylar.find((c) => c.startsWith('dark:')) ?? adaylar[0] : adaylar.find((c) => !c.startsWith('dark:'))
198
+ if (!secili) throw new Error(`${variantVal}/${blok} için metin sınıfı yok`)
199
+ return classColor(secili, blok, background)
200
+ }
201
+
202
+ const cls = (jsx: React.ReactElement) => {
203
+ const { container } = render(jsx)
204
+ return (container.querySelector('button') as HTMLElement).className
205
+ }
206
+
207
+ const PAKETLER = [
208
+ ['free', freeSrc],
209
+ ['pro', proSrc],
210
+ ] as const
211
+
212
+ // ════════════════════════════════════════════════════════════════════════════════
213
+ // Dal 1 — gereksiz `dark:` tekrarı tüketici override'ını eziyordu
214
+ // ════════════════════════════════════════════════════════════════════════════════
215
+
216
+ describe('#715 Dal 1 — `primary` artık hiçbir `dark:` sınıfı taşımıyor', () => {
217
+ it.each(PAKETLER)('%s: `primary` dizisinde `dark:` YOK (token zaten tema-duyarlı)', (_ad, src) => {
218
+ const dark = variantClasses(src, 'primary').filter((c) => c.startsWith('dark:'))
219
+ expect(dark).toEqual([])
220
+ })
221
+
222
+ it.each(PAKETLER)('%s: öneksiz çift KORUNDU — tema davranışı değişmedi', (_ad, src) => {
223
+ expect(variantClass(src, 'primary')).toContain('bg-primary text-primary-foreground')
224
+ // Dal 3 hover/active dallarını `/90`-`/80` alfasından `color-mix`e çevirdi;
225
+ // ikisi de HÂLÂ öneksiz (tek sınıf, iki tema) — Dal 1'in kazanımı bozulmadı.
226
+ expect(variantClasses(src, 'primary')).toEqual(
227
+ expect.arrayContaining([
228
+ 'hover:bg-[color:color-mix(in_srgb,hsl(var(--primary))_85%,black)]',
229
+ 'active:bg-[color:color-mix(in_srgb,hsl(var(--primary))_75%,black)]',
230
+ ])
231
+ )
232
+ })
233
+
234
+ it.each(PAKETLER)('%s: kaynakta `dark:text-primary-foreground` hiç kalmadı', (_ad, src) => {
235
+ // Yorum satırları issue'yu anlatmak için sınıf adını LİTERAL içeriyor →
236
+ // yorumsuz gövde üzerinden bak (yoksa test kendi belgesine takılır).
237
+ const yorumsuz = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
238
+ expect(yorumsuz).not.toContain('dark:text-primary-foreground')
239
+ expect(yorumsuz).not.toContain('dark:bg-primary ')
240
+ })
241
+
242
+ it('render: `primary` çıktısı `dark:text-primary-foreground` İÇERMEZ', () => {
243
+ const c = cls(<Button variant="primary">x</Button>)
244
+ expect(c).toContain('text-primary-foreground')
245
+ expect(c).not.toContain('dark:text-primary-foreground')
246
+ })
247
+ })
248
+
249
+ describe('#715 Dal 1 — tüketici override\'ı artık EZİLMİYOR', () => {
250
+ it('`className="text-white"` sağ kalıyor ve onu yenecek `dark:text-*` yok', () => {
251
+ const c = cls(<Button className="text-white">x</Button>)
252
+ expect(c.split(/\s+/)).toContain('text-white')
253
+ // Kusurun imzası: twMerge öneksizi eler, `dark:` ikizi kalır ve (0,2,0) ile
254
+ // tüketicinin (0,1,0) sınıfını yener. Hiç `dark:text-*` kalmadıysa mümkün değil.
255
+ expect(c.split(/\s+/).filter((x) => x.startsWith('dark:text-'))).toEqual([])
256
+ })
257
+
258
+ it('A13-V\'nin ölçtüğü GERÇEK docs vakası (click-animations gradyan butonu)', () => {
259
+ // src/docs-config/click-animations.config.tsx:363 — birebir aynı className
260
+ const c = cls(
261
+ <Button
262
+ size="lg"
263
+ className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white hover:from-indigo-700 hover:to-purple-700"
264
+ >
265
+ Ripple Effect
266
+ </Button>
267
+ )
268
+ const parcalar = c.split(/\s+/)
269
+ expect(parcalar).toContain('text-white')
270
+ expect(parcalar).toContain('bg-gradient-to-r')
271
+ expect(parcalar.filter((x) => x.startsWith('dark:text-'))).toEqual([])
272
+ // ⚠️ MEKANİZMANIN TAMAMI: twMerge `bg-gradient-to-r` ile `bg-primary`yi AYNI
273
+ // grupta saymaz → `bg-primary` sağ kalır (gradyan onu boyar, sorun değil).
274
+ // `text-white` ise `text-primary-foreground`u ELER. Geriye yalnız `dark:` ikizi
275
+ // kalıyordu ve koyu temada (0,2,0) > (0,1,0) ile kazanıyordu.
276
+ expect(parcalar).toContain('bg-primary')
277
+ expect(parcalar).not.toContain('text-primary-foreground')
278
+
279
+ // Kusurlu metin rengi = dark `--primary-foreground` (kaynaktan okundu).
280
+ const kusurluMetin = semanticRgb('primary-foreground', 'dark')
281
+ expect(hex(kusurluMetin)).toBe('#020205') // "neredeyse siyah" iddiasının kilidi
282
+ // Gradyanın İKİ ucunda da ölçüldü — tüketicinin istediği beyaz geçiyor,
283
+ // component'in dayattığı near-black KALIYOR:
284
+ const INDIGO_600: RGB = [79, 70, 229]
285
+ const PURPLE_600: RGB = [147, 51, 234]
286
+ for (const zemin of [INDIGO_600, PURPLE_600]) {
287
+ const beyaz = kontrast([255, 255, 255], zemin)
288
+ const kusurlu = kontrast(kusurluMetin, zemin)
289
+ expect(`${hex(zemin)} beyaz:${beyaz >= AA_TEXT ? 'geçer' : beyaz.toFixed(2)}`).toBe(
290
+ `${hex(zemin)} beyaz:geçer`
291
+ )
292
+ expect(kusurlu).toBeLessThan(AA_TEXT)
293
+ }
294
+ })
295
+ })
296
+
297
+ describe('#715 Dal 1 — GENEL dedektör: aynı değerin `dark:` tekrarı kalmadı', () => {
298
+ /**
299
+ * Kural: `dark:X` yalnızca (a) öneksiz ikizinden FARKLI bir değere işaret ediyorsa,
300
+ * ya da (b) SIRALAMA GUARD'ı ise meşrudur.
301
+ *
302
+ * (b) şu durumda doğar: varyant, aynı CSS özelliği için FARKLI değerli bir
303
+ * `dark:` kuralı da bildiriyorsa (ör. `dark:bg-error-600`). Tailwind `dark:`i
304
+ * `hover:`ten SONRA yazdığı ve ikisi de (0,2,0) olduğu için, öneksiz
305
+ * `hover:bg-error-700` koyu temada `dark:bg-error-600`a KAYBEDER; onu yenmek
306
+ * (0,3,0) özgüllüğündeki `dark:hover:` ikizini ZORUNLU kılar.
307
+ */
308
+ const ozellik = (c: string) => c.replace(/^(?:[a-z-]+:)+/, '').match(/^(bg|text|border|ring)-/)?.[1]
309
+
310
+ it.each(PAKETLER)('%s: her `dark:` sınıfının ya farklı değeri ya guard görevi var', (_ad, src) => {
311
+ const ihlaller: string[] = []
312
+ for (const v of VARYANTLAR) {
313
+ const liste = variantClasses(src, v)
314
+ // Bu varyant, hangi özellikler için FARKLI değerli bir `dark:` taban kuralı
315
+ // bildiriyor? (guard gerekçesi yalnız o özellikler için geçerli)
316
+ const guardOzellikleri = new Set(
317
+ liste
318
+ .filter((c) => /^dark:(?!hover:|active:|focus-visible:)/.test(c))
319
+ .filter((c) => !liste.includes(c.slice('dark:'.length)))
320
+ .map(ozellik)
321
+ .filter(Boolean) as string[]
322
+ )
323
+ for (const c of liste.filter((x) => x.startsWith('dark:'))) {
324
+ const oneksiz = c.slice('dark:'.length)
325
+ if (!liste.includes(oneksiz)) continue // farklı değer → meşru
326
+ if (guardOzellikleri.has(ozellik(c) as string)) continue // sıralama guard'ı → meşru
327
+ ihlaller.push(`${v}: ${c}`)
328
+ }
329
+ }
330
+ expect(ihlaller).toEqual([])
331
+ })
332
+
333
+ it.each(PAKETLER)('%s: `destructive`in guard ikizleri KALDI (ters yönlü kilit)', (_ad, src) => {
334
+ const liste = variantClasses(src, 'destructive')
335
+ expect(liste).toContain('dark:bg-error-600')
336
+ // `dark:bg-error-600` durdukça bu ikisi kaldırılamaz — kaldırılırsa koyu temada
337
+ // hover/active resting rengine geri düşer.
338
+ expect(liste).toContain('dark:hover:bg-error-700')
339
+ expect(liste).toContain('dark:active:bg-error-800')
340
+ })
341
+ })
342
+
343
+ // ════════════════════════════════════════════════════════════════════════════════
344
+ // Dal 2 — `destructive` durum-bağımlı dalları
345
+ // ════════════════════════════════════════════════════════════════════════════════
346
+
347
+ describe('#715 Dal 2 — `destructive` hover/active kompoziti AA taşıyor', () => {
348
+ it.each(PAKETLER)('%s: KONTROL GRUBU — resting DEĞİŞMEDİ (#442)', (_ad, src) => {
349
+ expect(variantClass(src, 'destructive')).toContain('bg-destructive text-destructive-foreground')
350
+ const zemin = fill(src, 'destructive', 'light', WHITE)
351
+ const oran = kontrast(zemin, textColor(src, 'destructive', 'light', zemin))
352
+ expect(hex(zemin)).toBe('#e11313')
353
+ expect(oran).toBeCloseTo(4.66, 1)
354
+ })
355
+
356
+ it.each(PAKETLER)('%s: LIGHT hover + active ≥ 4.5 (kusurun kendisi)', (_ad, src) => {
357
+ for (const onek of ['hover:', 'active:']) {
358
+ const zemin = fill(src, 'destructive', 'light', WHITE, onek)
359
+ const oran = kontrast(zemin, textColor(src, 'destructive', 'light', zemin))
360
+ expect(`light ${onek}${hex(zemin)}: ${oran >= AA_TEXT ? 'geçer' : oran.toFixed(2)}`).toBe(
361
+ `light ${onek}${hex(zemin)}: geçer`
362
+ )
363
+ }
364
+ })
365
+
366
+ it.each(PAKETLER)('%s: DARK hover + active ≥ 4.5 (regresyon açılmadı)', (_ad, src) => {
367
+ for (const onek of ['hover:', 'active:']) {
368
+ const zemin = fill(src, 'destructive', 'dark', CARD_ACTUAL_DARK, onek)
369
+ const oran = kontrast(zemin, textColor(src, 'destructive', 'dark', zemin))
370
+ expect(`dark ${onek}${hex(zemin)}: ${oran >= AA_TEXT ? 'geçer' : oran.toFixed(2)}`).toBe(
371
+ `dark ${onek}${hex(zemin)}: geçer`
372
+ )
373
+ }
374
+ })
375
+
376
+ it.each(PAKETLER)('%s: alfa harmanı KALMADI — kontrast artık zemine bağımlı değil', (_ad, src) => {
377
+ const alfali = variantClasses(src, 'destructive').filter((c) => /bg-[a-z-]+(?:-\d+)?\/\d+/.test(c))
378
+ expect(alfali).toEqual([])
379
+ expect(variantClasses(src, 'destructive')).toEqual(
380
+ expect.arrayContaining(['hover:bg-error-700', 'active:bg-error-800'])
381
+ )
382
+ })
383
+
384
+ it.each(PAKETLER)('%s: durum ilerlemesi tutarlı KOYULAŞIYOR', (_ad, src) => {
385
+ // Alfa sürümünde active (#e74242) hover'dan (#e42b2b) DAHA AÇIKtı — hem a11y
386
+ // hem etkileşim geri bildirimi bakımından ters.
387
+ const l = (onek: string) => relativeLuminance(fill(src, 'destructive', 'light', WHITE, onek))
388
+ expect(l('')).toBeGreaterThan(l('hover:'))
389
+ expect(l('hover:')).toBeGreaterThan(l('active:'))
390
+ })
391
+
392
+ it('render: `destructive` çıktısı yeni hover sınıfını taşıyor, restingi korumuş', () => {
393
+ const c = cls(<Button variant="destructive">x</Button>).split(/\s+/)
394
+ expect(c).toContain('bg-destructive')
395
+ expect(c).toContain('text-destructive-foreground')
396
+ expect(c).toContain('hover:bg-error-700')
397
+ expect(c).not.toContain('hover:bg-destructive/90')
398
+ expect(c).not.toContain('active:bg-destructive/80')
399
+ })
400
+ })
401
+
402
+ // ════════════════════════════════════════════════════════════════════════════════
403
+ // Dal 3 — `primary` + `success` durum-bağımlı dalları
404
+ //
405
+ // Dal 2 yalnız `destructive`i onarmıştı; AYNI alfa kusuru iki varyantta daha
406
+ // ölçüldü (metin her ikisinde de sabit `…-foreground` = #f8fafc, zemin beyaz):
407
+ // primary hover bg-primary/90 #2876f5 → 4.01 ✗ | active /80 #4085f6 → 3.40 ✗
408
+ // success hover bg-success/90 #2a8f4f → 3.90 ✗ | active /80 #419b62 → 3.30 ✗
409
+ // İki varyant AYNI kusuru taşısa da AYNI çözümü ALMADI, çünkü skala aileleri
410
+ // farklı: `--success-NNN` `--success` ile aynı hue'da (opak kademe yeterli),
411
+ // `--primary-NNN` ise SKY paleti (`--primary` BLUE) ve preset onları hiçbir
412
+ // utility'ye bağlamıyor → `bg-primary-700` ölü sınıf olurdu. `primary` bu yüzden
413
+ // koyu tonu `color-mix` ile TOKEN'IN KENDİSİNDEN türetiyor.
414
+ // ════════════════════════════════════════════════════════════════════════════════
415
+
416
+ describe('#715 Dal 3 — `primary` hover/active kompoziti AA taşıyor', () => {
417
+ it.each(PAKETLER)('%s: KONTROL GRUBU — resting DEĞİŞMEDİ (#442)', (_ad, src) => {
418
+ expect(variantClass(src, 'primary')).toContain('bg-primary text-primary-foreground')
419
+ const zemin = fill(src, 'primary', 'light', WHITE)
420
+ expect(hex(zemin)).toBe('#1067f4')
421
+ expect(kontrast(zemin, textColor(src, 'primary', 'light', zemin))).toBeCloseTo(4.7, 1)
422
+ })
423
+
424
+ it.each(PAKETLER)('%s: LIGHT hover + active ≥ 4.5 (kusurun kendisi)', (_ad, src) => {
425
+ for (const onek of ['hover:', 'active:']) {
426
+ const zemin = fill(src, 'primary', 'light', WHITE, onek)
427
+ const oran = kontrast(zemin, textColor(src, 'primary', 'light', zemin))
428
+ expect(`light ${onek}${hex(zemin)}: ${oran >= AA_TEXT ? 'geçer' : oran.toFixed(2)}`).toBe(
429
+ `light ${onek}${hex(zemin)}: geçer`
430
+ )
431
+ }
432
+ })
433
+
434
+ it.each(PAKETLER)('%s: DARK hover + active ≥ 4.5 (regresyon açılmadı)', (_ad, src) => {
435
+ // Koyu temada `--primary` near-white, `--primary-foreground` near-black —
436
+ // yani koyulaştırma TERS yönde çalışabilirdi. Reddedilen sky-kademesi adayı
437
+ // tam burada 3.49'a çöküyordu; `color-mix` türevi çökmüyor.
438
+ for (const onek of ['hover:', 'active:']) {
439
+ const zemin = fill(src, 'primary', 'dark', CARD_ACTUAL_DARK, onek)
440
+ const oran = kontrast(zemin, textColor(src, 'primary', 'dark', zemin))
441
+ expect(`dark ${onek}${hex(zemin)}: ${oran >= AA_TEXT ? 'geçer' : oran.toFixed(2)}`).toBe(
442
+ `dark ${onek}${hex(zemin)}: geçer`
443
+ )
444
+ }
445
+ })
446
+
447
+ it.each(PAKETLER)('%s: alfa harmanı KALMADI — kontrast zemine bağımlı değil', (_ad, src) => {
448
+ expect(variantClasses(src, 'primary').filter((c) => /bg-[a-z-]+(?:-\d+)?\/\d+/.test(c))).toEqual([])
449
+ })
450
+
451
+ it.each(PAKETLER)('%s: koyu ton `--primary`nin KENDİSİNDEN türetiliyor', (_ad, src) => {
452
+ // Bu testin ASIL işi: sabit bir renge (`bg-blue-700`) kaçışı yasaklamak.
453
+ // Tüketici `--primary`yi override ettiğinde hover/active KENDİLİĞİNDEN uymalı.
454
+ for (const onek of ['hover:', 'active:']) {
455
+ const secili = variantClasses(src, 'primary').find((c) => c.startsWith(`${onek}bg-`))!
456
+ const m = secili.slice(onek.length).match(COLOR_MIX_RE)
457
+ expect(`${onek}${m ? m[1] : 'TÜREV DEĞİL: ' + secili}`).toBe(`${onek}primary`)
458
+ // `[color:…]` etiketi: sınıfın RENK olarak tanınmasını Tailwind'in ve
459
+ // twMerge'ün tip ÇIKARIMINA bırakmıyoruz (ikisi de bugün doğru çıkarım
460
+ // yapıyor — kilit, gelecekte sessizce değişmesine karşı).
461
+ expect(secili).toContain('bg-[color:')
462
+ }
463
+ })
464
+
465
+ it.each(PAKETLER)('%s: durum ilerlemesi İKİ TEMADA da koyulaşıyor', (_ad, src) => {
466
+ for (const [blok, zemin] of [['light', WHITE], ['dark', CARD_ACTUAL_DARK]] as const) {
467
+ const l = (onek: string) => relativeLuminance(fill(src, 'primary', blok, zemin, onek))
468
+ expect(`${blok} resting>hover: ${l('') > l('hover:')}`).toBe(`${blok} resting>hover: true`)
469
+ expect(`${blok} hover>active: ${l('hover:') > l('active:')}`).toBe(`${blok} hover>active: true`)
470
+ }
471
+ })
472
+
473
+ it('render: `primary` çıktısı yeni dalları taşıyor, restingi korumuş', () => {
474
+ const c = cls(<Button variant="primary">x</Button>).split(/\s+/)
475
+ expect(c).toContain('bg-primary')
476
+ expect(c).toContain('text-primary-foreground')
477
+ expect(c).toContain('hover:bg-[color:color-mix(in_srgb,hsl(var(--primary))_85%,black)]')
478
+ expect(c).not.toContain('hover:bg-primary/90')
479
+ expect(c).not.toContain('active:bg-primary/80')
480
+ })
481
+
482
+ it('tüketici override\'ı hâlâ KAZANIYOR — arbitrary değer twMerge grubunu bozmadı', () => {
483
+ // #715'in tamamı "component sınıfı tüketiciyi eziyor" hikâyesi. Arbitrary
484
+ // değer yanlış GRUBA düşerse (ör. bg-image) twMerge onu elemez ve kusur
485
+ // yeni bir kılıkta geri gelir.
486
+ const c = cls(<Button className="hover:bg-red-500 active:bg-red-700">x</Button>).split(/\s+/)
487
+ expect(c).toContain('hover:bg-red-500')
488
+ expect(c).toContain('active:bg-red-700')
489
+ expect(c.filter((x) => x.includes('color-mix'))).toEqual([])
490
+ })
491
+ })
492
+
493
+ describe('#715 Dal 3 — `success` hover/active kompoziti AA taşıyor', () => {
494
+ it.each(PAKETLER)('%s: KONTROL GRUBU — resting DEĞİŞMEDİ (#442)', (_ad, src) => {
495
+ expect(variantClass(src, 'success')).toContain('bg-success text-success-foreground')
496
+ const zemin = fill(src, 'success', 'light', WHITE)
497
+ expect(hex(zemin)).toBe('#12823b')
498
+ expect(kontrast(zemin, textColor(src, 'success', 'light', zemin))).toBeCloseTo(4.69, 1)
499
+ })
500
+
501
+ it.each(PAKETLER)('%s: LIGHT hover + active ≥ 4.5 (kusurun kendisi)', (_ad, src) => {
502
+ for (const onek of ['hover:', 'active:']) {
503
+ const zemin = fill(src, 'success', 'light', WHITE, onek)
504
+ const oran = kontrast(zemin, textColor(src, 'success', 'light', zemin))
505
+ expect(`light ${onek}${hex(zemin)}: ${oran >= AA_TEXT ? 'geçer' : oran.toFixed(2)}`).toBe(
506
+ `light ${onek}${hex(zemin)}: geçer`
507
+ )
508
+ }
509
+ })
510
+
511
+ it.each(PAKETLER)('%s: DARK hover + active ≥ 4.5 (regresyon açılmadı)', (_ad, src) => {
512
+ for (const onek of ['hover:', 'active:']) {
513
+ const zemin = fill(src, 'success', 'dark', CARD_ACTUAL_DARK, onek)
514
+ const oran = kontrast(zemin, textColor(src, 'success', 'dark', zemin))
515
+ expect(`dark ${onek}${hex(zemin)}: ${oran >= AA_TEXT ? 'geçer' : oran.toFixed(2)}`).toBe(
516
+ `dark ${onek}${hex(zemin)}: geçer`
517
+ )
518
+ }
519
+ })
520
+
521
+ it.each(PAKETLER)('%s: alfa harmanı KALMADI, opak kademelere bağlandı', (_ad, src) => {
522
+ expect(variantClasses(src, 'success').filter((c) => /bg-[a-z-]+(?:-\d+)?\/\d+/.test(c))).toEqual([])
523
+ expect(variantClasses(src, 'success')).toEqual(
524
+ expect.arrayContaining(['hover:bg-success-800', 'active:bg-success-900'])
525
+ )
526
+ })
527
+
528
+ it.each(PAKETLER)('%s: -700 SEÇİLMEDİ — restingden ayırt edilemezdi', (_ad, src) => {
529
+ // `bg-success-700` (#15803d) 4.79 ile eşiği geçiyor AMA `bg-success` (#12823b)
530
+ // ile arasında pratikte fark yok; hover geri bildirimi görünmez olurdu.
531
+ // ΔL (bağıl parlaklık) eşiği: gözle seçilebilir bir durum değişimi için
532
+ // resting'in en az %15'i kadar düşüş istiyoruz.
533
+ const resting = relativeLuminance(fill(src, 'success', 'light', WHITE))
534
+ const hover = relativeLuminance(fill(src, 'success', 'light', WHITE, 'hover:'))
535
+ const yediYuz = relativeLuminance(scaleRgb('success-700'))
536
+ expect(`-700 farkı yeterli mi: ${(resting - yediYuz) / resting > 0.15}`).toBe(
537
+ '-700 farkı yeterli mi: false'
538
+ )
539
+ expect(`seçilen fark yeterli mi: ${(resting - hover) / resting > 0.15}`).toBe(
540
+ 'seçilen fark yeterli mi: true'
541
+ )
542
+ })
543
+
544
+ it.each(PAKETLER)('%s: `dark:` guard ikizleri KALDI (ters yönlü kilit)', (_ad, src) => {
545
+ const liste = variantClasses(src, 'success')
546
+ // `dark:bg-success-700` FARKLI değerde bir dark taban kuralı; Tailwind onu
547
+ // `hover:`ten SONRA yazıyor ve ikisi de (0,2,0) → önek atılırsa koyu temada
548
+ // hover resting rengine geri düşer. Bu iki satır o yüzden guard.
549
+ expect(liste).toContain('dark:bg-success-700')
550
+ expect(liste).toContain('dark:hover:bg-success-800')
551
+ expect(liste).toContain('dark:active:bg-success-900')
552
+ })
553
+
554
+ it.each(PAKETLER)('%s: durum ilerlemesi İKİ TEMADA da koyulaşıyor', (_ad, src) => {
555
+ for (const [blok, zemin] of [['light', WHITE], ['dark', CARD_ACTUAL_DARK]] as const) {
556
+ const l = (onek: string) => relativeLuminance(fill(src, 'success', blok, zemin, onek))
557
+ expect(`${blok} resting>hover: ${l('') > l('hover:')}`).toBe(`${blok} resting>hover: true`)
558
+ expect(`${blok} hover>active: ${l('hover:') > l('active:')}`).toBe(`${blok} hover>active: true`)
559
+ }
560
+ })
561
+
562
+ it('render: `success` çıktısı yeni dalları taşıyor, restingi korumuş', () => {
563
+ const c = cls(<Button variant="success">x</Button>).split(/\s+/)
564
+ expect(c).toContain('bg-success')
565
+ expect(c).toContain('hover:bg-success-800')
566
+ expect(c).not.toContain('hover:bg-success/90')
567
+ expect(c).not.toContain('active:bg-success/80')
568
+ })
569
+ })
570
+
571
+ // ════════════════════════════════════════════════════════════════════════════════
572
+ // ÖLÜ SINIF DEDEKTÖRÜ (#713 sınıfı) — Dal 3'ün asıl dersi
573
+ //
574
+ // `bg-primary-700` "çalışıyor gibi" duruyor: `--primary-700` tokens.css'te TANIMLI.
575
+ // Ama preset onu hiçbir utility'ye bağlamadığı için Tailwind o sınıfı HİÇ ÜRETMEZ
576
+ // → stil sessizce kaybolur. Dal 3'te bu tuzağa düşülmedi; aşağıdaki kilit bir
577
+ // sonraki geliştiricinin düşmesini de engeller.
578
+ // ════════════════════════════════════════════════════════════════════════════════
579
+
580
+ describe('#715 — button, preset\'in ÜRETMEDİĞİ hiçbir token sınıfı kullanmıyor', () => {
581
+ // Preset düz CJS; tailwindcss'e bağımlı değil, doğrudan require edilebilir.
582
+ const preset = require(path.join(PKG, '../tailwind-preset.js'))
583
+ const presetColors: Record<string, unknown> = preset.theme.extend.colors
584
+
585
+ it.each(PAKETLER)('%s: kullanılan her kademe preset\'te tanımlı', (_ad, src) => {
586
+ const olu: string[] = []
587
+ for (const v of VARYANTLAR) {
588
+ for (const c of variantClasses(src, v)) {
589
+ const g = c.replace(/^(?:(?:dark|hover|active|focus-visible|group-hover):)+/, '')
590
+ const m = g.match(/^(?:bg|text|border|ring)-([a-z-]+?)-(\d{2,3})(?:\/\d{1,3})?$/)
591
+ if (!m) continue
592
+ const [, aile, kademe] = m
593
+ // Tailwind'in KENDİ paleti (gray/blue/green/purple/pink…) preset'te yok
594
+ // ve her zaman üretilir → yalnız token-destekli aileleri denetle.
595
+ if (!(aile in presetColors)) continue
596
+ if (!(kademe in (presetColors[aile] as Record<string, unknown>))) olu.push(`${v}: ${c}`)
597
+ }
598
+ }
599
+ expect(olu).toEqual([])
600
+ })
601
+
602
+ it('KONTROL: dedektör gerçekten yakalıyor — `bg-primary-700` ölü sınıftır', () => {
603
+ // Reddedilen adayın kilidi. `--primary-700` tokens.css'te VAR…
604
+ expect(tokenOku('primary-700', 'light')).toBe('3 105 161')
605
+ // …ama preset'te YOK → `bg-primary-700` hiç CSS üretmez.
606
+ expect('700' in (presetColors.primary as Record<string, unknown>)).toBe(false)
607
+ // Üstelik yanlış hue: `--primary` blue, `--primary-700` sky.
608
+ expect(hex(semanticRgb('primary', 'light'))).toBe('#1067f4')
609
+ expect(hex(scaleRgb('primary-700'))).toBe('#0369a1')
610
+ })
611
+ })
612
+
613
+ // ════════════════════════════════════════════════════════════════════════════════
614
+ // İkiz pariteği — #643'ün "tek pakete yazma" tuzağına karşı kalıcı kilit
615
+ // ════════════════════════════════════════════════════════════════════════════════
616
+
617
+ describe('#715 — free ↔ pro ikiz pariteği (12 varyantın tamamı)', () => {
618
+ it.each(VARYANTLAR)('`%s` varyantı iki pakette AYNI sınıf kümesi', (v) => {
619
+ const norm = (src: string) => [...new Set(variantClasses(src, v))].sort()
620
+ expect({ varyant: v, free: norm(freeSrc) }).toEqual({ varyant: v, free: norm(proSrc) })
621
+ })
622
+
623
+ it('varyant ADLARI da aynı — birinde olup diğerinde olmayan yok', () => {
624
+ const adlar = (src: string) => {
625
+ const blok = src.slice(src.indexOf('variant: {'))
626
+ return [...blok.matchAll(/^ {8}"?([a-zA-Z-]+)"?:\s*\[/gm)].map((m) => m[1])
627
+ }
628
+ expect(adlar(freeSrc)).toEqual(adlar(proSrc))
629
+ expect(adlar(freeSrc).sort()).toEqual([...VARYANTLAR].sort())
630
+ })
631
+ })
@@ -396,11 +396,21 @@ describe('Select Components', () => {
396
396
  // #623: `text-warning` (DEFAULT token) açık temada beyaz zeminde 2.14:1 ölçüldü —
397
397
  // AA'nın (4.5) ve hatta büyük-metin eşiğinin (3.0) altında. `--warning` aynı zamanda
398
398
  // `bg-warning` yüzeylerinde kullanıldığı için token'ın kendisi DEĞİŞTİRİLMEDİ;
399
- // metin rengi ölçülmüş bir light/dark çiftine taşındı: 4.92 (light) / 10.51 (dark).
399
+ // metin rengi ham kademe ikilisine taşınmıştı: 4.92 (light) / 10.51 (dark).
400
+ //
401
+ // Kalite koşusu 12 (#710 sınıfı): kardeş varyantlar `destructive` ve `success`
402
+ // `-subtle` token ailesine geçirilmişti, `warning` bu göçün DIŞINDA kalmıştı.
403
+ // Artık hizalandı. İki kazanım:
404
+ // 1. Kontrast 4.92 → 8.83 (light) / 10.51 → 14.43 (dark) — tokens.css'te yazılı
405
+ // 2. `dark:` override KALKTI: token zaten tema duyarlı olduğu için gereksizdi,
406
+ // üstelik `dark:` sınıfı tüketicinin kendi rengini eziyordu (#715'in kök nedeni)
407
+ // Aşağıdaki `not.toHaveClass` bilinçli bir regresyon kilidi — ham kademeye
408
+ // ya da `dark:` override'ına geri dönüş bu testi kırar.
400
409
  rerender(<SelectItem value="item1" variant="warning">Item</SelectItem>)
401
410
  item = screen.getByTestId('select-item')
402
- expect(item).toHaveClass('text-warning-700')
403
- expect(item).toHaveClass('dark:text-warning-500')
411
+ expect(item).toHaveClass('text-warning-subtle-foreground')
412
+ expect(item).not.toHaveClass('text-warning-700')
413
+ expect(item).not.toHaveClass('dark:text-warning-500')
404
414
  })
405
415
 
406
416
  it('applies size classes correctly', () => {