@moontra/moonui 6.19.1 → 6.19.2

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,460 @@
1
+ /**
2
+ * #454 — Switch `variant` (checked track) kontrast değişmezleri.
3
+ *
4
+ * BULGU (PR #453 / issue #448'in kapsam-dışı kardeş bulgusu): `secondary` varyantı
5
+ * checked durumda `bg-accent` kullanıyordu — ADI `secondary`, TOKEN'ı `accent`.
6
+ *
7
+ * SWITCH'TE İKİ EKSEN AYNI SAYIYI VERİR, çünkü thumb `bg-background` taşır
8
+ * (`switchThumbVariants` tabanı):
9
+ * EKSEN A — checked track ↔ sayfa/kart zemini: kontrol "açık" okunuyor mu?
10
+ * EKSEN B — thumb ↔ checked track: tutamacın KONUMU görülüyor mu?
11
+ * İkisi de aynı iki rengi karşılaştırır (`--background` ↔ track). Bu yüzden
12
+ * `bg-accent` ile checked switch HEM sayfadan HEM tutamacından ayrışmıyordu:
13
+ * light 1.154 · dark 1.359 · docs kabuğunun gerçek koyu kartı (#111827) 1.197.
14
+ * Switch'in tek işi durumu göstermek olduğu için bu dekoratif bir kontrast
15
+ * eksiği değil İŞLEVSEL bir kusurdur.
16
+ *
17
+ * Eşik 3:1 (WCAG 1.4.11, non-text UI bileşeni). axe yakalamaz: `color-contrast`
18
+ * kuralı yalnız METİN ölçer.
19
+ *
20
+ * İKİ AYRI KÖK NEDEN, İKİ AYRI ÇÖZÜM ŞEKLİ (ikisi de bu dosyada kilitli):
21
+ *
22
+ * 1) `secondary` — `--accent` bir YÜZEY token'ı (`hover:bg-accent` tinti) ve iki
23
+ * temada da kendi zeminine bitişik. Varyant kendi ailesine, `--secondary-*`
24
+ * slate skalasına bağlandı. #448'in slider'da uyguladığı 700/400 `dark:` ayrımı
25
+ * BURADA GEREKMEZ: orada 500 elenmişti çünkü slider'ın `secondary` varyantı
26
+ * zaten o rengi kullanıyordu (ΔE*ab = 0.0 → klon). Switch'in `accent` adlı
27
+ * varyantı YOK → 500 serbest ve iki temayı birden geçen TEK kademe.
28
+ * secondary: "data-[state=checked]:bg-[rgb(var(--secondary-500))]"
29
+ *
30
+ * 2) `warning` — KARDEŞ KUSURU, farklı sınıf (#424/PR #440 progress · #442/PR #446
31
+ * slider ile aynı): `--warning` tokens.css'in `.dark` bloğunda EZİLMİYOR, iki
32
+ * temada aynı turuncu. Dark'ta koyu zemin sayesinde geçiyor (9.446), LIGHT'ta
33
+ * beyaz üstünde 2.133 ✗. Emsalin şekli aynen uygulandı: light'ta -700 kademesi,
34
+ * dark'ta semantik token DEĞİŞMEDEN kalır (dark render bit-bit aynı).
35
+ * warning: "...bg-[rgb(var(--warning-700))] dark:data-[state=checked]:bg-warning"
36
+ *
37
+ * RENKLER VARSAYILMAZ — `switch.tsx`'in O ANKİ CVA dizgelerinden çözülür
38
+ * (PR #440/#446/#453 dersi: sabit varsayım boş-yeşil üretir).
39
+ *
40
+ * Pro ikizi: packages/moonui-pro/src/components/ui/__tests__/switch-variant-contrast.test.tsx
41
+ */
42
+ import * as React from 'react'
43
+ import { render } from '@testing-library/react'
44
+ import '@testing-library/jest-dom'
45
+ import * as fs from 'fs'
46
+ import * as path from 'path'
47
+ import { Switch } from '../switch'
48
+
49
+ const KOK = path.resolve(__dirname, '../../..')
50
+ const tokens = fs.readFileSync(path.join(KOK, 'styles/tokens.css'), 'utf8')
51
+
52
+ type RGB = [number, number, number]
53
+ type Blok = 'light' | 'dark'
54
+
55
+ /** "H S% L%" ya da "R G B" biçimindeki ham token değeri (light = `.moonui-root`, dark = `.dark`) */
56
+ function tokenOku(ad: string, blok: Blok): string | null {
57
+ const kaynak =
58
+ blok === 'dark'
59
+ ? tokens.slice(tokens.indexOf('.dark'))
60
+ : tokens.slice(0, tokens.indexOf('.dark'))
61
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
62
+ }
63
+
64
+ function hslToRgb(h: number, s: number, l: number): RGB {
65
+ h /= 360
66
+ s /= 100
67
+ l /= 100
68
+ const f = (n: number) => {
69
+ const k = (n + h * 12) % 12
70
+ const a = s * Math.min(l, 1 - l)
71
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
72
+ }
73
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
74
+ }
75
+
76
+ const hslTokenRgb = (v: string): RGB => {
77
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
78
+ return hslToRgb(h, s, l)
79
+ }
80
+
81
+ /** Skala token'ları ("--secondary-500: 100 116 139") HSL DEĞİL, RGB üçlüsüdür */
82
+ const rgbTokenRgb = (v: string): RGB => {
83
+ const p = v.split(/\s+/).map((x) => parseFloat(x))
84
+ return [p[0], p[1], p[2]]
85
+ }
86
+
87
+ /** Semantik token'ın ilgili temadaki değeri — `.dark` ezmesi YOKSA light'a düşer */
88
+ const semantikRgb = (ad: string, blok: Blok): RGB =>
89
+ hslTokenRgb(tokenOku(ad, blok) ?? tokenOku(ad, 'light')!)
90
+
91
+ function bagilParlaklik([r, g, b]: RGB): number {
92
+ const c = (v: number) => {
93
+ const x = v / 255
94
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
95
+ }
96
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
97
+ }
98
+
99
+ function kontrast(a: RGB, b: RGB): number {
100
+ const la = bagilParlaklik(a)
101
+ const lb = bagilParlaklik(b)
102
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
103
+ return (hi + 0.05) / (lo + 0.05)
104
+ }
105
+
106
+ /**
107
+ * CIE76 ΔE*ab — "iki varyant AYIRT EDİLEBİLİR mi?" metriği. WCAG kontrastı bu
108
+ * soruya cevap veremez (yalnız bağıl parlaklık ölçer; apayrı hue'daki iki rengi
109
+ * "aynı" gösterebilir). Klon iddiaları bu yüzden ΔE ile kurulur. JND ≈ 2.3; eşik 10.
110
+ */
111
+ function rgbToLab([r, g, b]: RGB): RGB {
112
+ const s = (v: number) => {
113
+ const x = v / 255
114
+ return (x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)) * 100
115
+ }
116
+ const [R, G, B] = [s(r), s(g), s(b)]
117
+ const X = R * 0.4124 + G * 0.3576 + B * 0.1805
118
+ const Y = R * 0.2126 + G * 0.7152 + B * 0.0722
119
+ const Z = R * 0.0193 + G * 0.1192 + B * 0.9505
120
+ const ref: RGB = [95.047, 100.0, 108.883]
121
+ const g2 = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116)
122
+ const [fx, fy, fz] = [g2(X / ref[0]), g2(Y / ref[1]), g2(Z / ref[2])]
123
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
124
+ }
125
+
126
+ function deltaE(a: RGB, b: RGB): number {
127
+ const A = rgbToLab(a)
128
+ const B = rgbToLab(b)
129
+ return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2])
130
+ }
131
+
132
+ /** Docs kabuğunda ölçülen GERÇEK koyu kart zemini (#284/#368/#448 konvansiyonu) */
133
+ const KART_GERCEK_DARK: RGB = [17, 24, 39]
134
+
135
+ function zeminler(blok: Blok): Array<[string, RGB]> {
136
+ const z: Array<[string, RGB]> = [
137
+ ['--background', semantikRgb('background', blok)],
138
+ ['--card', semantikRgb('card', blok)],
139
+ ]
140
+ if (blok === 'dark') z.push(['gerçek kart #111827', KART_GERCEK_DARK])
141
+ return z
142
+ }
143
+
144
+ // ——— Renkler KAYNAKTAN türetilir (PR #440/#446/#453 dersi) ——————————————
145
+ const kaynak = fs.readFileSync(path.join(KOK, 'components/ui/switch.tsx'), 'utf8')
146
+ /** Yorum satırları ayıklanır — gerekçe metinleri `varyant:` gibi görünmesin */
147
+ const yorumsuz = kaynak.replace(/^[ \t]*\/\/.*$/gm, '')
148
+ const TRACK_CVA = yorumsuz.slice(
149
+ yorumsuz.indexOf('const switchVariants'),
150
+ yorumsuz.indexOf('const switchThumbVariants')
151
+ )
152
+ const THUMB_CVA = yorumsuz.slice(
153
+ yorumsuz.indexOf('const switchThumbVariants'),
154
+ yorumsuz.indexOf('export interface SwitchProps')
155
+ )
156
+
157
+ const VARYANTLAR = ['primary', 'success', 'warning', 'destructive', 'secondary'] as const
158
+ type Varyant = (typeof VARYANTLAR)[number]
159
+
160
+ /** CVA `variant` bloğundaki ham sınıf dizgesi */
161
+ function varyantSinifi(varyant: string): string {
162
+ const m = TRACK_CVA.match(new RegExp(`\\n\\s*${varyant}:\\s*"([^"]+)"`))
163
+ if (!m) throw new Error(`${varyant} varyantı switchVariants içinde bulunamadı`)
164
+ return m[1]
165
+ }
166
+
167
+ /** `bg-primary` / `bg-[rgb(var(--secondary-500))]` → RGB */
168
+ function sinifRengi(sinif: string, blok: Blok): RGB {
169
+ const skala = sinif.match(/^bg-\[rgb\(var\(--([a-z0-9-]+)\)\)\]$/)
170
+ if (skala) return rgbTokenRgb(tokenOku(skala[1], 'light')!) // skala YALNIZ light blokta
171
+ const semantik = sinif.match(/^bg-([a-z-]+)$/)
172
+ if (semantik) return semantikRgb(semantik[1], blok)
173
+ throw new Error(`çözülemeyen sınıf: ${sinif}`)
174
+ }
175
+
176
+ /** checked track'in ilgili temadaki rengi — `dark:` önekli sınıf dark'ta kazanır */
177
+ function checkedTrackRengi(varyant: string, blok: Blok): RGB {
178
+ const KOSUL = 'data-[state=checked]:'
179
+ const adaylar = varyantSinifi(varyant)
180
+ .split(/\s+/)
181
+ .filter(Boolean)
182
+ .filter((s) => s.replace(/^dark:/, '').startsWith(KOSUL))
183
+ const soy = (s: string) => s.replace(/^dark:/, '').slice(KOSUL.length)
184
+ const darkSinif = adaylar.find((s) => s.startsWith('dark:'))
185
+ const lightSinif = adaylar.find((s) => !s.startsWith('dark:'))
186
+ if (!lightSinif) throw new Error(`${varyant}: temel (light) checked sınıfı yok`)
187
+ return sinifRengi(soy(blok === 'dark' && darkSinif ? darkSinif : lightSinif), blok)
188
+ }
189
+
190
+ /** Thumb'ın dolgusu — A ve B eksenlerinin AYNI sayıyı vermesinin sebebi */
191
+ function thumbRengi(blok: Blok): RGB {
192
+ const m = THUMB_CVA.match(/"([^"]*\bbg-[a-z-]+\b[^"]*)"/)
193
+ if (!m) throw new Error('thumb taban sınıfı okunamadı')
194
+ const bg = m[1].split(/\s+/).find((s) => s.startsWith('bg-'))!
195
+ return sinifRengi(bg, blok)
196
+ }
197
+
198
+ const KADEMELER = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
199
+ const ESIK = 3.0
200
+ const DELTAE_ESIK = 10
201
+
202
+ /** Bir rengin switch eksenlerinde (A + B) o temada eşiği geçip geçmediği */
203
+ const eksenleriGecer = (c: RGB, blok: Blok): boolean =>
204
+ zeminler(blok).every(([, z]) => kontrast(c, z) >= ESIK) &&
205
+ kontrast(thumbRengi(blok), c) >= ESIK
206
+
207
+ describe('#454 — hesaplayıcı çapaları', () => {
208
+ it('kontrast hesaplayıcısı DOĞRU — belgelenmiş token çifti beklenen oranı veriyor', () => {
209
+ // tokens.css `--info-subtle` / `--info-subtle-foreground` için 9.5:1 iddia ediyor
210
+ const oran = kontrast(
211
+ semantikRgb('info-subtle', 'light'),
212
+ semantikRgb('info-subtle-foreground', 'light')
213
+ )
214
+ expect(oran).toBeGreaterThan(9.3)
215
+ expect(oran).toBeLessThan(9.7)
216
+ })
217
+
218
+ it('ΔE hesaplayıcısı DOĞRU — aynı renk 0, siyah↔beyaz 100', () => {
219
+ expect(deltaE([100, 116, 139], [100, 116, 139])).toBeCloseTo(0, 6)
220
+ expect(deltaE([0, 0, 0], [255, 255, 255])).toBeCloseTo(100, 0)
221
+ })
222
+
223
+ it('hesaplayıcı, issue #454 gövdesindeki ESKİ ölçümleri birebir üretiyor', () => {
224
+ // 1.154 / 1.359 / 1.197 — `bg-accent` ile checked track ↔ üç zemin
225
+ const eski = (blok: Blok) =>
226
+ zeminler(blok).map(([, z]) =>
227
+ Number(kontrast(semantikRgb('accent', blok), z).toFixed(3))
228
+ )
229
+ expect(eski('light')[0]).toBe(1.154)
230
+ expect(eski('dark')[0]).toBe(1.359)
231
+ expect(eski('dark')[2]).toBe(1.197)
232
+ })
233
+ })
234
+
235
+ describe('#454 — iki eksenin AYNI olmasının yapısal sebebi', () => {
236
+ it('thumb dolgusu `bg-background` — B ekseni A ekseniyle aynı renk çiftini ölçer', () => {
237
+ // Biri thumb'a ayrı bir dolgu verirse iki eksen ayrışır ve bu dosyanın
238
+ // "tek ölçüm iki ekseni kapatır" varsayımı sessizce geçersizleşir.
239
+ expect(THUMB_CVA).toContain('bg-background')
240
+ for (const blok of ['light', 'dark'] as const) {
241
+ expect(thumbRengi(blok)).toEqual(semantikRgb('background', blok))
242
+ }
243
+ })
244
+
245
+ it('durum ayrıca KONUMLA da taşınıyor — thumb ötelemeleri korunuyor', () => {
246
+ // Renk tek başına durum taşıyıcısı değil; `translate-x` ayrımı kapsam kilidi.
247
+ expect(THUMB_CVA).toContain('data-[state=checked]:translate-x-4')
248
+ expect(THUMB_CVA).toContain('data-[state=checked]:translate-x-5')
249
+ expect(THUMB_CVA).toContain('data-[state=checked]:translate-x-7')
250
+ expect(THUMB_CVA).toContain('data-[state=unchecked]:translate-x-0')
251
+ })
252
+ })
253
+
254
+ describe('#454 — KABUL KRİTERİ: her varyantın checked track’i iki eksende ≥3:1', () => {
255
+ for (const varyant of VARYANTLAR) {
256
+ for (const blok of ['light', 'dark'] as const) {
257
+ it(`${varyant} / ${blok}`, () => {
258
+ const c = checkedTrackRengi(varyant, blok)
259
+ for (const [ad, z] of zeminler(blok)) {
260
+ // Zemin adı hata mesajına girsin diye ayrı assertion
261
+ expect(`${ad}: ${kontrast(c, z) >= ESIK ? 'geçer' : kontrast(c, z).toFixed(3)}`).toBe(
262
+ `${ad}: geçer`
263
+ )
264
+ }
265
+ expect(kontrast(thumbRengi(blok), c)).toBeGreaterThanOrEqual(ESIK)
266
+ })
267
+ }
268
+ }
269
+ })
270
+
271
+ describe('#454 — klon filtresi: varyantlar birbirinden ayırt edilebilir', () => {
272
+ for (const blok of ['light', 'dark'] as const) {
273
+ it(`${blok} — tüm varyant çiftleri ΔE*ab ≥ ${DELTAE_ESIK}`, () => {
274
+ for (let i = 0; i < VARYANTLAR.length; i++) {
275
+ for (let j = i + 1; j < VARYANTLAR.length; j++) {
276
+ const cift = `${VARYANTLAR[i]}↔${VARYANTLAR[j]}`
277
+ const d = deltaE(
278
+ checkedTrackRengi(VARYANTLAR[i], blok),
279
+ checkedTrackRengi(VARYANTLAR[j], blok)
280
+ )
281
+ // Çift adı hata mesajına girsin diye dizge karşılaştırması
282
+ expect(`${cift}: ${d >= DELTAE_ESIK ? 'ayrık' : d.toFixed(1)}`).toBe(`${cift}: ayrık`)
283
+ }
284
+ }
285
+ })
286
+
287
+ it(`${blok} — checked track, UNCHECKED track’ten (bg-input) ayırt edilebilir`, () => {
288
+ // Açık switch'in kapalıya benzemesi tam olarak bu issue'nun şikâyeti.
289
+ const unchecked = semantikRgb('input', blok)
290
+ for (const varyant of VARYANTLAR) {
291
+ expect(deltaE(checkedTrackRengi(varyant, blok), unchecked)).toBeGreaterThanOrEqual(
292
+ DELTAE_ESIK
293
+ )
294
+ }
295
+ })
296
+ }
297
+ })
298
+
299
+ describe('#454 — `secondary` kararı: ad ↔ token tutarsızlığı ve reddedilen alternatifler', () => {
300
+ it('ESKİ hâli (`bg-accent`) üç zeminde de eşiğin ALTINDA — kusur gerçek', () => {
301
+ for (const blok of ['light', 'dark'] as const) {
302
+ for (const [, z] of zeminler(blok)) {
303
+ expect(kontrast(semantikRgb('accent', blok), z)).toBeLessThan(ESIK)
304
+ }
305
+ }
306
+ })
307
+
308
+ it('SEMANTİK `bg-secondary` ELENDİ — light’ta `--accent` ile BİREBİR aynı, dark’ta daha kötü', () => {
309
+ // "Adı secondary ise bg-secondary kullansın" en bariz alternatif; ölçümle elendi.
310
+ expect(tokenOku('secondary', 'light')).toBe(tokenOku('accent', 'light'))
311
+ expect(kontrast(semantikRgb('secondary', 'light'), semantikRgb('background', 'light'))).toBeLessThan(ESIK)
312
+ expect(kontrast(semantikRgb('secondary', 'dark'), semantikRgb('background', 'dark'))).toBeLessThan(1.05)
313
+ })
314
+
315
+ it('`bg-secondary-foreground` ELENDİ — dark’ta `--primary` ile BİREBİR aynı (primary klonu)', () => {
316
+ expect(tokenOku('secondary-foreground', 'dark')).toBe(tokenOku('primary', 'dark'))
317
+ })
318
+
319
+ it('slate skalasında iki temayı birden geçen VE klon olmayan TEK kademe 500', () => {
320
+ const uygun = KADEMELER.filter((k) => {
321
+ const ham = tokenOku(`secondary-${k}`, 'light')
322
+ if (!ham) return false
323
+ const c = rgbTokenRgb(ham)
324
+ return (['light', 'dark'] as const).every(
325
+ (blok) =>
326
+ eksenleriGecer(c, blok) &&
327
+ (['primary', 'success', 'warning', 'destructive'] as Varyant[]).every(
328
+ (v) => deltaE(c, checkedTrackRengi(v, blok)) >= DELTAE_ESIK
329
+ )
330
+ )
331
+ })
332
+ expect(uygun).toEqual([500])
333
+ })
334
+
335
+ it('#448’in slider’daki 500-elemesi BURADA GEÇERSİZ — switch’in `accent` varyantı YOK', () => {
336
+ // #453 ölçümü: `--secondary-500` ↔ slider'ın `secondary` varyantı ΔE = 0.0 (klon).
337
+ // Switch'te o çakışma yok; kaynak varyant listesi bunu kanıtlar.
338
+ expect(VARYANTLAR).not.toContain('accent')
339
+ expect(TRACK_CVA).not.toMatch(/\n\s*accent:/)
340
+ const k500 = rgbTokenRgb(tokenOku('secondary-500', 'light')!)
341
+ for (const blok of ['light', 'dark'] as const) {
342
+ for (const v of ['primary', 'success', 'warning', 'destructive'] as Varyant[]) {
343
+ expect(deltaE(k500, checkedTrackRengi(v, blok))).toBeGreaterThanOrEqual(DELTAE_ESIK)
344
+ }
345
+ }
346
+ })
347
+
348
+ it('`--accent-50 … --accent-950` skalası tokens.css’te TANIMLI DEĞİL', () => {
349
+ // Gerçek bir accent skalası eklenirse "varyant kendi ailesine dönsün mü?"
350
+ // sorusu yeniden açılır — burası o an kırmızıya döner.
351
+ for (const k of KADEMELER) expect(tokenOku(`accent-${k}`, 'light')).toBeNull()
352
+ expect(tokenOku('secondary-500', 'light')).not.toBeNull()
353
+ })
354
+ })
355
+
356
+ describe('#454 — `warning` kardeş kusuru: kök neden ve çözüm şekli', () => {
357
+ it('KÖK NEDEN: `--warning` `.dark` bloğunda EZİLMİYOR (kardeşleri tema-uyarlanabilir)', () => {
358
+ expect(tokenOku('warning', 'dark')).toBeNull()
359
+ expect(tokenOku('success', 'dark')).not.toBeNull() // karşıtlık
360
+ expect(tokenOku('accent', 'dark')).not.toBeNull() // karşıtlık (#448 ile farkı)
361
+ })
362
+
363
+ it('ESKİ hâli (`bg-warning`) LIGHT’ta eşiğin altında, DARK’ta değil', () => {
364
+ expect(kontrast(semantikRgb('warning', 'light'), semantikRgb('background', 'light'))).toBeLessThan(ESIK)
365
+ expect(kontrast(semantikRgb('warning', 'dark'), semantikRgb('background', 'dark'))).toBeGreaterThan(ESIK)
366
+ })
367
+
368
+ it('tema-KÖR bir warning kademesi seçilebilirdi ama DARK’ı gereksizce oynatırdı', () => {
369
+ // `dark:` ayrımının gerekçesi: dark zaten geçiyordu, regresyon yüzeyi sıfır tutuldu.
370
+ const temaKor = KADEMELER.filter((k) => {
371
+ const ham = tokenOku(`warning-${k}`, 'light')
372
+ return ham && (['light', 'dark'] as const).every((b) => eksenleriGecer(rgbTokenRgb(ham), b))
373
+ })
374
+ expect(temaKor).toEqual([700])
375
+ expect(checkedTrackRengi('warning', 'dark')).toEqual(semantikRgb('warning', 'dark'))
376
+ })
377
+
378
+ it('LIGHT’ta eşiği geçen kademe kümesi {700,800,900,950} — en açığı seçildi', () => {
379
+ const gecen = KADEMELER.filter((k) => {
380
+ const ham = tokenOku(`warning-${k}`, 'light')
381
+ return ham && eksenleriGecer(rgbTokenRgb(ham), 'light')
382
+ })
383
+ expect(gecen).toEqual([700, 800, 900, 950])
384
+ expect(checkedTrackRengi('warning', 'light')).toEqual(
385
+ rgbTokenRgb(tokenOku('warning-700', 'light')!)
386
+ )
387
+ })
388
+
389
+ it('DARK render BİT-BİT AYNI kaldı (warning + dokunulmayan varyantlar)', () => {
390
+ for (const v of ['primary', 'success', 'warning', 'destructive'] as Varyant[]) {
391
+ const tok = v === 'destructive' ? 'error' : v
392
+ expect(checkedTrackRengi(v, 'dark')).toEqual(semantikRgb(tok, 'dark'))
393
+ }
394
+ })
395
+ })
396
+
397
+ describe('#454 — kaynak sözleşmesi (switch.tsx)', () => {
398
+ it('`secondary` → slate skalasının 500 kademesi, `dark:` ayrımı YOK', () => {
399
+ expect(varyantSinifi('secondary')).toBe('data-[state=checked]:bg-[rgb(var(--secondary-500))]')
400
+ })
401
+
402
+ it('`warning` → light -700, dark semantik token', () => {
403
+ expect(varyantSinifi('warning')).toBe(
404
+ 'data-[state=checked]:bg-[rgb(var(--warning-700))] dark:data-[state=checked]:bg-warning'
405
+ )
406
+ })
407
+
408
+ it('KAPSAM KİLİDİ: geçen üç varyant DOKUNULMADI', () => {
409
+ expect(varyantSinifi('primary')).toBe('data-[state=checked]:bg-primary')
410
+ expect(varyantSinifi('success')).toBe('data-[state=checked]:bg-success')
411
+ expect(varyantSinifi('destructive')).toBe('data-[state=checked]:bg-error')
412
+ })
413
+
414
+ it('KAPSAM KİLİDİ: unchecked track ve prop yüzeyi DEĞİŞMEDİ', () => {
415
+ expect(TRACK_CVA).toContain('data-[state=unchecked]:bg-input')
416
+ expect(kaynak).toContain(
417
+ 'type SwitchVariant = "primary" | "success" | "warning" | "destructive" | "secondary";'
418
+ )
419
+ expect(kaynak).toContain('export { Switch, switchVariants }')
420
+ })
421
+
422
+ it('`bg-secondary-500` / `bg-warning-700` YAZILMADI — preset o sınıfları üretmiyor (#307 ölü-sınıf tuzağı)', () => {
423
+ const preset = fs.readFileSync(path.resolve(KOK, '../tailwind-preset.js'), 'utf8')
424
+ expect(preset).not.toMatch(/"?secondary"?:\s*\{[^}]*\b500:/s)
425
+ expect(preset).not.toMatch(/\bwarning:\s*\{[^}]*\b700:/s)
426
+ expect(TRACK_CVA).not.toContain('bg-secondary-500')
427
+ expect(TRACK_CVA).not.toContain('bg-warning-700')
428
+ })
429
+ })
430
+
431
+ describe('#454 — render sözleşmesi (<Switch />)', () => {
432
+ const sinif = (jsx: React.ReactElement) => {
433
+ const { container } = render(jsx)
434
+ return (container.querySelector('[role="switch"]') as HTMLElement).className
435
+ }
436
+
437
+ it('checked `secondary` switch slate-500 sınıfını taşıyor', () => {
438
+ expect(sinif(<Switch variant="secondary" defaultChecked />)).toContain(
439
+ 'data-[state=checked]:bg-[rgb(var(--secondary-500))]'
440
+ )
441
+ })
442
+
443
+ it('checked `secondary` switch ARTIK `bg-accent` taşımıyor', () => {
444
+ expect(sinif(<Switch variant="secondary" defaultChecked />)).not.toContain(
445
+ 'data-[state=checked]:bg-accent'
446
+ )
447
+ })
448
+
449
+ it('checked `warning` switch light -700 + dark semantik sınıflarını birlikte taşıyor', () => {
450
+ const c = sinif(<Switch variant="warning" defaultChecked />)
451
+ expect(c).toContain('data-[state=checked]:bg-[rgb(var(--warning-700))]')
452
+ expect(c).toContain('dark:data-[state=checked]:bg-warning')
453
+ })
454
+
455
+ it('varsayılan varyant (primary) ve unchecked davranışı korunuyor', () => {
456
+ const c = sinif(<Switch />)
457
+ expect(c).toContain('data-[state=checked]:bg-primary')
458
+ expect(c).toContain('data-[state=unchecked]:bg-input')
459
+ })
460
+ })
@@ -77,10 +77,14 @@ describe('Switch Component', () => {
77
77
  expect(switchElement).toHaveClass('data-[state=checked]:bg-success')
78
78
  })
