@moontra/moonui 6.24.0 → 6.25.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.
Files changed (56) hide show
  1. package/dist/cdn/moonui.css +2 -2
  2. package/dist/cdn/moonui.esm.js +12 -12
  3. package/dist/cdn/moonui.global.js +12 -12
  4. package/dist/index.d.mts +24 -6
  5. package/dist/index.d.ts +24 -6
  6. package/dist/index.js +417 -87
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +418 -89
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/issue-544-kontrast.test.tsx +153 -145
  12. package/src/__tests__/issue-645-overlay-tab-focus.test.tsx +262 -0
  13. package/src/__tests__/issue-646-dark-scope-override.test.tsx +297 -0
  14. package/src/__tests__/issue-661-a11y.test.tsx +113 -0
  15. package/src/__tests__/issue-661-token-kontrast.test.tsx +82 -0
  16. package/src/components/ui/__tests__/accent-foreground-contrast.test.ts +197 -0
  17. package/src/components/ui/__tests__/accordion.test.tsx +6 -4
  18. package/src/components/ui/__tests__/alert.test.tsx +4 -1
  19. package/src/components/ui/__tests__/button.test.tsx +6 -1
  20. package/src/components/ui/__tests__/dark-token-contrast.test.ts +18 -18
  21. package/src/components/ui/__tests__/heading-level-policy.test.tsx +4 -4
  22. package/src/components/ui/__tests__/issue-643-card-token-parite.test.tsx +113 -0
  23. package/src/components/ui/__tests__/issue-649-button-aschild.test.tsx +199 -0
  24. package/src/components/ui/__tests__/issue-664-separator-dekoratif-rol.test.tsx +41 -0
  25. package/src/components/ui/__tests__/preset-color-shadowing.test.ts +10 -10
  26. package/src/components/ui/__tests__/primary-subtle-token.test.ts +7 -7
  27. package/src/components/ui/__tests__/progress-secondary-contrast.test.ts +38 -38
  28. package/src/components/ui/__tests__/progress-warning-error-contrast.test.ts +64 -64
  29. package/src/components/ui/__tests__/quality-group-de.test.tsx +2 -2
  30. package/src/components/ui/__tests__/responsive-color-invariance.test.ts +20 -20
  31. package/src/components/ui/__tests__/select.test.tsx +6 -1
  32. package/src/components/ui/__tests__/semantic-token-usage.test.ts +23 -23
  33. package/src/components/ui/__tests__/size-scale-convention.test.ts +10 -10
  34. package/src/components/ui/__tests__/slider-accent-contrast.test.ts +142 -136
  35. package/src/components/ui/__tests__/slider-secondary-contrast.test.ts +60 -60
  36. package/src/components/ui/__tests__/slider-warning-error-contrast.test.ts +100 -100
  37. package/src/components/ui/__tests__/switch-variant-contrast.test.tsx +103 -103
  38. package/src/components/ui/__tests__/tooltip.test.tsx +122 -0
  39. package/src/components/ui/accordion.tsx +16 -3
  40. package/src/components/ui/alert.tsx +1 -1
  41. package/src/components/ui/badge.tsx +12 -1
  42. package/src/components/ui/button.tsx +76 -18
  43. package/src/components/ui/card.tsx +59 -5
  44. package/src/components/ui/dropdown-menu.tsx +99 -5
  45. package/src/components/ui/file-upload.tsx +4 -0
  46. package/src/components/ui/index.ts +4 -1
  47. package/src/components/ui/popover-pro.tsx +41 -0
  48. package/src/components/ui/popover.tsx +162 -7
  49. package/src/components/ui/rating.tsx +24 -2
  50. package/src/components/ui/select.tsx +4 -4
  51. package/src/components/ui/simple-editor.tsx +3 -18
  52. package/src/components/ui/tabs.tsx +16 -1
  53. package/src/components/ui/textarea.tsx +6 -1
  54. package/src/components/ui/toast.tsx +17 -2
  55. package/src/components/ui/tooltip.tsx +154 -2
  56. package/src/styles/tokens.css +34 -2
@@ -53,12 +53,12 @@ type RGB = [number, number, number]
53
53
  type Blok = 'light' | 'dark'
54
54
 
55
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 =
56
+ function tokenOku(name: string, blok: Blok): string | null {
57
+ const source =
58
58
  blok === 'dark'
59
59
  ? tokens.slice(tokens.indexOf('.dark'))
60
60
  : tokens.slice(0, tokens.indexOf('.dark'))
61
- return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
61
+ return source.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1].trim() ?? null
62
62
  }
63
63
 
