@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moontra/moonui",
3
- "version": "6.19.1",
3
+ "version": "6.19.2",
4
4
  "description": "Premium React component library with modern design and customization",
5
5
  "author": "MoonUI",
6
6
  "license": "MIT",
@@ -0,0 +1,553 @@
1
+ /**
2
+ * #448 — Slider `accent` kontrast değişmezleri (İKİ AYRI EKSEN, İKİ TEMA).
3
+ *
4
+ * BULGU (PR #446'nın kapsam-dışı kardeş bulgusu): `accent` varyantı HER İKİ eksende
5
+ * ve HER İKİ temada eşiğin altındaydı — `warning`/`error`dan (#424/#442) ağır, çünkü
6
+ * onlar yalnız light'ta kusurluydu.
7
+ *
8
+ * EKSEN A (range↔track): range `bg-accent`, track `bg-accent/20` — aynı yüzey
9
+ * token'ının iki alfası. Ölçülen: light 1.123 · dark 1.303 · docs kartı 1.162.
10
+ *
11
+ * EKSEN B (thumb kenarlığı↔zemin): tutamaç `border-accent` + `bg-background`.
12
+ * Dolgu zeminin kendisi olduğundan ayırt edilebilirliği YALNIZ kenarlık taşır.
13
+ * Ölçülen: light 1.154 · dark 1.359 · docs kartı 1.197.
14
+ *
15
+ * Eşik 3:1 (WCAG 1.4.11, non-text). axe yakalamaz: `color-contrast` yalnız METİN ölçer.
16
+ *
17
+ * KÖK NEDEN — YÜZEY token'ı, dolgu rolüne yapısal olarak uygun değil:
18
+ * `--accent` `hover:bg-accent` tinti olarak tasarlanmış ve `.dark`'ta EZİLİYOR (yani
19
+ * #442'nin "tema uyarlaması eksik" kök nedeni burada GEÇERSİZ) — ama iki temada da
20
+ * kendi zeminine bitişik duruyor. Bu yüzden track'i değiştirerek onarmak da mümkün
21
+ * değil; dolgu/kenarlık renginin kendisi değişmek ZORUNDA.
22
+ *
23
+ * ÇÖZÜM SINIFI — #385/#406'nın tek-orta-ton kademe araması AYNEN uygulanamaz:
24
+ * tokens.css'te `--accent-50 … --accent-950` skalası YOK. Sistemin tek nötr skalası
25
+ * slate (`--secondary-*`); `--accent` onunla aynı hue ailesinde (light temada BİREBİR
26
+ * aynı token değeri). Tema-kör tek kademe de seçilemedi — o ailede iki temayı birden
27
+ * geçen tek kademe 500'dür, o da `secondary` varyantının kendi rengi (ΔE*ab = 0.0 →
28
+ * iki varyant klon olurdu). Bu yüzden #442'nin `dark:` ayrımı deseni kullanıldı:
29
+ * range: "bg-[rgb(var(--secondary-700))] dark:bg-[rgb(var(--secondary-400))]"
30
+ * thumb: "border-[rgb(var(--secondary-700))] dark:border-[rgb(var(--secondary-400))] bg-background"
31
+ *
32
+ * NOT: `slider-secondary-contrast.test.ts` (#389/#407) ve
33
+ * `slider-warning-error-contrast.test.ts` (#442) dosyalarından BİLEREK ayrı — her
34
+ * dosya kendi token eksenini kilitler. Pro ikizi:
35
+ * packages/moonui-pro/.../slider-accent-contrast.test.tsx
36
+ */
37
+ import * as React from 'react'
38
+ import { render } from '@testing-library/react'
39
+ import '@testing-library/jest-dom'
40
+ import * as fs from 'fs'
41
+ import * as path from 'path'
42
+ import { Slider } from '../slider'
43
+
44
+ const KOK = path.resolve(__dirname, '../../..')
45
+ const tokens = fs.readFileSync(path.join(KOK, 'styles/tokens.css'), 'utf8')
46
+
47
+ /** "H S% L%" biçimindeki token değerini oku (light = :root, dark = .dark bloğu) */
48
+ function tokenOku(ad: string, blok: 'light' | 'dark'): string | null {
49
+ const kaynak =
50
+ blok === 'dark'
51
+ ? tokens.slice(tokens.indexOf('.dark'))
52
+ : tokens.slice(0, tokens.indexOf('.dark'))
53
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
54
+ }
55
+
56
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
57
+ h /= 360
58
+ s /= 100
59
+ l /= 100
60
+ const f = (n: number) => {
61
+ const k = (n + h * 12) % 12
62
+ const a = s * Math.min(l, 1 - l)
63
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
64
+ }
65
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
66
+ }
67
+
68
+ const hslTokenRgb = (v: string): [number, number, number] => {
69
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
70
+ return hslToRgb(h, s, l)
71
+ }
72
+
73
+ /**
74
+ * Skala token'ları ("--secondary-700: 51 65 85") HSL DEĞİL, RGB üçlüsüdür (semantik
75
+ * token'ların HSL formatından farklı) — component'te bu yüzden
76
+ * `rgb(var(--secondary-700))` kompozisyonu kullanılır, hsl() değil.
77
+ */
78
+ const rgbTokenRgb = (v: string): [number, number, number] => {
79
+ const p = v.split(/\s+/).map((x) => parseFloat(x))
80
+ return [p[0], p[1], p[2]]
81
+ }
82
+
83
+ function bagilParlaklik([r, g, b]: [number, number, number]): number {
84
+ const c = (v: number) => {
85
+ const x = v / 255
86
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
87
+ }
88
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
89
+ }
90
+
91
+ function rgbKontrast(a: [number, number, number], b: [number, number, number]): number {
92
+ const la = bagilParlaklik(a)
93
+ const lb = bagilParlaklik(b)
94
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
95
+ return (hi + 0.05) / (lo + 0.05)
96
+ }
97
+
98
+ /**
99
+ * CIE76 ΔE*ab — "iki varyant AYIRT EDİLEBİLİR mi?" sorusunun metriği.
100
+ * WCAG kontrastı bu soruya CEVAP VEREMEZ: yalnız bağıl parlaklık ölçer, bu yüzden
101
+ * apayrı hue'daki iki rengi "aynı" gösterebilir (ölçüldü: `--primary` mavisi ile
102
+ * slate-500 arasındaki kontrast 1.03, ama renkler apayrı). Klon iddiaları bu yüzden
103
+ * ΔE ile kurulur. JND ≈ 2.3; burada kullanılan eşik 10 (rahatça "farklı renk").
104
+ */
105
+ function rgbToLab([r, g, b]: [number, number, number]): [number, number, number] {
106
+ const s = (v: number) => {
107
+ const x = v / 255
108
+ return (x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)) * 100
109
+ }
110
+ const [R, G, B] = [s(r), s(g), s(b)]
111
+ const X = R * 0.4124 + G * 0.3576 + B * 0.1805
112
+ const Y = R * 0.2126 + G * 0.7152 + B * 0.0722
113
+ const Z = R * 0.0193 + G * 0.1192 + B * 0.9505
114
+ const ref: [number, number, number] = [95.047, 100.0, 108.883]
115
+ const g2 = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116)
116
+ const [fx, fy, fz] = [g2(X / ref[0]), g2(Y / ref[1]), g2(Z / ref[2])]
117
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
118
+ }
119
+
120
+ function deltaE(a: [number, number, number], b: [number, number, number]): number {
121
+ const A = rgbToLab(a)
122
+ const B = rgbToLab(b)
123
+ return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2])
124
+ }
125
+
126
+ /** `bg-accent/20` gibi alfa katmanının zemin üstündeki bileşke rengi */
127
+ function alfaBileske(
128
+ ust: [number, number, number],
129
+ alfa: number,
130
+ zemin: [number, number, number]
131
+ ): [number, number, number] {
132
+ return [
133
+ Math.round(alfa * ust[0] + (1 - alfa) * zemin[0]),
134
+ Math.round(alfa * ust[1] + (1 - alfa) * zemin[1]),
135
+ Math.round(alfa * ust[2] + (1 - alfa) * zemin[2]),
136
+ ]
137
+ }
138
+
139
+ /**
140
+ * Docs kabuğunda ölçülen GERÇEK koyu kart zemini (#111827 = rgb(17,24,39)) —
141
+ * #284/#368 konvansiyonu. Bu bulguda DARBOĞAZ: en düşük dark oranlarını o veriyor.
142
+ */
143
+ const KART_GERCEK_DARK: [number, number, number] = [17, 24, 39]
144
+
145
+ /** Semantik token'ın ilgili temadaki değeri — `.dark` ezmesi YOKSA light'a düşer */
146
+ function semantikRgb(ad: string, blok: 'light' | 'dark'): [number, number, number] {
147
+ return hslTokenRgb(tokenOku(ad, blok) ?? tokenOku(ad, 'light')!)
148
+ }
149
+
150
+ /** O temada slider'ın oturabileceği tüm zeminler */
151
+ function zeminler(blok: 'light' | 'dark'): Array<[string, [number, number, number]]> {
152
+ const z: Array<[string, [number, number, number]]> = [
153
+ ['--background', semantikRgb('background', blok)],
154
+ ['--card', semantikRgb('card', blok)],
155
+ ]
156
+ if (blok === 'dark') z.push(['gerçek kart #111827', KART_GERCEK_DARK])
157
+ return z
158
+ }
159
+
160
+ // ——— Renkler KAYNAKTAN türetilir ———————————————————————————————————————
161
+ // PR #440'ın dersi: ölçümü "-700/-400" VARSAYIMINA göre yaparsan, biri değeri
162
+ // eskiye döndürdüğünde kriter testleri yeşil kalır (boş-yeşil). Bu yüzden renkler
163
+ // slider.tsx'in O ANKİ CVA sınıf dizgelerinden çözülür — track alfası dahil.
164
+ const kaynak = fs.readFileSync(path.join(KOK, 'components/ui/slider.tsx'), 'utf8')
165
+ const iTrack = kaynak.indexOf('sliderTrackVariants')
166
+ const iRange = kaynak.indexOf('sliderRangeVariants')
167
+ const iThumb = kaynak.indexOf('sliderThumbVariants')
168
+ const iSon = kaynak.indexOf('SliderBaseProps')
169
+ const TRACK_BLOK = kaynak.slice(iTrack, iRange)
170
+ const RANGE_BLOK = kaynak.slice(iRange, iThumb)
171
+ const THUMB_BLOK = kaynak.slice(iThumb, iSon)
172
+
173
+ /** CVA bloğundan `<varyant>: "<sınıflar>"` değerini oku */
174
+ function varyantSinifi(blok: string, varyant: string): string {
175
+ const m = blok.match(new RegExp(`\\n\\s*${varyant}:\\s*"([^"]+)"`))
176
+ if (!m) throw new Error(`${varyant} varyantı bulunamadı`)
177
+ return m[1]
178
+ }
179
+
180
+ /** Tek bir Tailwind sınıfını renge çevir (arbitrary skala VEYA semantik token) */
181
+ function sinifRengi(sinif: string, blok: 'light' | 'dark'): [number, number, number] {
182
+ const skala = sinif.match(/^(?:bg|border)-\[rgb\(var\(--([a-z0-9-]+)\)\)\]$/)
183
+ if (skala) return rgbTokenRgb(tokenOku(skala[1], 'light')!) // skala yalnız :root'ta
184
+ const semantik = sinif.match(/^(?:bg|border)-([a-z-]+)$/)
185
+ if (semantik) return semantikRgb(semantik[1], blok)
186
+ throw new Error(`çözülemeyen sınıf: ${sinif}`)
187
+ }
188
+
189
+ /**
190
+ * Varyant dizgesinden o temada GERÇEKTEN uygulanan rengi çöz.
191
+ * `dark:` önekli sınıf dark temada kazanır; `onek` ile eksen seçilir (thumb dizgesi
192
+ * `border-*` yanında `bg-background` da taşır).
193
+ */
194
+ function uygulananRenk(
195
+ cvaBlok: string,
196
+ varyant: string,
197
+ onek: 'bg' | 'border',
198
+ blok: 'light' | 'dark'
199
+ ): [number, number, number] {
200
+ const siniflar = varyantSinifi(cvaBlok, varyant)
201
+ .split(/\s+/)
202
+ .filter((s) => s.replace(/^dark:/, '').startsWith(`${onek}-`))
203
+ const darkSinif = siniflar.find((s) => s.startsWith('dark:'))?.slice('dark:'.length)
204
+ const lightSinif = siniflar.find((s) => !s.startsWith('dark:'))
205
+ if (!lightSinif) throw new Error(`${varyant}/${onek}: temel (light) sınıf yok`)
206
+ return sinifRengi(blok === 'dark' ? darkSinif ?? lightSinif : lightSinif, blok)
207
+ }
208
+
209
+ const rangeRengi = (v: string, blok: 'light' | 'dark') => uygulananRenk(RANGE_BLOK, v, 'bg', blok)
210
+ const thumbKenarRengi = (v: string, blok: 'light' | 'dark') =>
211
+ uygulananRenk(THUMB_BLOK, v, 'border', blok)
212
+
213
+ /** Track'in her zemin üstündeki bileşkesi — alfa da KAYNAKTAN okunur */
214
+ function trackler(varyant: string, blok: 'light' | 'dark') {
215
+ const sinif = varyantSinifi(TRACK_BLOK, varyant)
216
+ const m = sinif.match(/^bg-([a-z-]+)\/(\d+)$/)
217
+ if (!m) throw new Error(`track sınıfı çözülemedi: ${sinif}`)
218
+ const taban = semantikRgb(m[1], blok)
219
+ const alfa = Number(m[2]) / 100
220
+ return zeminler(blok).map(([ad, z]) => [ad, alfaBileske(taban, alfa, z)] as const)
221
+ }
222
+
223
+ /** `accent` DIŞINDAKİ slider varyantlarının o temada gerçekten uygulanan dolgu renkleri */
224
+ const KARDESLER = ['default', 'primary', 'secondary', 'success', 'warning', 'error'] as const
225
+ const kardesRenkleri = (blok: 'light' | 'dark') =>
226
+ KARDESLER.map((v) => [v, rangeRengi(v, blok)] as const)
227
+
228
+ const KADEMELER = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
229
+ /** Ayırt edilebilirlik eşiği (CIE76). Eşiğin yeri sonucu DEĞİŞTİRMİYOR: elenen
230
+ * tek-orta-ton adayı 500, kardeşine ΔE = 0.0 ile birebir AYNI renk. */
231
+ const DELTAE_ESIK = 10
232
+
233
+ /** Slider DOM'u: kök > .moonui-theme > track > range; thumb'lar track'in KARDEŞİ */
234
+ function parcalar(container: HTMLElement) {
235
+ const kabuk = container.querySelector('.moonui-theme') as HTMLElement
236
+ expect(kabuk).not.toBeNull()
237
+ const track = kabuk.firstElementChild as HTMLElement
238
+ const range = track.firstElementChild as HTMLElement
239
+ const thumb = kabuk.querySelector('[role="slider"]') as HTMLElement
240
+ expect(range).not.toBeNull()
241
+ expect(thumb).not.toBeNull()
242
+ return { track, range, thumb }
243
+ }
244
+
245
+ describe('#448 hesaplayıcı çapaları', () => {
246
+ it('kontrast hesaplayıcısı DOĞRU — belgelenmiş token çifti beklenen oranı veriyor', () => {
247
+ // tokens.css `--info-subtle` çiftini 9.52:1 diye belgeliyor.
248
+ const oran = rgbKontrast(
249
+ semantikRgb('info-subtle', 'light'),
250
+ semantikRgb('info-subtle-foreground', 'light')
251
+ )
252
+ expect(oran).toBeGreaterThan(9.3)
253
+ expect(oran).toBeLessThan(9.7)
254
+ })
255
+
256
+ it('ΔE hesaplayıcısı DOĞRU — aynı renk 0, siyah↔beyaz 100', () => {
257
+ expect(deltaE([100, 116, 139], [100, 116, 139])).toBeCloseTo(0, 6)
258
+ expect(deltaE([0, 0, 0], [255, 255, 255])).toBeCloseTo(100, 0)
259
+ })
260
+
261
+ it('hesaplayıcı, issue #448 gövdesindeki ölçümleri birebir üretiyor', () => {
262
+ // Çapraz doğrulama: issue tablosu (A: 1.12/1.30/1.16 · B: 1.15/1.36/1.20) bu
263
+ // dosyadan BAĞIMSIZ olarak PR #446 çalışmasında ölçülmüştü. Model onları
264
+ // tutturamıyorsa aşağıdaki tüm oranlar anlamsızdır.
265
+ const eskiA = (blok: 'light' | 'dark') =>
266
+ trackler('accent', blok).map(([, t]) =>
267
+ Number(rgbKontrast(semantikRgb('accent', blok), t).toFixed(2))
268
+ )
269
+ expect(eskiA('light')[0]).toBe(1.12)
270
+ expect(eskiA('dark')[0]).toBe(1.3)
271
+ expect(eskiA('dark')[2]).toBe(1.16) // gerçek docs kartı
272
+ const eskiB = (blok: 'light' | 'dark') =>
273
+ zeminler(blok).map(([, z]) =>
274
+ Number(rgbKontrast(semantikRgb('accent', blok), z).toFixed(2))
275
+ )
276
+ expect(eskiB('light')[0]).toBe(1.15)
277
+ expect(eskiB('dark')[0]).toBe(1.36)
278
+ expect(eskiB('dark')[2]).toBe(1.2)
279
+ })
280
+ })
281
+
282
+ describe('#448 kök neden — `--accent` YÜZEY token’ı, skalası YOK', () => {
283
+ it('`--accent-50 … --accent-950` skalası tokens.css’te TANIMLI DEĞİL', () => {
284
+ // Bu testin varlık sebebi: #385/#406'nın tek-orta-ton kademe araması bir
285
+ // `--accent-*` skalası VARSAYAR. Yok. Biri ileride gerçek bir accent skalası
286
+ // eklerse burası kırmızı olur ve varyantın kendi ailesine döndürülmesi
287
+ // (bu PR'ın önerdiği takip işi) gerektiğini söyler.
288
+ for (const k of KADEMELER) {
289
+ expect(tokenOku(`accent-${k}`, 'light')).toBeNull()
290
+ }
291
+ // `--brand-accent-*` AYRI bir ailedir (marka pembesi), `--accent`in skalası değil.
292
+ expect(tokenOku('brand-accent-500', 'light')).not.toBeNull()
293
+ })
294
+
295
+ it('#442’den FARKLI kök neden: `--accent` `.dark` bloğunda EZİLİYOR', () => {
296
+ // warning/error'ın kusuru "dark ezmesi yok" idi (o yüzden yalnız light'ta
297
+ // kırıktılar). accent tema-uyarlanabilir OLDUĞU HÂLDE iki temada da kırık:
298
+ // kusur uyarlamanın yokluğu değil, token'ın YÜZEY rolü.
299
+ expect(tokenOku('accent', 'dark')).not.toBeNull()
300
+ expect(tokenOku('accent', 'light')).not.toBe(tokenOku('accent', 'dark'))
301
+ expect(tokenOku('warning', 'dark')).toBeNull() // karşıtlık
302
+ })
303
+
304
+ it('`--accent` iki temada da KENDİ zeminine bitişik (yüzey tinti olduğunun kanıtı)', () => {
305
+ // Track'i değiştirerek onarmanın neden imkânsız olduğunun kanıtı: dolgu rengi
306
+ // zeminden ayrışmıyorsa, altına konacak hiçbir track 3:1 üretemez.
307
+ for (const blok of ['light', 'dark'] as const) {
308
+ for (const [, z] of zeminler(blok)) {
309
+ expect(rgbKontrast(semantikRgb('accent', blok), z)).toBeLessThan(1.5)
310
+ }
311
+ }
312
+ })
313
+ })
314
+
315
+ describe('#448 reddedilen alternatifler (seçimin gerekçesi)', () => {
316
+ it('`bg-accent-foreground` ELENDİ — iki temada da `--primary` ile BİREBİR aynı', () => {
317
+ // #389'un `secondary` için verdiği kararın aynısı: varyant primary klonuna dönerdi.
318
+ for (const blok of ['light', 'dark'] as const) {
319
+ expect(tokenOku('accent-foreground', blok)).toBe(tokenOku('primary', blok))
320
+ }
321
+ })
322
+
323
+ it('`bg-accent-foreground` AYRICA tek başına dolgu olamaz — preset’lerin 2’sinde BEYAZ', () => {
324
+ // Creative + Nature preset'lerinde accentForeground "0 0% 100%": beyaz sayfada
325
+ // beyaz dolgu. Token, accent YÜZEYİNİN mürekkebi; bağımsız dolgu rengi değil.
326
+ const presetler = fs.readFileSync(path.join(KOK, 'lib/theme-presets.ts'), 'utf8')
327
+ const beyazlar = [...presetler.matchAll(/accentForeground:\s*"([^"]+)"/g)].filter(
328
+ (m) => m[1] === '0 0% 100%'
329
+ )
330
+ expect(beyazlar.length).toBeGreaterThanOrEqual(2)
331
+ })
332
+
333
+ it('slate ailesinde TEMA-KÖR geçen tek kademe 500 — o da `secondary` varyantının KLONU', () => {
334
+ // #385/#406/#389'un çözüm şeklinin burada neden uygulanamadığının kanıtı.
335
+ const gecenler = KADEMELER.filter((k) => {
336
+ const ham = tokenOku(`secondary-${k}`, 'light')
337
+ if (!ham) return false
338
+ const c = rgbTokenRgb(ham)
339
+ return (['light', 'dark'] as const).every((blok) =>
340
+ trackler('accent', blok).every(([, t]) => rgbKontrast(c, t) >= 3.0) &&
341
+ zeminler(blok).every(([, z]) => rgbKontrast(c, z) >= 3.0)
342
+ )
343
+ })
344
+ expect(gecenler).toEqual([500])
345
+ // ...ve 500, `secondary` varyantının o anki rengiyle BİREBİR aynı (ΔE = 0).
346
+ const k500 = rgbTokenRgb(tokenOku('secondary-500', 'light')!)
347
+ expect(deltaE(k500, rangeRengi('secondary', 'light'))).toBeCloseTo(0, 6)
348
+ expect(deltaE(k500, rangeRengi('secondary', 'dark'))).toBeCloseTo(0, 6)
349
+ })
350
+
351
+ it('DARK temada eşiği geçen VE kardeşlerden ayırt edilebilen TEK kademe 400', () => {
352
+ // Seçimin zorunlu olduğunun kanıtı: dark tarafta serbestlik YOK.
353
+ // ≤300 `default`/`primary` ile çakışır, 500 `secondary` klonu, ≥600 eşiğin altı.
354
+ const uygun = KADEMELER.filter((k) => {
355
+ const ham = tokenOku(`secondary-${k}`, 'light')
356
+ if (!ham) return false
357
+ const c = rgbTokenRgb(ham)
358
+ const esik =
359
+ trackler('accent', 'dark').every(([, t]) => rgbKontrast(c, t) >= 3.0) &&
360
+ zeminler('dark').every(([, z]) => rgbKontrast(c, z) >= 3.0)
361
+ const ayirt = kardesRenkleri('dark').every(([, r]) => deltaE(c, r) >= DELTAE_ESIK)
362
+ return esik && ayirt
363
+ })
364
+ expect(uygun).toEqual([400])
365
+ })
366
+
367
+ it('LIGHT temada {600,700,800} uygun; 700 kardeş ayrımını MAKSİMİZE ediyor', () => {
368
+ const skor = (k: number) => {
369
+ const c = rgbTokenRgb(tokenOku(`secondary-${k}`, 'light')!)
370
+ return Math.min(...kardesRenkleri('light').map(([, r]) => deltaE(c, r)))
371
+ }
372
+ const uygun = KADEMELER.filter((k) => {
373
+ const c = rgbTokenRgb(tokenOku(`secondary-${k}`, 'light')!)
374
+ const esik =
375
+ trackler('accent', 'light').every(([, t]) => rgbKontrast(c, t) >= 3.0) &&
376
+ zeminler('light').every(([, z]) => rgbKontrast(c, z) >= 3.0)
377
+ return esik && skor(k) >= DELTAE_ESIK
378
+ })
379
+ expect(uygun).toEqual([600, 700, 800])
380
+ expect(Math.max(...uygun.map(skor))).toBeCloseTo(skor(700), 6)
381
+ })
382
+ })
383
+
384
+ describe('#448 kaynak sözleşmesi — slider.tsx', () => {
385
+ it('track `accent` sınıfı `/20` alfasını KORUYOR (yedi varyantın ortak deseni)', () => {
386
+ expect(varyantSinifi(TRACK_BLOK, 'accent')).toBe('bg-accent/20')
387
+ })
388
+
389
+ it('EKSEN A: range `accent` light’ta -700, dark’ta -400', () => {
390
+ expect(varyantSinifi(RANGE_BLOK, 'accent')).toBe(
391
+ 'bg-[rgb(var(--secondary-700))] dark:bg-[rgb(var(--secondary-400))]'
392
+ )
393
+ })
394
+
395
+ it('EKSEN B: thumb `accent` kenarlığı light’ta -700, dark’ta -400', () => {
396
+ expect(varyantSinifi(THUMB_BLOK, 'accent')).toBe(
397
+ 'border-[rgb(var(--secondary-700))] dark:border-[rgb(var(--secondary-400))] bg-background'
398
+ )
399
+ })
400
+
401
+ it('KAPSAM KİLİDİ: diğer altı varyant DOKUNULMADI', () => {
402
+ // #424/#442 (warning/error) ve #389/#407 (secondary) kararları korunuyor;
403
+ // primary/success/default zaten eşiği geçiyor. Gereksiz değişiklik = regresyon riski.
404
+ expect(varyantSinifi(RANGE_BLOK, 'default')).toBe('bg-foreground')
405
+ expect(varyantSinifi(RANGE_BLOK, 'primary')).toBe('bg-primary')
406
+ expect(varyantSinifi(RANGE_BLOK, 'secondary')).toBe('bg-[rgb(var(--secondary-500))]')
407
+ expect(varyantSinifi(RANGE_BLOK, 'success')).toBe('bg-success')
408
+ expect(varyantSinifi(RANGE_BLOK, 'warning')).toBe(
409
+ 'bg-[rgb(var(--warning-700))] dark:bg-warning'
410
+ )
411
+ expect(varyantSinifi(RANGE_BLOK, 'error')).toBe('bg-[rgb(var(--error-700))] dark:bg-error')
412
+ })
413
+
414
+ it('zemin denkliği: `.moonui-theme` paket CSS’inde token yeniden tanımlamıyor', () => {
415
+ // Hesap, track'in/tutamacın altındaki yüzeyin --background/--card olduğunu
416
+ // VARSAYAR. Biri `.moonui-theme`i token kapsamına çevirirse o varsayım sessizce
417
+ // yanlışlanır — o an burası kırmızı olur (#389 deseni).
418
+ const stilDizini = path.join(KOK, 'styles')
419
+ const stiller = fs
420
+ .readdirSync(stilDizini)
421
+ .filter((f) => f.endsWith('.css'))
422
+ .map((f) => fs.readFileSync(path.join(stilDizini, f), 'utf8'))
423
+ .join('\n')
424
+ expect(stiller).not.toMatch(/\.moonui-theme[^{]*\{[^}]*--(accent|secondary|background|card)/)
425
+ })
426
+ })
427
+
428
+ describe('#448 render sözleşmesi — <Slider />', () => {
429
+ it('accent: track eski token’da, range yeni ifadeyle DOM’a iniyor', () => {
430
+ const { container } = render(
431
+ React.createElement(Slider, { trackVariant: 'accent', rangeVariant: 'accent' })
432
+ )
433
+ const { track, range } = parcalar(container)
434
+ expect(track).toHaveClass('bg-accent/20')
435
+ expect(range.className).toContain('bg-[rgb(var(--secondary-700))]')
436
+ expect(range.className).toContain('dark:bg-[rgb(var(--secondary-400))]')
437
+ // Yüzey token'ı artık dolguya UYGULANMAMALI (track'in aynısıydı).
438
+ expect(range.className).not.toMatch(/(?:^|\s)bg-accent(?:\s|$)/)
439
+ })
440
+
441
+ it('accent: thumb kenarlığı yeni ifadeyle iniyor, dolgusu bg-background kalıyor', () => {
442
+ const { container } = render(React.createElement(Slider, { thumbVariant: 'accent' }))
443
+ const { thumb } = parcalar(container)
444
+ expect(thumb.className).toContain('border-[rgb(var(--secondary-700))]')
445
+ expect(thumb.className).toContain('dark:border-[rgb(var(--secondary-400))]')
446
+ expect(thumb.className).toContain('bg-background')
447
+ expect(thumb.className).not.toMatch(/(?:^|\s)border-accent(?:\s|$)/)
448
+ })
449
+
450
+ it('thumbVariant düşüşü KORUNUYOR — tek prop ile track+range+thumb', () => {
451
+ // `trackVariant || thumbVariant || "default"` ve `rangeVariant || thumbVariant ||
452
+ // "primary"` düşüşleri değişmedi; en yaygın kullanım bu yoldan geçer.
453
+ const { container } = render(React.createElement(Slider, { thumbVariant: 'accent' }))
454
+ const { track, range, thumb } = parcalar(container)
455
+ expect(track).toHaveClass('bg-accent/20')
456
+ expect(range.className).toContain('bg-[rgb(var(--secondary-700))]')
457
+ expect(thumb.className).toContain('border-[rgb(var(--secondary-700))]')
458
+ })
459
+
460
+ it('rangeVariant açık verildiğinde thumbVariant’ı hâlâ eziyor', () => {
461
+ const { container } = render(
462
+ React.createElement(Slider, { thumbVariant: 'accent', rangeVariant: 'success' })
463
+ )
464
+ const { range, thumb } = parcalar(container)
465
+ expect(range.className).toContain('bg-success')
466
+ expect(range.className).not.toContain('--secondary-700')
467
+ // Eksenler bağımsız: tutamaç kendi varyantını korur.
468
+ expect(thumb.className).toContain('border-[rgb(var(--secondary-700))]')
469
+ })
470
+
471
+ it('diğer varyantlara SIZMADI — secondary hâlâ -500, primary hâlâ token’ında', () => {
472
+ const { container: c1 } = render(
473
+ React.createElement(Slider, { rangeVariant: 'secondary', thumbVariant: 'secondary' })
474
+ )
475
+ const p1 = parcalar(c1)
476
+ expect(p1.range.className).toContain('bg-[rgb(var(--secondary-500))]')
477
+ expect(p1.range.className).not.toContain('--secondary-700')
478
+ const { container: c2 } = render(
479
+ React.createElement(Slider, { rangeVariant: 'primary', thumbVariant: 'primary' })
480
+ )
481
+ const p2 = parcalar(c2)
482
+ expect(p2.range.className).toContain('bg-primary')
483
+ expect(p2.thumb.className).toContain('border-primary')
484
+ })
485
+ })
486
+
487
+ describe.each(['light', 'dark'] as const)('#448 %s tema — kabul kriteri ≥3:1 (WCAG 1.4.11)', (blok) => {
488
+ it('EKSEN A (range↔track): TÜM zeminlerde geçiyor', () => {
489
+ // Ölçülen: light 10.081 · dark 7.533 · gerçek docs kartı 6.717
490
+ const dolgu = rangeRengi('accent', blok)
491
+ for (const [ad, track] of trackler('accent', blok)) {
492
+ const oran = rgbKontrast(dolgu, track)
493
+ expect({ eksen: 'A', tema: blok, zemin: ad, gecti: oran >= 3.0 }).toEqual({
494
+ eksen: 'A',
495
+ tema: blok,
496
+ zemin: ad,
497
+ gecti: true,
498
+ })
499
+ }
500
+ })
501
+
502
+ it('EKSEN B (thumb kenarlığı↔zemin): TÜM zeminlerde geçiyor', () => {
503
+ // Ölçülen: light 10.355 · dark 7.857 · gerçek docs kartı 6.919
504
+ const kenar = thumbKenarRengi('accent', blok)
505
+ for (const [ad, zemin] of zeminler(blok)) {
506
+ const oran = rgbKontrast(kenar, zemin)
507
+ expect({ eksen: 'B', tema: blok, zemin: ad, gecti: oran >= 3.0 }).toEqual({
508
+ eksen: 'B',
509
+ tema: blok,
510
+ zemin: ad,
511
+ gecti: true,
512
+ })
513
+ }
514
+ })
515
+
516
+ it('ESKİ değer (`bg-accent`/`border-accent`) BU temada başarısızdı — bug’ın belgesi', () => {
517
+ // Düzeltmenin GEREKLİLİĞİNİ sabitler. warning/error'dan farkı: orada yalnız
518
+ // light kırıktı, burada İKİ tema da.
519
+ const eski = semantikRgb('accent', blok)
520
+ for (const [, track] of trackler('accent', blok)) {
521
+ expect(rgbKontrast(eski, track)).toBeLessThan(3.0)
522
+ }
523
+ for (const [, zemin] of zeminler(blok)) {
524
+ expect(rgbKontrast(eski, zemin)).toBeLessThan(3.0)
525
+ }
526
+ })
527
+
528
+ it('TEK RENK: tutamaç kenarlığı ile range dolgusu AYNI renk', () => {
529
+ // #407'nin gerekçesi: "tutamaç ve dolgu tek renkte okunur".
530
+ expect(thumbKenarRengi('accent', blok)).toEqual(rangeRengi('accent', blok))
531
+ })
532
+
533
+ it('KLON DEĞİL: accent, altı kardeş varyanttan da ayırt edilebiliyor (ΔE ≥ 10)', () => {
534
+ // Bu iş sırasında `--secondary-500`e eşlemenin neden REDDEDİLDİĞİNİN kilidi.
535
+ // Biri ileride accent'i secondary'nin kademesine çekerse burası kırmızı olur.
536
+ const dolgu = rangeRengi('accent', blok)
537
+ for (const [ad, renk] of kardesRenkleri(blok)) {
538
+ expect({ kardes: ad, ayirt: deltaE(dolgu, renk) >= DELTAE_ESIK }).toEqual({
539
+ kardes: ad,
540
+ ayirt: true,
541
+ })
542
+ }
543
+ })
544
+
545
+ it('HUE SADAKATİ: yeni dolgu, `--accent`in nötr slate ailesinde kaldı', () => {
546
+ // Marka pembesi/moru (`--brand-accent-*`, `--brand-*`) eşiği geçen alternatiflerdi
547
+ // ama accent'in hue'sunu (216°) terk ederlerdi. Nötr kalındığının kilidi:
548
+ // R/G/B kanalları arasındaki yayılım dar (doygunluk düşük).
549
+ const [r, g, b] = rangeRengi('accent', blok)
550
+ expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(60)
551
+ expect(b).toBeGreaterThan(r) // slate: maviye çalan nötr, `--accent` gibi
552
+ })
553
+ })