79
79
 
80
+ // #454: `--warning` `.dark` bloğunda ezilmediği için light temada checked
81
+ // track ↔ zemin 2.133 idi (eşik 3:1). Light'ta skalanın -700 kademesine
82
+ // inildi, dark semantik token'da kaldı. Ölçüm: switch-variant-contrast.test.tsx
80
83
  it('renders warning variant correctly', () => {
81
84
  render(<Switch variant="warning" data-testid="switch" />)
82
85
  const switchElement = screen.getByTestId('switch')
83
- expect(switchElement).toHaveClass('data-[state=checked]:bg-warning')
86
+ expect(switchElement).toHaveClass('data-[state=checked]:bg-[rgb(var(--warning-700))]')
87
+ expect(switchElement).toHaveClass('dark:data-[state=checked]:bg-warning')
84
88
  })
85
89
 
86
90
  it('renders destructive variant correctly', () => {
@@ -89,10 +93,15 @@ describe('Switch Component', () => {
89
93
  expect(switchElement).toHaveClass('data-[state=checked]:bg-error')
90
94
  })
91
95
 
96
+ // #454: varyantın ADI `secondary` iken TOKEN'ı `accent` idi; `--accent` bir
97
+ // yüzey token'ı olduğu için checked switch iki temada da görünmüyordu
98
+ // (1.154 / 1.359). Varyant kendi ailesine, `--secondary-*` slate skalasına
99
+ // bağlandı. Ölçüm: switch-variant-contrast.test.tsx
92
100
  it('renders secondary variant correctly', () => {
93
101
  render(<Switch variant="secondary" data-testid="switch" />)
94
102
  const switchElement = screen.getByTestId('switch')
95
- expect(switchElement).toHaveClass('data-[state=checked]:bg-accent')
103
+ expect(switchElement).toHaveClass('data-[state=checked]:bg-[rgb(var(--secondary-500))]')
104
+ expect(switchElement).not.toHaveClass('data-[state=checked]:bg-accent')
96
105
  })
97
106
 
98
107
  it('uses primary variant as default', () => {
@@ -72,7 +72,42 @@ const sliderRangeVariants = cva(
72
72
  // tüketici --secondary-500'ü ezerek özelleştirebilir.
73
73
  // Regresyon: __tests__/slider-secondary-contrast.test.ts
74
74
  secondary: "bg-[rgb(var(--secondary-500))]",
75
- accent: "bg-accent",
75
+ // #448 (#389/#407'nin `accent` kardeşi — ama ÇÖZÜM SINIFI farklı): range
76
+ // `bg-accent`, track `bg-accent/20` idi — aynı yüzey token'ının iki alfası.
77
+ // `--accent` HER İKİ temada da kusurluydu (warning/error'dan ağır: onlar
78
+ // yalnız light'ta kusurluydu): A ekseni light 1.123 · dark 1.303 · gerçek
79
+ // docs kartı (#111827) 1.162. Eşik 3:1 (WCAG 1.4.11 non-text).
80
+ //
81
+ // KÖK NEDEN: `--accent` bir YÜZEY token'ı — `hover:bg-accent` tinti olarak
82
+ // tasarlanmış ve iki temada da kendi zeminine BİTİŞİK duruyor (light
83
+ // rgb(235,239,245) beyaz üstünde, dark rgb(29,40,58) rgb(3,7,17) üstünde).
84
+ // Dolgu/kenarlık rolüne yapısal olarak uygun değil; bu yüzden track'i
85
+ // değiştirerek onarmak da MÜMKÜN DEĞİL (light'ta accent'ten 3:1 uzakta
86
+ // duran daha açık bir yüzey yok).
87
+ //
88
+ // #389'un tek-orta-ton kademe araması AYNEN uygulanamadı: tokens.css'te
89
+ // `--accent-50 … --accent-950` skalası YOK (skalası olan aileler:
90
+ // primary/secondary/success/warning/error/brand/brand-accent).
91
+ // `bg-accent-foreground` de elendi — `--accent-foreground` iki temada da
92
+ // `--primary` ile BİREBİR aynı değer (varyant primary klonuna dönerdi) ve
93
+ // 7 tema preset'inin 2'sinde beyaz (Creative/Nature), yani tek başına
94
+ // dolgu olamaz.
95
+ //
96
+ // ÇÖZÜM: sistemin TEK nötr skalası olan slate (`--secondary-*`) — `--accent`
97
+ // ile aynı hue ailesi (216° ↔ 215°), üstelik light temada `--accent` ile
98
+ // `--secondary` BİREBİR aynı token değerini taşıyor. Tema-kör tek kademe
99
+ // SEÇİLEMEDİ: bu ailede iki temayı birden geçen tek kademe 500'dür, o da
100
+ // `secondary` varyantının kendi rengi (ΔE*ab = 0.0 — iki varyant klon olurdu).
101
+ // Bu yüzden #442'nin `dark:` ayrımı deseni kullanıldı, tema başına ayrı kademe.
102
+ // Tam tarama (11 kademe × 2 tema × 3 zemin × 2 eksen): DARK'ta eşiği geçen VE
103
+ // kardeş varyantlardan ayırt edilebilen (ΔE*ab ≥ 10) TEK kademe 400
104
+ // (500 = secondary klonu · ≤300 `default`/`primary` ile çakışıyor · ≥600 eşiğin
105
+ // altında). LIGHT'ta {600,700,800} uygun; 700 kardeş ayrımını maksimize eder
106
+ // (ΔE 21.2 · 600→12.7 · 800→14.9).
107
+ // Ölçülen — A: light 1.123 → 10.081 ✓ · dark 1.303 → 7.533 ✓ · docs kartı
108
+ // 1.162 → 6.717 ✓. Track `bg-accent/20` DEĞİŞMEDİ (yedi varyantın ortak deseni).
109
+ // Regresyon: __tests__/slider-accent-contrast.test.ts
110
+ accent: "bg-[rgb(var(--secondary-700))] dark:bg-[rgb(var(--secondary-400))]",
76
111
  success: "bg-success",
77
112
  // #442 (#424/PR #440'ın slider ikizi): range `bg-warning`/`bg-error` idi —
78
113
  // track (`bg-warning/20` · `bg-error/20`) AYNI token'ın alfalı hâli olduğundan
@@ -128,7 +163,16 @@ const sliderThumbVariants = cva(
128
163
  // aynı kademe: tutamaç ve dolgu tek renkte okunur.
129
164
  // Regresyon: __tests__/slider-secondary-contrast.test.ts
130
165
  secondary: "border-[rgb(var(--secondary-500))] bg-background",
131
- accent: "border-accent bg-background",
166
+ // #448 EKSEN B (range'den FARKLI eksen: bileşen↔sayfa-zemini) — tutamacın
167
+ // dolgusu `bg-background`, yani zeminin kendisi; ayırt edilebilirliğini
168
+ // YALNIZ kenarlık taşır → non-text UI bileşeni, WCAG 1.4.11 ≥3:1.
169
+ // `border-accent` iki temada da kayboluyordu: light 1.154 · dark 1.359 ·
170
+ // gerçek docs kartı 1.197. Range ile AYNI kademeler seçildi — #407'nin
171
+ // gerekçesi burada da geçerli: "tutamaç ve dolgu tek renkte okunur".
172
+ // Ölçülen — light 1.154 → 10.355 ✓ · dark 1.359 → 7.857 ✓ · docs kartı
173
+ // 1.197 → 6.919 ✓.
174
+ // Regresyon: __tests__/slider-accent-contrast.test.ts
175
+ accent: "border-[rgb(var(--secondary-700))] dark:border-[rgb(var(--secondary-400))] bg-background",
132
176
  success: "border-success bg-background",
133
177
  // #442 EKSEN B (range'den FARKLI eksen: bileşen↔sayfa-zemini) — #407'de
134
178
  // `secondary` için açılan eksenin warning/error karşılığı. Tutamacın dolgusu
@@ -34,12 +34,51 @@ const switchVariants = cva(
34
34
  md: "h-6 w-11",
35
35
  lg: "h-7 w-14",
36
36
  },
37
+ // #454 — checked track'in İKİ ekseni de ölçüldü (eşik 3:1, WCAG 1.4.11
38
+ // non-text UI). Switch'te bu iki eksen AYNI sayıyı verir, çünkü thumb
39
+ // `bg-background` taşır (switchThumbVariants tabanı):
40
+ // A) checked track ↔ sayfa/kart zemini → kontrol "açık" okunuyor mu?
41
+ // B) thumb ↔ checked track → tutamacın KONUMU görülüyor mu?
42
+ // Ölçüm zeminleri: `--background` · `--card` · docs kabuğunun gerçek koyu
43
+ // kartı (#111827). Regresyon: __tests__/switch-variant-contrast.test.tsx
37
44
  variant: {
38
- primary: "data-[state=checked]:bg-primary",
39
- success: "data-[state=checked]:bg-success",
40
- warning: "data-[state=checked]:bg-warning",
41
- destructive: "data-[state=checked]:bg-error",
42
- secondary: "data-[state=checked]:bg-accent",
45
+ primary: "data-[state=checked]:bg-primary", // light 4.920 · dark 19.256 ✓
46
+ success: "data-[state=checked]:bg-success", // light 4.904 · dark 6.043 ✓
47
+ // #454 kardeş kusuru (#424/PR #440 progress · #442/PR #446 slider ile AYNI
48
+ // sınıf, üçüncü component): `--warning` tokens.css'in `.dark` bloğunda
49
+ // EZİLMİYOR, yani iki temada da aynı turuncu. Dark'ta zemin koyu olduğu
50
+ // için geçiyor (9.446), LIGHT'ta beyaz üstünde 2.133 ✗ — açık temada
51
+ // checked warning switch'in tutamacı da kayboluyordu (B ekseni 2.133).
52
+ // ÇÖZÜM (emsalle birebir aynı şekil): eksik tema-uyarlamasını component
53
+ // düzeyinde ver — light'ta skalanın -700 kademesine in, dark'ta semantik
54
+ // token'a DOKUNMA (dark render bit-bit aynı kalır, regresyon yüzeyi sıfır).
55
+ // Light'ta eşiği geçen kademe kümesi {700,800,900,950}; en açık olanı
56
+ // seçildi (turuncu kimliğini en çok koruyan). Ölçülen: 2.133 → 4.923 ✓.
57
+ warning:
58
+ "data-[state=checked]:bg-[rgb(var(--warning-700))] dark:data-[state=checked]:bg-warning",
59
+ destructive: "data-[state=checked]:bg-error", // light 3.781 · dark 5.328 ✓ (dokunulmadı)
60
+ // #454 asıl kusuru: `secondary` varyantı `bg-accent` kullanıyordu — ADI
61
+ // `secondary`, TOKEN'ı `accent`. `--accent` bir YÜZEY token'ı (`hover:bg-accent`
62
+ // tinti) ve iki temada da kendi zeminine bitişik: light rgb(235,239,245)
63
+ // beyaz üstünde 1.154 · dark rgb(29,40,58) 1.359 · docs kartı 1.197.
64
+ // Yani checked switch hem sayfadan hem de kendi tutamacından ayrışmıyordu:
65
+ // "açık" durum GÖRÜNMÜYORDU (switch'in tek işi durumu göstermek).
66
+ //
67
+ // AD ↔ TOKEN KARARI: varyant kendi ailesine, `--secondary-*` slate skalasına
68
+ // bağlandı. `bg-secondary` (semantik) BİLEREK seçilmedi — light'ta `--accent`
69
+ // ile BİREBİR aynı değer (1.154), dark'ta daha da kötü (1.007). Skala
70
+ // taraması (11 kademe × 2 tema × 3 zemin × 2 eksen + kardeş ΔE*ab ≥ 10 klon
71
+ // filtresi): light {500,600,700,800,900,950} · dark {300,400,500} → iki
72
+ // temayı birden geçen TEK kademe **500**, yani `dark:` ayrımına gerek yok.
73
+ // #448'in slider'daki 700/400 ayrımı burada GEREKMEZ ve İSTENMEZ: orada 500
74
+ // elenmişti çünkü slider'ın `secondary` varyantı zaten o rengi kullanıyordu
75
+ // (ΔE = 0.0 → klon). Switch'in `accent` adlı bir varyantı YOK, dolayısıyla
76
+ // 500 serbest ve kardeşlerden ayrık (ΔE: primary 69.5/51.6 · success
77
+ // 63.6/74.8 · warning 96.0 · destructive 85.0 — hepsi ≥ 10).
78
+ // Yan fayda: slider'ın `secondary` varyantı da #389/PR #406'da aynı kademeye
79
+ // sabitlenmişti → aynı varyant adı artık iki component'te aynı rengi veriyor.
80
+ // Ölçülen: A/B light 1.154 → 4.759 ✓ · dark 1.359 → 4.234 ✓ · kart 1.197 → 3.728 ✓.
81
+ secondary: "data-[state=checked]:bg-[rgb(var(--secondary-500))]",
43
82
  },
44
83
  },
45
84
  defaultVariants: {