64
64
  function hslToRgb(h: number, s: number, l: number): RGB {
@@ -85,10 +85,10 @@ const rgbTokenRgb = (v: string): RGB => {
85
85
  }
86
86
 
87
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')!)
88
+ const semanticRgb = (name: string, blok: Blok): RGB =>
89
+ hslTokenRgb(tokenOku(name, blok) ?? tokenOku(name, 'light')!)
90
90
 
91
- function bagilParlaklik([r, g, b]: RGB): number {
91
+ function relativeLuminance([r, g, b]: RGB): number {
92
92
  const c = (v: number) => {
93
93
  const x = v / 255
94
94
  return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
@@ -97,8 +97,8 @@ function bagilParlaklik([r, g, b]: RGB): number {
97
97
  }
98
98
 
99
99
  function kontrast(a: RGB, b: RGB): number {
100
- const la = bagilParlaklik(a)
101
- const lb = bagilParlaklik(b)
100
+ const la = relativeLuminance(a)
101
+ const lb = relativeLuminance(b)
102
102
  const [hi, lo] = la > lb ? [la, lb] : [lb, la]
103
103
  return (hi + 0.05) / (lo + 0.05)
104
104
  }
@@ -130,61 +130,61 @@ function deltaE(a: RGB, b: RGB): number {
130
130
  }
131
131
 
132
132
  /** Docs kabuğunda ölçülen GERÇEK koyu kart zemini (#284/#368/#448 konvansiyonu) */
133
- const KART_GERCEK_DARK: RGB = [17, 24, 39]
133
+ const CARD_ACTUAL_DARK: RGB = [17, 24, 39]
134
134
 
135
- function zeminler(blok: Blok): Array<[string, RGB]> {
135
+ function backgrounds(blok: Blok): Array<[string, RGB]> {
136
136
  const z: Array<[string, RGB]> = [
137
- ['--background', semantikRgb('background', blok)],
138
- ['--card', semantikRgb('card', blok)],
137
+ ['--background', semanticRgb('background', blok)],
138
+ ['--card', semanticRgb('card', blok)],
139
139
  ]
140
- if (blok === 'dark') z.push(['gerçek kart #111827', KART_GERCEK_DARK])
140
+ if (blok === 'dark') z.push(['gerçek kart #111827', CARD_ACTUAL_DARK])
141
141
  return z
142
142
  }
143
143
 
144
144
  // ——— Renkler KAYNAKTAN türetilir (PR #440/#446/#453 dersi) ——————————————
145
- const kaynak = fs.readFileSync(path.join(KOK, 'components/ui/switch.tsx'), 'utf8')
145
+ const source = fs.readFileSync(path.join(KOK, 'components/ui/switch.tsx'), 'utf8')
146
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')
147
+ const stripComments = source.replace(/^[ \t]*\/\/.*$/gm, '')
148
+ const TRACK_CVA = stripComments.slice(
149
+ stripComments.indexOf('const switchVariants'),
150
+ stripComments.indexOf('const switchThumbVariants')
151
151
  )
152
- const THUMB_CVA = yorumsuz.slice(
153
- yorumsuz.indexOf('const switchThumbVariants'),
154
- yorumsuz.indexOf('export interface SwitchProps')
152
+ const THUMB_CVA = stripComments.slice(
153
+ stripComments.indexOf('const switchThumbVariants'),
154
+ stripComments.indexOf('export interface SwitchProps')
155
155
  )
156
156
 
157
- const VARYANTLAR = ['primary', 'success', 'warning', 'destructive', 'secondary'] as const
158
- type Varyant = (typeof VARYANTLAR)[number]
157
+ const VARIANTS = ['primary', 'success', 'warning', 'destructive', 'secondary'] as const
158
+ type Varyant = (typeof VARIANTS)[number]
159
159
 
160
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ı`)
161
+ function variantClass(variantVal: string): string {
162
+ const m = TRACK_CVA.match(new RegExp(`\\n\\s*${variantVal}:\\s*"([^"]+)"`))
163
+ if (!m) throw new Error(`${variantVal} varyantı switchVariants içinde bulunamadı`)
164
164
  return m[1]
165
165
  }
166
166
 
167
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}`)
168
+ function classColor(cls: string, blok: Blok): RGB {
169
+ const scale = cls.match(/^bg-\[rgb\(var\(--([a-z0-9-]+)\)\)\]$/)
170
+ if (scale) return rgbTokenRgb(tokenOku(scale[1], 'light')!) // skala YALNIZ light blokta
171
+ const semantic = cls.match(/^bg-([a-z-]+)$/)
172
+ if (semantic) return semanticRgb(semantic[1], blok)
173
+ throw new Error(`çözülemeyen sınıf: ${cls}`)
174
174
  }
175
175
 
176
176
  /** checked track'in ilgili temadaki rengi — `dark:` önekli sınıf dark'ta kazanır */
177
- function checkedTrackRengi(varyant: string, blok: Blok): RGB {
177
+ function checkedTrackRengi(variantVal: string, blok: Blok): RGB {
178
178
  const KOSUL = 'data-[state=checked]:'
179
- const adaylar = varyantSinifi(varyant)
179
+ const adaylar = variantClass(variantVal)
180
180
  .split(/\s+/)
181
181
  .filter(Boolean)
182
182
  .filter((s) => s.replace(/^dark:/, '').startsWith(KOSUL))
183
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)
184
+ const darkClass = adaylar.find((s) => s.startsWith('dark:'))
185
+ const lightClass = adaylar.find((s) => !s.startsWith('dark:'))
186
+ if (!lightClass) throw new Error(`${variantVal}: temel (light) checked sınıfı yok`)
187
+ return classColor(soy(blok === 'dark' && darkClass ? darkClass : lightClass), blok)
188
188
  }
189
189
 
190
190
  /** Thumb'ın dolgusu — A ve B eksenlerinin AYNI sayıyı vermesinin sebebi */
@@ -192,24 +192,24 @@ function thumbRengi(blok: Blok): RGB {
192
192
  const m = THUMB_CVA.match(/"([^"]*\bbg-[a-z-]+\b[^"]*)"/)
193
193
  if (!m) throw new Error('thumb taban sınıfı okunamadı')
194
194
  const bg = m[1].split(/\s+/).find((s) => s.startsWith('bg-'))!
195
- return sinifRengi(bg, blok)
195
+ return classColor(bg, blok)
196
196
  }
197
197
 
198
198
  const KADEMELER = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
199
- const ESIK = 3.0
200
- const DELTAE_ESIK = 10
199
+ const THRESHOLD = 3.0
200
+ const DELTAE_THRESHOLD = 10
201
201
 
202
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
203
+ const passesAxes = (c: RGB, blok: Blok): boolean =>
204
+ backgrounds(blok).every(([, z]) => kontrast(c, z) >= THRESHOLD) &&
205
+ kontrast(thumbRengi(blok), c) >= THRESHOLD
206
206
 
207
207
  describe('#454 — hesaplayıcı çapaları', () => {
208
208
  it('kontrast hesaplayıcısı DOĞRU — belgelenmiş token çifti beklenen oranı veriyor', () => {
209
209
  // tokens.css `--info-subtle` / `--info-subtle-foreground` için 9.5:1 iddia ediyor
210
210
  const oran = kontrast(
211
- semantikRgb('info-subtle', 'light'),
212
- semantikRgb('info-subtle-foreground', 'light')
211
+ semanticRgb('info-subtle', 'light'),
212
+ semanticRgb('info-subtle-foreground', 'light')
213
213
  )
214
214
  expect(oran).toBeGreaterThan(9.3)
215
215
  expect(oran).toBeLessThan(9.7)
@@ -222,13 +222,13 @@ describe('#454 — hesaplayıcı çapaları', () => {
222
222
 
223
223
  it('hesaplayıcı, issue #454 gövdesindeki ESKİ ölçümleri birebir üretiyor', () => {
224
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))
225
+ const old = (blok: Blok) =>
226
+ backgrounds(blok).map(([, z]) =>
227
+ Number(kontrast(semanticRgb('accent', blok), z).toFixed(3))
228
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)
229
+ expect(old('light')[0]).toBe(1.154)
230
+ expect(old('dark')[0]).toBe(1.359)
231
+ expect(old('dark')[2]).toBe(1.197)
232
232
  })
233
233
  })
234
234
 
@@ -238,7 +238,7 @@ describe('#454 — iki eksenin AYNI olmasının yapısal sebebi', () => {
238
238
  // "tek ölçüm iki ekseni kapatır" varsayımı sessizce geçersizleşir.
239
239
  expect(THUMB_CVA).toContain('bg-background')
240
240
  for (const blok of ['light', 'dark'] as const) {
241
- expect(thumbRengi(blok)).toEqual(semantikRgb('background', blok))
241
+ expect(thumbRengi(blok)).toEqual(semanticRgb('background', blok))
242
242
  }
243
243
  })
244
244
 
@@ -252,17 +252,17 @@ describe('#454 — iki eksenin AYNI olmasının yapısal sebebi', () => {
252
252
  })
253
253
 
254
254
  describe('#454 — KABUL KRİTERİ: her varyantın checked track’i iki eksende ≥3:1', () => {
255
- for (const varyant of VARYANTLAR) {
255
+ for (const variantVal of VARIANTS) {
256
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)) {
257
+ it(`${variantVal} / ${blok}`, () => {
258
+ const c = checkedTrackRengi(variantVal, blok)
259
+ for (const [name, z] of backgrounds(blok)) {
260
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`
261
+ expect(`${name}: ${kontrast(c, z) >= THRESHOLD ? 'geçer' : kontrast(c, z).toFixed(3)}`).toBe(
262
+ `${name}: geçer`
263
263
  )
264
264
  }
265
- expect(kontrast(thumbRengi(blok), c)).toBeGreaterThanOrEqual(ESIK)
265
+ expect(kontrast(thumbRengi(blok), c)).toBeGreaterThanOrEqual(THRESHOLD)
266
266
  })
267
267
  }
268
268
  }
@@ -270,26 +270,26 @@ describe('#454 — KABUL KRİTERİ: her varyantın checked track’i iki eksende
270
270
 
271
271
  describe('#454 — klon filtresi: varyantlar birbirinden ayırt edilebilir', () => {
272
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]}`
273
+ it(`${blok} — tüm varyant çiftleri ΔE*ab ≥ ${DELTAE_THRESHOLD}`, () => {
274
+ for (let i = 0; i < VARIANTS.length; i++) {
275
+ for (let j = i + 1; j < VARIANTS.length; j++) {
276
+ const pair = `${VARIANTS[i]}↔${VARIANTS[j]}`
277
277
  const d = deltaE(
278
- checkedTrackRengi(VARYANTLAR[i], blok),
279
- checkedTrackRengi(VARYANTLAR[j], blok)
278
+ checkedTrackRengi(VARIANTS[i], blok),
279
+ checkedTrackRengi(VARIANTS[j], blok)
280
280
  )
281
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`)
282
+ expect(`${pair}: ${d >= DELTAE_THRESHOLD ? 'ayrık' : d.toFixed(1)}`).toBe(`${pair}: ayrık`)
283
283
  }
284
284
  }
285
285
  })
286
286
 
287
287
  it(`${blok} — checked track, UNCHECKED track’ten (bg-input) ayırt edilebilir`, () => {
288
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
289
+ const unchecked = semanticRgb('input', blok)
290
+ for (const variantVal of VARIANTS) {
291
+ expect(deltaE(checkedTrackRengi(variantVal, blok), unchecked)).toBeGreaterThanOrEqual(
292
+ DELTAE_THRESHOLD
293
293
  )
294
294
  }
295
295
  })
@@ -299,8 +299,8 @@ describe('#454 — klon filtresi: varyantlar birbirinden ayırt edilebilir', ()
299
299
  describe('#454 — `secondary` kararı: ad ↔ token tutarsızlığı ve reddedilen alternatifler', () => {
300
300
  it('ESKİ hâli (`bg-accent`) üç zeminde de eşiğin ALTINDA — kusur gerçek', () => {
301
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)
302
+ for (const [, z] of backgrounds(blok)) {
303
+ expect(kontrast(semanticRgb('accent', blok), z)).toBeLessThan(THRESHOLD)
304
304
  }
305
305
  }
306
306
  })
@@ -308,8 +308,8 @@ describe('#454 — `secondary` kararı: ad ↔ token tutarsızlığı ve reddedi
308
308
  it('SEMANTİK `bg-secondary` ELENDİ — light’ta `--accent` ile BİREBİR aynı, dark’ta daha kötü', () => {
309
309
  // "Adı secondary ise bg-secondary kullansın" en bariz alternatif; ölçümle elendi.
310
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)
311
+ expect(kontrast(semanticRgb('secondary', 'light'), semanticRgb('background', 'light'))).toBeLessThan(THRESHOLD)
312
+ expect(kontrast(semanticRgb('secondary', 'dark'), semanticRgb('background', 'dark'))).toBeLessThan(1.05)
313
313
  })
314
314
 
315
315
  it('`bg-secondary-foreground` ELENDİ — dark’ta `--primary` ile BİREBİR aynı (primary klonu)', () => {
@@ -317,30 +317,30 @@ describe('#454 — `secondary` kararı: ad ↔ token tutarsızlığı ve reddedi
317
317
  })
318
318
 
319
319
  it('slate skalasında iki temayı birden geçen VE klon olmayan TEK kademe 500', () => {
320
- const uygun = KADEMELER.filter((k) => {
320
+ const suitable = KADEMELER.filter((k) => {
321
321
  const ham = tokenOku(`secondary-${k}`, 'light')
322
322
  if (!ham) return false
323
323
  const c = rgbTokenRgb(ham)
324
324
  return (['light', 'dark'] as const).every(
325
325
  (blok) =>
326
- eksenleriGecer(c, blok) &&
326
+ passesAxes(c, blok) &&
327
327
  (['primary', 'success', 'warning', 'destructive'] as Varyant[]).every(
328
- (v) => deltaE(c, checkedTrackRengi(v, blok)) >= DELTAE_ESIK
328
+ (v) => deltaE(c, checkedTrackRengi(v, blok)) >= DELTAE_THRESHOLD
329
329
  )
330
330
  )
331
331
  })
332
- expect(uygun).toEqual([500])
332
+ expect(suitable).toEqual([500])
333
333
  })
334
334
 
335
335
  it('#448’in slider’daki 500-elemesi BURADA GEÇERSİZ — switch’in `accent` varyantı YOK', () => {
336
336
  // #453 ölçümü: `--secondary-500` ↔ slider'ın `secondary` varyantı ΔE = 0.0 (klon).
337
337
  // Switch'te o çakışma yok; kaynak varyant listesi bunu kanıtlar.
338
- expect(VARYANTLAR).not.toContain('accent')
338
+ expect(VARIANTS).not.toContain('accent')
339
339
  expect(TRACK_CVA).not.toMatch(/\n\s*accent:/)
340
340
  const k500 = rgbTokenRgb(tokenOku('secondary-500', 'light')!)
341
341
  for (const blok of ['light', 'dark'] as const) {
342
342
  for (const v of ['primary', 'success', 'warning', 'destructive'] as Varyant[]) {
343
- expect(deltaE(k500, checkedTrackRengi(v, blok))).toBeGreaterThanOrEqual(DELTAE_ESIK)
343
+ expect(deltaE(k500, checkedTrackRengi(v, blok))).toBeGreaterThanOrEqual(DELTAE_THRESHOLD)
344
344
  }
345
345
  }
346
346
  })
@@ -361,26 +361,26 @@ describe('#454 — `warning` kardeş kusuru: kök neden ve çözüm şekli', ()
361
361
  })
362
362
 
363
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)
364
+ expect(kontrast(semanticRgb('warning', 'light'), semanticRgb('background', 'light'))).toBeLessThan(THRESHOLD)
365
+ expect(kontrast(semanticRgb('warning', 'dark'), semanticRgb('background', 'dark'))).toBeGreaterThan(THRESHOLD)
366
366
  })
367
367
 
368
368
  it('tema-KÖR bir warning kademesi seçilebilirdi ama DARK’ı gereksizce oynatırdı', () => {
369
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) => {
370
+ const themeBlind = KADEMELER.filter((k) => {
371
371
  const ham = tokenOku(`warning-${k}`, 'light')
372
- return ham && (['light', 'dark'] as const).every((b) => eksenleriGecer(rgbTokenRgb(ham), b))
372
+ return ham && (['light', 'dark'] as const).every((b) => passesAxes(rgbTokenRgb(ham), b))
373
373
  })
374
- expect(temaKor).toEqual([700])
375
- expect(checkedTrackRengi('warning', 'dark')).toEqual(semantikRgb('warning', 'dark'))
374
+ expect(themeBlind).toEqual([700])
375
+ expect(checkedTrackRengi('warning', 'dark')).toEqual(semanticRgb('warning', 'dark'))
376
376
  })
377
377
 
378
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) => {
379
+ const passedCount = KADEMELER.filter((k) => {
380
380
  const ham = tokenOku(`warning-${k}`, 'light')
381
- return ham && eksenleriGecer(rgbTokenRgb(ham), 'light')
381
+ return ham && passesAxes(rgbTokenRgb(ham), 'light')
382
382
  })
383
- expect(gecen).toEqual([700, 800, 900, 950])
383
+ expect(passedCount).toEqual([700, 800, 900, 950])
384
384
  expect(checkedTrackRengi('warning', 'light')).toEqual(
385
385
  rgbTokenRgb(tokenOku('warning-700', 'light')!)
386
386
  )
@@ -389,34 +389,34 @@ describe('#454 — `warning` kardeş kusuru: kök neden ve çözüm şekli', ()
389
389
  it('DARK render BİT-BİT AYNI kaldı (warning + dokunulmayan varyantlar)', () => {
390
390
  for (const v of ['primary', 'success', 'warning', 'destructive'] as Varyant[]) {
391
391
  const tok = v === 'destructive' ? 'error' : v
392
- expect(checkedTrackRengi(v, 'dark')).toEqual(semantikRgb(tok, 'dark'))
392
+ expect(checkedTrackRengi(v, 'dark')).toEqual(semanticRgb(tok, 'dark'))
393
393
  }
394
394
  })
395
395
  })
396
396
 
397
397
  describe('#454 — kaynak sözleşmesi (switch.tsx)', () => {
398
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))]')
399
+ expect(variantClass('secondary')).toBe('data-[state=checked]:bg-[rgb(var(--secondary-500))]')
400
400
  })
401
401
 
402
402
  it('`warning` → light -700, dark semantik token', () => {
403
- expect(varyantSinifi('warning')).toBe(
403
+ expect(variantClass('warning')).toBe(
404
404
  'data-[state=checked]:bg-[rgb(var(--warning-700))] dark:data-[state=checked]:bg-warning'
405
405
  )
406
406
  })
407
407
 
408
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')
409
+ expect(variantClass('primary')).toBe('data-[state=checked]:bg-primary')
410
+ expect(variantClass('success')).toBe('data-[state=checked]:bg-success')
411
+ expect(variantClass('destructive')).toBe('data-[state=checked]:bg-error')
412
412
  })
413
413
 
414
414
  it('KAPSAM KİLİDİ: unchecked track ve prop yüzeyi DEĞİŞMEDİ', () => {
415
415
  expect(TRACK_CVA).toContain('data-[state=unchecked]:bg-input')
416
- expect(kaynak).toContain(
416
+ expect(source).toContain(
417
417
  'type SwitchVariant = "primary" | "success" | "warning" | "destructive" | "secondary";'
418
418
  )
419
- expect(kaynak).toContain('export { Switch, switchVariants }')
419
+ expect(source).toContain('export { Switch, switchVariants }')
420
420
  })
421
421
 
422
422
  it('`bg-secondary-500` YAZILMADI — preset secondary skalasını hâlâ açmıyor (#307 ölü-sınıf tuzağı)', () => {
@@ -453,31 +453,31 @@ describe('#454 — kaynak sözleşmesi (switch.tsx)', () => {
453
453
  })
454
454
 
455
455
  describe('#454 — render sözleşmesi (<Switch />)', () => {
456
- const sinif = (jsx: React.ReactElement) => {
456
+ const cls = (jsx: React.ReactElement) => {
457
457
  const { container } = render(jsx)
458
458
  return (container.querySelector('[role="switch"]') as HTMLElement).className
459
459
  }
460
460
 
461
461
  it('checked `secondary` switch slate-500 sınıfını taşıyor', () => {
462
- expect(sinif(<Switch variant="secondary" defaultChecked />)).toContain(
462
+ expect(cls(<Switch variant="secondary" defaultChecked />)).toContain(
463
463
  'data-[state=checked]:bg-[rgb(var(--secondary-500))]'
464
464
  )
465
465
  })
466
466
 
467
467
  it('checked `secondary` switch ARTIK `bg-accent` taşımıyor', () => {
468
- expect(sinif(<Switch variant="secondary" defaultChecked />)).not.toContain(
468
+ expect(cls(<Switch variant="secondary" defaultChecked />)).not.toContain(
469
469
  'data-[state=checked]:bg-accent'
470
470
  )
471
471
  })
472
472
 
473
473
  it('checked `warning` switch light -700 + dark semantik sınıflarını birlikte taşıyor', () => {
474
- const c = sinif(<Switch variant="warning" defaultChecked />)
474
+ const c = cls(<Switch variant="warning" defaultChecked />)
475
475
  expect(c).toContain('data-[state=checked]:bg-[rgb(var(--warning-700))]')
476
476
  expect(c).toContain('dark:data-[state=checked]:bg-warning')
477
477
  })
478
478
 
479
479
  it('varsayılan varyant (primary) ve unchecked davranışı korunuyor', () => {
480
- const c = sinif(<Switch />)
480
+ const c = cls(<Switch />)
481
481
  expect(c).toContain('data-[state=checked]:bg-primary')
482
482
  expect(c).toContain('data-[state=unchecked]:bg-input')
483
483
  })
@@ -482,6 +482,128 @@ describe('Tooltip Components', () => {
482
482
  })
483
483
  })
484
484
 
485
+ // #650 — WCAG 1.4.13 "Content on Hover or Focus"
486
+ //
487
+ // Radix `TooltipContentImpl` KOŞULSUZ bir "kaydırma → kapat" etkisi kurar:
488
+ // window.addEventListener("scroll", e => {
489
+ // if (e.target?.contains(trigger)) onClose()
490
+ // }, { capture: true })
491
+ //
492
+ // Görünür alanın dışındaki bir tetikleyiciye `Tab` ile gelindiğinde tarayıcı
493
+ // elemanı görünür alana kaydırır → tooltip açıldığı anda kapanır. Gerçek
494
+ // tarayıcıda ölçülen sıra (docs sayfası, 365 gerçek `Tab` basışı):
495
+ // FOCUSIN → data-state=instant-open (+4.5 ms) → SCROLL → closed (+6.4 ms)
496
+ //
497
+ // Aşağıdaki testler o zincirin tamamını jsdom'da yeniden üretir: gerçek odak
498
+ // (userEvent.tab → jsdom `focus`+`focusin`) + tetikleyiciyi İÇEREN bir hedefte
499
+ // kaydırma olayı. Düzeltme geri alınırsa ilk test kırmızıya döner.
500
+ describe('#650 Klavye Odağı Kalıcılığı (WCAG 1.4.13)', () => {
501
+ // Tetikleyiciyi kapsayan bir hedefte kaydırma üret. Radix dinleyicisi
502
+ // `window` üzerinde YAKALAMA fazındadır; `document`e gönderilen olay
503
+ // oraya `target === document` ile ulaşır ve `document.contains(trigger)`
504
+ // doğru olduğu için kapatma tetiklenir.
505
+ const scrollTriggerIntoView = () => {
506
+ fireEvent.scroll(document)
507
+ }
508
+
509
+ it('klavye odağı tooltip"i açar', async () => {
510
+ const user = userEvent.setup()
511
+ render(
512
+ <Tooltip content="Keyboard reachable" delayDuration={0}>
513
+ <button>Focus me</button>
514
+ </Tooltip>
515
+ )
516
+
517
+ await user.tab()
518
+ expect(screen.getByRole('button')).toHaveFocus()
519
+
520
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Keyboard reachable')
521
+ })
522
+
523
+ it('odak kaydırması tooltip"i KAPATMAZ (asıl #650 gerilemesi)', async () => {
524
+ const user = userEvent.setup()
525
+ render(
526
+ <Tooltip content="Keyboard reachable" delayDuration={0}>
527
+ <button>Focus me</button>
528
+ </Tooltip>
529
+ )
530
+
531
+ await user.tab()
532
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument()
533
+
534
+ // Tarayıcının odaklanan elemanı görünür alana taşıması
535
+ scrollTriggerIntoView()
536
+ scrollTriggerIntoView() // yumuşak kaydırma birden çok olay üretir
537
+
538
+ // Odak hâlâ tetikleyicide → içerik görünür KALMALI ("Persistent")
539
+ await waitFor(() => {
540
+ expect(screen.getByRole('tooltip')).toBeInTheDocument()
541
+ })
542
+ expect(screen.getByRole('button')).toHaveFocus()
543
+ })
544
+
545
+ it('Escape klavye odağındaki tooltip"i kapatır ("Dismissible")', async () => {
546
+ const user = userEvent.setup()
547
+ render(
548
+ <Tooltip content="Keyboard reachable" delayDuration={0}>
549
+ <button>Focus me</button>
550
+ </Tooltip>
551
+ )
552
+
553
+ await user.tab()
554
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument()
555
+
556
+ await user.keyboard('{Escape}')
557
+
558
+ await waitFor(() => {
559
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
560
+ })
561
+ // Odak tetikleyicide kalır — Escape yalnız içeriği kapatır
562
+ expect(screen.getByRole('button')).toHaveFocus()
563
+ })
564
+
565
+ it('odak ayrıldığında tooltip yine kapanır (kilit sızmıyor)', async () => {
566
+ const user = userEvent.setup()
567
+ render(
568
+ <div>
569
+ <Tooltip content="Keyboard reachable" delayDuration={0}>
570
+ <button>Focus me</button>
571
+ </Tooltip>
572
+ <button>Next</button>
573
+ </div>
574
+ )
575
+
576
+ await user.tab()
577
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument()
578
+
579
+ await user.tab() // sonraki tetiklenebilir elemana geç
580
+ expect(screen.getByRole('button', { name: 'Next' })).toHaveFocus()
581
+
582
+ await waitFor(() => {
583
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
584
+ })
585
+ })
586
+
587
+ it('FARE ile açılan tooltip kaydırmada kapanmaya devam eder (kilit klavyeye özgü)', async () => {
588
+ const user = userEvent.setup()
589
+ render(
590
+ <Tooltip content="Pointer opened" delayDuration={0}>
591
+ <button>Hover me</button>
592
+ </Tooltip>
593
+ )
594
+
595
+ await openByHover(user, screen.getByRole('button'))
596
+ expect(screen.getByRole('tooltip')).toBeInTheDocument()
597
+
598
+ // Odak hiç oluşmadı → kilit yok → Radix'in kaydırma davranışı korunur
599
+ scrollTriggerIntoView()
600
+
601
+ await waitFor(() => {
602
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
603
+ })
604
+ })
605
+ })
606
+
485
607
  describe('Controlled State', () => {
486
608
  it('renders open when defaultOpen is set and reports changes', async () => {
487
609
  const onOpenChange = jest.fn()
@@ -20,7 +20,12 @@ const AccordionItem = React.forwardRef<
20
20
  >(({ className, ...props }, ref) => (
21
21
  <AccordionPrimitive.Item
22
22
  ref={ref}
23
- className={cn("moonui-theme", "border-b border-gray-200 dark:border-gray-800", className)}
23
+ // #661-17: ham `border-gray-200 dark:border-gray-800` → `border-border`.
24
+ // Pro ikizi (accordion.tsx:21-23) zaten `border-border` kullanıyor; free
25
+ // taraf ham palette kalınca tema/marka özelleştirmesi bu kenarlığa
26
+ // İŞLEMİYORDU. Token değerleri görsel olarak denk: --border açık temada
27
+ // 214.3 31.8% 91.4% (≈ gray-200), koyu temada 216 34% 17% (≈ gray-800).
28
+ className={cn("moonui-theme", "border-b border-border", className)}
24
29
  {...props}
25
30
  />
26
31
  ))
@@ -34,7 +39,9 @@ const AccordionTrigger = React.forwardRef<
34
39
  <AccordionPrimitive.Trigger
35
40
  ref={ref}
36
41
  className={cn(
37
- "flex flex-1 items-center justify-between py-4 font-medium transition-all dark:text-gray-100",
42
+ // #661-17: `dark:text-gray-100` KALDIRILDI — tetikleyici artık iki temada
43
+ // da `--foreground`u miras alır (koyu temada 213 31% 91% ≈ gray-100).
44
+ "flex flex-1 items-center justify-between py-4 font-medium transition-all",
38
45
  "hover:underline hover:text-primary",
39
46
  "[&[data-state=open]>svg]:rotate-180",
40
47
  className
@@ -55,7 +62,13 @@ const AccordionContent = React.forwardRef<
55
62
  <AccordionPrimitive.Content
56
63
  ref={ref}
57
64
  className={cn(
58
- "overflow-hidden text-sm text-gray-700 dark:text-gray-300 transition-all",
65
+ // #661-17: ham `text-gray-700 dark:text-gray-300` → `text-foreground/80`.
66
+ // Ölçüldü (iki tema, kart zemini): açık 10.30 → 11.48, koyu 12.19 → 9.44.
67
+ // İkisi de AA'nın (4.5) çok üstünde, yani eşik geçilmiyor — `#545 ihlal
68
+ // takası` sınıfı bir gerileme YOK. `text-muted-foreground` bilerek
69
+ // SEÇİLMEDİ: açık temada 4.76'ya inip gövde metnini görünür biçimde
70
+ // soldururdu (§9 estetik değişiklik olurdu).
71
+ "overflow-hidden text-sm text-foreground/80 transition-all",
59
72
  "data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
60
73
  className
61
74
  )}
@@ -21,7 +21,7 @@ const alertVariants = cva(
21
21
  default: "bg-background text-foreground border-border",
22
22
  primary: "bg-primary/10 text-primary border-primary/30",
23
23
  success: "bg-success/10 text-success border-success/30",
24
- warning: "bg-warning/10 text-warning border-warning/30",
24
+ warning: "bg-warning/10 text-warning-subtle-foreground border-warning/30",
25
25
  error: "bg-destructive/10 text-destructive border-destructive/30",
26
26
  // `--info` token'ı (tokens.css) blue-500 ile birebir aynı değeri
27
27
  // taşır → görsel değişiklik yok, ancak varyant artık temaya ve
@@ -25,11 +25,22 @@ const badgeVariants = cva(
25
25
  // aynı varyant pro pakette token'lıydı → free ve pro `primary` için
26
26
  // farklı renk üretiyordu. Artık token'a bağlı (tema/karanlık moda duyarlı).
27
27
  // `primary` VARSAYILAN varyant olduğu için görünür bir renk değişimidir.
28
+ // #646: `dark:bg-primary/90` KALDIRILDI. `primary` VARSAYILAN varyant olduğu
29
+ // için varyant verilmeyen HER `<Badge>` bu sınıfı alıyordu; `tailwind-merge`
30
+ // `dark:` önekli arka planı tüketicinin ÖNEKSİZ `bg-*` sınıfıyla aynı grupta
31
+ // görmediğinden ikisi birden hayatta kalıyor ve koyu temada `dark:` özgüllükle
32
+ // kazanıyordu. Sonuç: `<Badge className="bg-green-500/20 text-green-300">`
33
+ // koyu temada near-white zemin + açık yeşil metin = 1.10:1 (ölçüldü,
34
+ // magnetic-button "Pending"; swipe-actions'ta 6 rozet 1.10–1.48 aralığında).
35
+ // ÖNEKSİZ `bg-primary` bırakıldı: tüketici sınıfıyla AYNI grupta çakışır ve
36
+ // doğru şekilde ezilir. Rozetin kendi görünümü BOZULMAZ — koyu temada
37
+ // `bg-primary` + `text-primary-foreground` 16.16 → 19.80'e çıkar
38
+ // (yarı saydamlık kalktığı için); açık temada değişiklik yok.
28
39
  primary: [
29
40
  "border-transparent bg-primary text-primary-foreground",
30
41
  "hover:bg-primary/90",
31
42
  "focus-visible:ring-primary/30 dark:focus-visible:ring-primary/40",
32
- "dark:bg-primary/90 dark:shadow-inner dark:shadow-primary/10",
43
+ "dark:shadow-inner dark:shadow-primary/10",
33
44
  ],
34
45
  secondary: [
35
46
  "border-transparent bg-secondary text-secondary-foreground",