@moontra/moonui 6.16.0 → 6.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * #372 — Progress `variant="secondary"` dolgu↔track kontrast değişmezi.
3
+ *
4
+ * BULGU (#318'in ardılı): #318 `indicatorVariant ?? variant` düşüşünü kurunca
5
+ * `variant="secondary"` dolguya İLK KEZ ulaştı — ve ulaşan değer görünmez çıktı.
6
+ * Dolgu `bg-secondary`, track `bg-secondary/20`: ikisi de AYNI yüzey token'ına
7
+ * basıyor. `--secondary` light'ta rgb(235,239,245), dark'ta rgb(2,8,23) — %20
8
+ * alfa track ile tam dolgu iki temada da ~1.0 oranında, bar hiç görünmüyordu.
9
+ * axe bunu yakalamaz: `color-contrast` yalnız METİN öğelerini ölçer.
10
+ *
11
+ * DÜZELTME: dolgu `bg-[rgb(var(--secondary-500))]` (tokens.css slate skalası).
12
+ * `bg-secondary-foreground` BİLEREK seçilmedi — light'ta --primary ile aynı
13
+ * hue/sat'ta ΔL=%2, dark'ta birebir --primary değeri: varyant primary klonuna
14
+ * dönerdi. Track `bg-secondary/20` KALDI (issue #372 seçenek 1; track↔zemin
15
+ * ayrışması ayrı token-ekseni bulgusudur, kapsam dışı).
16
+ *
17
+ * Kabul kriteri (issue #372 §3): dolgu↔track kontrastı light ve dark temada
18
+ * ≥3:1 (WCAG 1.4.11 non-text eşiği). Buradaki testler hem bu eşiği hem de
19
+ * "500 iki temada da geçen TEK kademe" seçim gerekçesini kilitler.
20
+ *
21
+ * Hesap yöntemi primary-subtle-token.test.ts ile birebir aynıdır (WCAG 2.x
22
+ * bağıl parlaklık); oradaki "hesaplayıcı doğru mu?" çapası burada da var.
23
+ * NOT: #375'in dark-token-contrast.test.ts dosyasıyla bilerek AYRI dosya —
24
+ * o PR --destructive/--muted/--muted-foreground'u değiştirir, --secondary'ye
25
+ * ve --secondary-500 skalasına dokunmaz (diff'i teyit edildi, çakışma yok).
26
+ */
27
+ import * as React from 'react'
28
+ import { render, screen } from '@testing-library/react'
29
+ import '@testing-library/jest-dom'
30
+ import * as fs from 'fs'
31
+ import * as path from 'path'
32
+ import { Progress } from '../progress'
33
+
34
+ const KOK = path.resolve(__dirname, '../../..')
35
+ const tokens = fs.readFileSync(path.join(KOK, 'styles/tokens.css'), 'utf8')
36
+
37
+ /** "H S% L%" biçimindeki token değerini oku (light = :root, dark = .dark bloğu) */
38
+ function tokenOku(ad: string, blok: 'light' | 'dark'): string | null {
39
+ const kaynak =
40
+ blok === 'dark'
41
+ ? tokens.slice(tokens.indexOf('.dark'))
42
+ : tokens.slice(0, tokens.indexOf('.dark'))
43
+ return kaynak.match(new RegExp(`--${ad}:\\s*([^;]+);`))?.[1].trim() ?? null
44
+ }
45
+
46
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
47
+ h /= 360
48
+ s /= 100
49
+ l /= 100
50
+ const f = (n: number) => {
51
+ const k = (n + h * 12) % 12
52
+ const a = s * Math.min(l, 1 - l)
53
+ return l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)))
54
+ }
55
+ return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]
56
+ }
57
+
58
+ const hslTokenRgb = (v: string): [number, number, number] => {
59
+ const [h, s, l] = v.split(/\s+/).map((x) => parseFloat(x))
60
+ return hslToRgb(h, s, l)
61
+ }
62
+
63
+ /**
64
+ * Skala token'ları ("--secondary-500: 100 116 139") HSL DEĞİL, RGB üçlüsüdür
65
+ * (slate-500 = rgb(100,116,139) ile birebir; component'te bu yüzden
66
+ * `bg-[rgb(var(--secondary-500))]` kompozisyonu kullanılır, hsl() değil).
67
+ */
68
+ const rgbTokenRgb = (v: string): [number, number, number] => {
69
+ const p = v.split(/\s+/).map((x) => parseFloat(x))
70
+ return [p[0], p[1], p[2]]
71
+ }
72
+
73
+ function bagilParlaklik([r, g, b]: [number, number, number]): number {
74
+ const c = (v: number) => {
75
+ const x = v / 255
76
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)
77
+ }
78
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b)
79
+ }
80
+
81
+ function rgbKontrast(a: [number, number, number], b: [number, number, number]): number {
82
+ const la = bagilParlaklik(a)
83
+ const lb = bagilParlaklik(b)
84
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la]
85
+ return (hi + 0.05) / (lo + 0.05)
86
+ }
87
+
88
+ /** `bg-secondary/20` gibi alfa katmanının zemin üstündeki bileşke rengi */
89
+ function alfaBileske(
90
+ ust: [number, number, number],
91
+ alfa: number,
92
+ zemin: [number, number, number]
93
+ ): [number, number, number] {
94
+ return [
95
+ Math.round(alfa * ust[0] + (1 - alfa) * zemin[0]),
96
+ Math.round(alfa * ust[1] + (1 - alfa) * zemin[1]),
97
+ Math.round(alfa * ust[2] + (1 - alfa) * zemin[2]),
98
+ ]
99
+ }
100
+
101
+ /**
102
+ * Docs kabuğunda ölçülen GERÇEK koyu kart zemini (#111827 = rgb(17,24,39)) —
103
+ * #284/#368 konvansiyonu: kontrast, --background'ın yanında bu gerçek yüzeye
104
+ * göre de ölçülür (tokens.css --input notu).
105
+ */
106
+ const KART_GERCEK_DARK: [number, number, number] = [17, 24, 39]
107
+
108
+ /** Tema başına dolgu (secondary-500) ve track (secondary@%20 ∘ zemin) renkleri */
109
+ function temaRenkleri(blok: 'light' | 'dark') {
110
+ const dolgu = rgbTokenRgb(tokenOku('secondary-500', 'light')!) // skala yalnız :root'ta — iki temada aynı
111
+ const secondary = hslTokenRgb(tokenOku('secondary', blok)!)
112
+ const background = hslTokenRgb(tokenOku('background', blok)!)
113
+ const card = hslTokenRgb(tokenOku('card', blok)!)
114
+ const zeminler: Array<[string, [number, number, number]]> = [
115
+ ['--background', background],
116
+ ['--card', card],
117
+ ]
118
+ if (blok === 'dark') zeminler.push(['gerçek kart #111827', KART_GERCEK_DARK])
119
+ return { dolgu, secondary, zeminler }
120
+ }
121
+
122
+ describe('#372 progress secondary dolgu↔track kontrastı', () => {
123
+ it('hesaplayıcı DOĞRU — bilinen bir çift belgelenen oranı veriyor', () => {
124
+ // Sessiz-yeşil koruması: `--info-subtle` çifti tokens.css'te 9.52:1 diye
125
+ // belgelenmiş; hesaplayıcı bozulursa bu çapa kırmızı olur.
126
+ const oran = rgbKontrast(
127
+ hslTokenRgb(tokenOku('info-subtle', 'light')!),
128
+ hslTokenRgb(tokenOku('info-subtle-foreground', 'light')!)
129
+ )
130
+ expect(oran).toBeGreaterThan(9.3)
131
+ expect(oran).toBeLessThan(9.7)
132
+ })
133
+
134
+ it('--secondary-500 tanımlı ve RGB üçlüsü olarak ayrıştırılabiliyor', () => {
135
+ const ham = tokenOku('secondary-500', 'light')
136
+ expect(ham).toBeTruthy()
137
+ const rgb = rgbTokenRgb(ham!)
138
+ rgb.forEach((kanal) => {
139
+ expect(Number.isFinite(kanal)).toBe(true)
140
+ expect(kanal).toBeGreaterThanOrEqual(0)
141
+ expect(kanal).toBeLessThanOrEqual(255)
142
+ })
143
+ })
144
+
145
+ describe('kaynak sözleşmesi: dolgu ile track FARKLI token’lara basıyor', () => {
146
+ const kaynak = fs.readFileSync(path.join(KOK, 'components/ui/progress.tsx'), 'utf8')
147
+
148
+ it('track secondary sınıfı `bg-secondary/20` (issue seçenek 1 — değişmedi)', () => {
149
+ expect(kaynak).toMatch(/secondary:\s*"bg-secondary\/20"/)
150
+ })
151
+
152
+ it('dolgu secondary sınıfı `bg-[rgb(var(--secondary-500))]` (artık --secondary DEĞİL)', () => {
153
+ expect(kaynak).toMatch(/secondary:\s*"bg-\[rgb\(var\(--secondary-500\)\)\]"/)
154
+ })
155
+ })
156
+
157
+ describe('render sözleşmesi: <Progress variant="secondary" />', () => {
158
+ it('track ve dolgu sınıfları DOM’a farklı token’larla iniyor', () => {
159
+ render(React.createElement(Progress, { variant: 'secondary', value: 50 }))
160
+ const track = screen.getByRole('progressbar')
161
+ expect(track).toHaveClass('bg-secondary/20')
162
+ const dolgu = track.firstElementChild as HTMLElement
163
+ expect(dolgu).not.toBeNull()
164
+ expect(dolgu.className).toContain('bg-[rgb(var(--secondary-500))]')
165
+ expect(dolgu.className).not.toMatch(/(?:^|\s)bg-secondary(?:\s|$)/)
166
+ })
167
+
168
+ it('#318 düşüş mantığı KORUNUYOR — indicatorVariant hâlâ variant’ı ezer', () => {
169
+ render(React.createElement(Progress, { variant: 'secondary', indicatorVariant: 'success', value: 50 }))
170
+ const dolgu = screen.getByRole('progressbar').firstElementChild as HTMLElement
171
+ expect(dolgu.className).toContain('bg-success')
172
+ expect(dolgu.className).not.toContain('bg-[rgb(var(--secondary-500))]')
173
+ })
174
+ })
175
+
176
+ describe.each(['light', 'dark'] as const)('%s tema — kabul kriteri ≥3:1 (WCAG 1.4.11)', (blok) => {
177
+ it('YENİ dolgu (secondary-500), track’in her olası zemindeki bileşkesine ≥3:1', () => {
178
+ // Ölçülen değerler: light 4.63 (--background=--card=beyaz) · dark 4.23
179
+ // (--background=--card) / 3.83 (gerçek kart #111827).
180
+ const { dolgu, secondary, zeminler } = temaRenkleri(blok)
181
+ for (const [ad, zemin] of zeminler) {
182
+ const track = alfaBileske(secondary, 0.2, zemin)
183
+ const oran = rgbKontrast(dolgu, track)
184
+ expect({ zemin: ad, oran }).toEqual({ zemin: ad, oran: expect.any(Number) })
185
+ expect(oran).toBeGreaterThanOrEqual(3.0)
186
+ }
187
+ })
188
+
189
+ it('ESKİ dolgu (bg-secondary) aynı ölçümde başarısızdı — bug’ın belgesi', () => {
190
+ // Biri dolguyu `bg-secondary`ye geri döndürürse bu test ona "eski değer
191
+ // zaten görünmüyordu" diye değil, üstteki kriter testi kırmızıyla söyler;
192
+ // burası ise düzeltmenin GEREKLİLİĞİNİ sabitler (light 1.02, dark ~1.0-1.5).
193
+ const { secondary, zeminler } = temaRenkleri(blok)
194
+ for (const [, zemin] of zeminler) {
195
+ const track = alfaBileske(secondary, 0.2, zemin)
196
+ expect(rgbKontrast(secondary, track)).toBeLessThan(3.0)
197
+ }
198
+ })
199
+ })
200
+
201
+ describe('seçim gerekçesi: 500, iki temada birden geçen TEK skala kademesi', () => {
202
+ // Dolgu her iki temada AYNI renk olduğundan parlaklığı [~0.107, ~0.289]
203
+ // bandında kalmalı: daha açık kademe light track'e (near-white), daha koyu
204
+ // kademe dark track'e (near-black) yetişemiyor. Komşu kademeler bunu kanıtlar.
205
+ it('secondary-400 light temada eşiğin ALTINDA kalır (~2.50)', () => {
206
+ const k400 = rgbTokenRgb(tokenOku('secondary-400', 'light')!)
207
+ const { secondary, zeminler } = temaRenkleri('light')
208
+ const track = alfaBileske(secondary, 0.2, zeminler[0][1])
209
+ expect(rgbKontrast(k400, track)).toBeLessThan(3.0)
210
+ })
211
+
212
+ it('secondary-600 dark temada eşiğin ALTINDA kalır (~2.66)', () => {
213
+ const k600 = rgbTokenRgb(tokenOku('secondary-600', 'light')!)
214
+ const { secondary, zeminler } = temaRenkleri('dark')
215
+ const track = alfaBileske(secondary, 0.2, zeminler[0][1])
216
+ expect(rgbKontrast(k600, track)).toBeLessThan(3.0)
217
+ })
218
+
219
+ it('secondary-foreground kontrastı GEÇERDİ ama primary klonu üretirdi — kilit', () => {
220
+ // Bu test "neden secondary-foreground değil?" kararının dayanağını sabitler:
221
+ // light'ta --secondary-foreground ile --primary aynı hue/sat'ta ΔL=%2
222
+ // (fill↔fill 1.07), dark'ta birebir aynı değer. Varyant kimliği kaybolurdu.
223
+ const lightOran = rgbKontrast(
224
+ hslTokenRgb(tokenOku('secondary-foreground', 'light')!),
225
+ hslTokenRgb(tokenOku('primary', 'light')!)
226
+ )
227
+ expect(lightOran).toBeLessThan(1.3) // ayırt edilemez — near-identical
228
+ expect(tokenOku('secondary-foreground', 'dark')).toBe(tokenOku('primary', 'dark'))
229
+ })
230
+ })
231
+ })
@@ -218,7 +218,10 @@ describe('Progress Component', () => {
218
218
  render(<Progress indicatorVariant="secondary" />)
219
219
  const progress = screen.getByRole('progressbar')
220
220
  const indicator = progress.querySelector('div')
221
- expect(indicator).toHaveClass('bg-secondary')
221
+ // #372: dolgu artık `bg-secondary` DEĞİL — o yüzey token'ı track'in
222
+ // (`bg-secondary/20`) aynısıydı ve bar iki temada da görünmüyordu.
223
+ // Gerekçe + kontrast hesabı: progress-secondary-contrast.test.ts
224
+ expect(indicator).toHaveClass('bg-[rgb(var(--secondary-500))]')
222
225
  })
223
226
 
224
227
  it('renders success indicator variant correctly', () => {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * #354 — kontrast ölçümü viewport'tan BAĞIMSIZ olmalı.
3
+ *
4
+ * Issue'nun kabul kriteri "sabit viewport"ta ölçüm istiyordu. Ama mevcut tarayıcı
5
+ * araçlarıyla viewport DEĞİŞTİRİLEMEDİ: `resize_window` "başarılı" dönüyor,
6
+ * `innerWidth` 2751'de kalıyor, `outerWidth` 0 raporlanıyor.
7
+ *
8
+ * Bu yüzden risk ÖLÇÜLMEK yerine ORTADAN KALDIRILDI: eğer hiçbir kırılma noktası
9
+ * renk değiştirmiyorsa, contrast sonucu viewport'tan bağımsızdır ve tek bir
10
+ * genişlikte ölçmek yeterlidir.
11
+ *
12
+ * Tarama sonucu (2026-08-04): iki pakette 351 dosyada 199 responsive varyant
13
+ * (free 17 + pro 182); renk ailesinden olanların tamamı boyut/hiza değerleri.
14
+ * **Sıfır renk varyantı.**
15
+ *
16
+ * Bu test o değişmezi kilitler: biri `md:text-primary` gibi bir sınıf eklerse
17
+ * kırmızı olur ve "artık viewport başına ölçmek gerekiyor" uyarısı verir.
18
+ */
19
+ import * as fs from 'fs'
20
+ import * as path from 'path'
21
+
22
+ const KOKLER = [
23
+ path.resolve(__dirname, '../../..'), // packages/moonui/src
24
+ path.resolve(__dirname, '../../../../../moonui-pro/src'), // packages/moonui-pro/src
25
+ ]
26
+
27
+ /** Renk ailesinden yardımcı adları — boyut/hiza/spacing DEĞİL. */
28
+ const RENK_ONEKLERI = ['text', 'bg', 'border', 'from', 'via', 'to', 'ring', 'divide',
29
+ 'placeholder', 'decoration', 'caret', 'accent', 'outline', 'shadow']
30
+
31
+ /** `text-*` ailesinde renk OLMAYAN değerler (boyut, hiza, taşma). */
32
+ const RENK_OLMAYAN_TEXT = new Set([
33
+ 'xs','sm','base','lg','xl','2xl','3xl','4xl','5xl','6xl','7xl','8xl','9xl',
34
+ 'left','center','right','justify','start','end',
35
+ 'wrap','nowrap','balance','pretty','clip','ellipsis',
36
+ ])
37
+
38
+ function dosyalar(kok: string): string[] {
39
+ const cikti: string[] = []
40
+ if (!fs.existsSync(kok)) return cikti
41
+ ;(function walk(dir: string) {
42
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const p = path.join(dir, e.name)
44
+ if (e.isDirectory()) {
45
+ if (!/node_modules|__tests__|dist/.test(p)) walk(p)
46
+ } else if (/\.(tsx?|css)$/.test(e.name)) cikti.push(p)
47
+ }
48
+ })(kok)
49
+ return cikti
50
+ }
51
+
52
+ const tumDosyalar = KOKLER.flatMap(dosyalar)
53
+ const RESPONSIVE = /\b(sm|md|lg|xl|2xl):([a-z][a-z0-9-]*)(?:-([a-z0-9[\]#/.%-]+))?/g
54
+
55
+ describe('#354 responsive renk değişmezi', () => {
56
+ it('tarayıcı GERÇEKTEN responsive varyant buluyor (sessiz-yeşil koruması)', () => {
57
+ // Bu koruma olmadan, regex/yol bozulduğunda test "0 renk varyantı" ile YEŞİL
58
+ // geçerdi ve viewport riski sessizce açık kalırdı.
59
+ //
60
+ // Eşik, gerçek sayıya (2026-08-04'te 199) YAPIŞIK olmamalı: ölü dosya silmek
61
+ // gibi meşru işler sayıyı birkaç birim düşürür. İlk sürüm `> 200` idi ve o
62
+ // günkü değer 202'ydi; #365'te ölü `dashboard/demo.tsx` (3 varyant) silinince
63
+ // kırmızıya döndü — regex bozulmadan. Amaç "tarayıcı ölü mü" sorusunu
64
+ // yanıtlamak olduğundan büyüklük mertebesi yeterli.
65
+ let toplam = 0
66
+ for (const f of tumDosyalar) {
67
+ toplam += [...fs.readFileSync(f, 'utf8').matchAll(RESPONSIVE)].length
68
+ }
69
+ expect(toplam).toBeGreaterThan(100)
70
+ expect(tumDosyalar.length).toBeGreaterThan(100)
71
+ })
72
+
73
+ it('hiçbir kırılma noktası RENK değiştirmiyor', () => {
74
+ const ihlaller: string[] = []
75
+ for (const f of tumDosyalar) {
76
+ const src = fs.readFileSync(f, 'utf8')
77
+ for (const m of src.matchAll(RESPONSIVE)) {
78
+ const [tam, , yardimci, deger] = m
79
+ if (!RENK_ONEKLERI.includes(yardimci)) continue
80
+ if (!deger) continue
81
+ // `text-` ailesinde boyut/hiza değerleri renk değildir
82
+ if (yardimci === 'text' && RENK_OLMAYAN_TEXT.has(deger)) continue
83
+ // köşeli parantezli keyfi değerler: renk mi boyut mu? `#`/`rgb` varsa renk.
84
+ if (deger.startsWith('[') && !/#|rgb|hsl/.test(deger)) continue
85
+ ihlaller.push(`${path.basename(f)} → ${tam}`)
86
+ }
87
+ }
88
+ // Boş olmalı: aksi halde contrast ölçümü artık viewport'a bağımlıdır ve
89
+ // #354'ün "tek genişlikte ölçmek yeterli" gerekçesi düşer.
90
+ expect(ihlaller).toEqual([])
91
+ })
92
+ })
@@ -135,30 +135,81 @@ export function DataTableBasic<TData, TValue>({
135
135
  {table.getHeaderGroups().map((headerGroup) => (
136
136
  <TableRow key={headerGroup.id}>
137
137
  {headerGroup.headers.map((header) => {
138
+ /* #366: mevcut ikon guard'ı ile aynı koşul — `enableSorting`
139
+ kapalıyken başlık tamamen etkileşimsizdir (eskiden onClick
140
+ ve cursor-pointer yine de kalıyordu ama sortedRowModel
141
+ olmadığından hiçbir görünür etki üretmiyordu). */
142
+ const canSort = enableSorting && header.column.getCanSort();
143
+ const sorted = header.column.getIsSorted();
144
+ const headerDef = header.column.columnDef.header;
138
145
  return (
139
- <TableHead key={header.id}>
146
+ <TableHead
147
+ key={header.id}
148
+ /* #366: sıralama durumu ekran okuyucuya YANSIMIYORDU
149
+ (dosyada `aria-sort` grep'i 0 idi) — yalnız görsel ok
150
+ ikonu değişiyordu. Sıralanamayan sütunda öznitelik hiç
151
+ basılmaz. table.tsx'te #321, pro DataTable'da #291 ile
152
+ aynı standart. */
153
+ aria-sort={
154
+ canSort
155
+ ? sorted === "asc"
156
+ ? "ascending"
157
+ : sorted === "desc"
158
+ ? "descending"
159
+ : "none"
160
+ : undefined
161
+ }
162
+ >
140
163
  {header.isPlaceholder ? null : (
141
164
  <div
142
165
  className={cn(
143
166
  "flex items-center gap-2",
144
- header.column.getCanSort() && "cursor-pointer select-none"
167
+ canSort && "cursor-pointer select-none"
145
168
  )}
146
- onClick={header.column.getToggleSortingHandler()}
169
+ /* Fare kolaylığı: başlığın tamamı tıklanabilir kalır.
170
+ Klavye yolu aşağıdaki GERÇEK <button>'dan geçer —
171
+ bu div'e role="button" taklidi VERMİYORUZ çünkü
172
+ başlık içeriği tüketiciden gelir (flexRender +
173
+ `createSortableHeader` zaten Button render eder)
174
+ ve rol taklidi buton-içinde-buton üretirdi; aynı
175
+ hata pro DataTable'da #345'te düzeltilmişti. */
176
+ onClick={
177
+ canSort
178
+ ? header.column.getToggleSortingHandler()
179
+ : undefined
180
+ }
147
181
  >
148
182
  {flexRender(
149
183
  header.column.columnDef.header,
150
184
  header.getContext()
151
185
  )}
152
- {enableSorting && header.column.getCanSort() && (
153
- <div className="ml-auto">
154
- {header.column.getIsSorted() === "desc" ? (
186
+ {canSort && (
187
+ /* #366: sıralama kontrolü yalnız fareyle çalışıyordu
188
+ (dosyada `tabIndex`/`onKeyDown` grep'i 0 idi).
189
+ Gerçek <button> ile Tab + Enter/Space native
190
+ gelir; görünür odak focus-visible ring ile. */
191
+ <button
192
+ type="button"
193
+ onClick={(event) => {
194
+ // Sarmalayıcı div de sıralıyor; çifte tetiklenmesin
195
+ event.stopPropagation();
196
+ header.column.getToggleSortingHandler()?.(event);
197
+ }}
198
+ aria-label={`Sort by ${
199
+ typeof headerDef === "string"
200
+ ? headerDef
201
+ : header.column.id
202
+ }`}
203
+ className="ml-auto inline-flex items-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
204
+ >
205
+ {sorted === "desc" ? (
155
206
  <ArrowDown className="h-4 w-4" />
156
- ) : header.column.getIsSorted() === "asc" ? (
207
+ ) : sorted === "asc" ? (
157
208
  <ArrowUp className="h-4 w-4" />
158
209
  ) : (
159
210
  <ArrowUpDown className="h-4 w-4 opacity-50" />
160
211
  )}
161
- </div>
212
+ </button>
162
213
  )}
163
214
  </div>
164
215
  )}
@@ -81,6 +81,35 @@ const formatFileSize = (bytes: number): string => {
81
81
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
82
82
  }
83
83
 
84
+ /**
85
+ * Tek bir accept token'ını HTML `input[accept]` semantiğiyle dosyayla eşler (#378):
86
+ * - Uzantı token'ı (`.pdf`) → dosya ADI son ekiyle (harf duyarsız)
87
+ * - Wildcard token'ı (`image/*`) → MIME type prefix'iyle
88
+ * - Düz MIME token'ı (`application/pdf`) → MIME type ile birebir
89
+ *
90
+ * Eski mantık uzantı token'larını da `File.type` (MIME) ile karşılaştırıyordu;
91
+ * `.pdf` hiçbir MIME değerine eşit olamayacağı için `accept=".pdf"` altında
92
+ * batch komple reddediliyordu.
93
+ */
94
+ const matchesAcceptToken = (file: File, token: string): boolean => {
95
+ const normalized = token.trim().toLowerCase()
96
+ if (!normalized) return false
97
+ if (normalized === '*' || normalized === '*/*') return true
98
+ if (normalized.startsWith('.')) {
99
+ return file.name.toLowerCase().endsWith(normalized)
100
+ }
101
+ if (normalized.endsWith('/*')) {
102
+ return file.type.toLowerCase().startsWith(normalized.slice(0, -1))
103
+ }
104
+ return file.type.toLowerCase() === normalized
105
+ }
106
+
107
+ const isFileAccepted = (file: File, accept: string): boolean => {
108
+ const trimmed = accept.trim()
109
+ if (trimmed === '' || trimmed === '*') return true
110
+ return trimmed.split(',').some(token => matchesAcceptToken(file, token))
111
+ }
112
+
84
113
  const FileItem = ({
85
114
  uploadedFile,
86
115
  onRemove
@@ -188,13 +217,7 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
188
217
  return `File size exceeds ${formatFileSize(maxSize)}`
189
218
  }
190
219
 
191
- if (accept !== "*" && !accept.split(',').some(type => {
192
- const cleanType = type.trim()
193
- if (cleanType.includes('*')) {
194
- return file.type.startsWith(cleanType.replace('*', ''))
195
- }
196
- return file.type === cleanType
197
- })) {
220
+ if (!isFileAccepted(file, accept)) {
198
221
  return `File type not supported`
199
222
  }
200
223
 
@@ -241,6 +264,11 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
241
264
  setUploadedFiles(prev => [...prev, ...newUploadedFiles])
242
265
 
243
266
  // Simulate upload progress
267
+ // Timer'lar toplanır ki onUpload reddederse simülasyon durdurulabilsin;
268
+ // eskiden interval'ler yaşamaya devam ediyor ve son tick, catch'in yazdığı
269
+ // 'error' state'ini ezip 'success'/"Done" gösteriyordu (#378).
270
+ const progressIntervals = new Map<string, ReturnType<typeof setInterval>>()
271
+
244
272
  for (const uploadedFile of newUploadedFiles) {
245
273
  let progress = 0
246
274
  const interval = setInterval(() => {
@@ -248,24 +276,28 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
248
276
  if (progress >= 100) {
249
277
  progress = 100
250
278
  clearInterval(interval)
251
-
252
- setUploadedFiles(prev =>
253
- prev.map(f =>
254
- f.id === uploadedFile.id
279
+ progressIntervals.delete(uploadedFile.id)
280
+
281
+ setUploadedFiles(prev =>
282
+ prev.map(f =>
283
+ // 'error'a düşmüş dosya simülasyonca geri 'success' yapılamaz —
284
+ // error nihai durumdur (#378)
285
+ f.id === uploadedFile.id && f.status === 'uploading'
255
286
  ? { ...f, progress: 100, status: 'success' as const }
256
287
  : f
257
288
  )
258
289
  )
259
290
  } else {
260
- setUploadedFiles(prev =>
261
- prev.map(f =>
262
- f.id === uploadedFile.id
291
+ setUploadedFiles(prev =>
292
+ prev.map(f =>
293
+ f.id === uploadedFile.id && f.status === 'uploading'
263
294
  ? { ...f, progress }
264
295
  : f
265
296
  )
266
297
  )
267
298
  }
268
299
  }, 200)
300
+ progressIntervals.set(uploadedFile.id, interval)
269
301
  }
270
302
 
271
303
  // Call onUpload if provided
@@ -277,8 +309,11 @@ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
277
309
  await onUpload(newUploadedFiles)
278
310
  } catch (error) {
279
311
  console.error('Upload failed:', error)
280
- setUploadedFiles(prev =>
281
- prev.map(f =>
312
+ // Reddetme anında bu batch'in simülasyon timer'ları temizlenir (#378)
313
+ progressIntervals.forEach(handle => clearInterval(handle))
314
+ progressIntervals.clear()
315
+ setUploadedFiles(prev =>
316
+ prev.map(f =>
282
317
  newUploadedFiles.some(nf => nf.id === f.id)
283
318
  ? { ...f, status: 'error' as const }
284
319
  : f
@@ -69,7 +69,20 @@ const progressIndicatorVariants = cva(
69
69
  variant: {
70
70
  default: "bg-foreground",
71
71
  primary: "bg-primary",
72
- secondary: "bg-secondary",
72
+ // #372: dolgu `bg-secondary` idi — track (`bg-secondary/20`) ile AYNI
73
+ // yüzey token'ına basıyordu; %20 alfa track ile tam dolgu iki temada da
74
+ // ~1.0 oranında kalıyor, bar görünmüyordu (light rgb(235,239,245) /
75
+ // dark rgb(2,8,23)). `bg-secondary-foreground` BİLEREK seçilmedi:
76
+ // light'ta --primary ile aynı hue/sat'ta yalnız ΔL=%2 (49 vs 51,
77
+ // fill↔fill 1.07), dark'ta birebir --primary değeri (210 40% 98%) —
78
+ // secondary varyantı primary'nin klonuna dönerdi. `--secondary-500`
79
+ // (tokens.css slate skalası, RGB üçlüsü) iki temada da track'e ≥3:1
80
+ // (WCAG 1.4.11) veren TEK kademe: light 4.63, dark 4.23 (gerçek kart
81
+ // #111827 üstünde 3.83; 400 light'ta 2.50 ✗, 600 dark'ta 2.66 ✗).
82
+ // Skala :root'ta tanımlı olduğundan tema değişiminde sabittir;
83
+ // tüketici --secondary-500'ü ezerek özelleştirebilir.
84
+ // Regresyon: __tests__/progress-secondary-contrast.test.ts
85
+ secondary: "bg-[rgb(var(--secondary-500))]",
73
86
  success: "bg-success",
74
87
  warning: "bg-warning",
75
88
  error: "bg-error",
@@ -199,6 +199,17 @@
199
199
  --info-subtle: 214 100% 97%;
200
200
  --info-subtle-foreground: 224 64% 33%; /* 9.52:1 */
201
201
 
202
+ /* #354: `primary` ailesi bu listede EKSİKTİ. Pro Sidebar'ın aktif öğesi
203
+ "primary metin + primary/10 zemin" deseni kullanıyordu; light temada bu
204
+ 4.28:1 veriyor (gereken 4.5) — yani docs kabuğu her sayfada bir WCAG AA
205
+ ihlali basıyordu. Dark tarafta `--primary` neredeyse beyaz olduğu için
206
+ sorun yoktu (15.73:1, ölçüldü).
207
+
208
+ Zemin, bugünkü `primary/10` bileşiminin BİREBİR HSL karşılığı (beyaz üstüne
209
+ %10 primary = #e7f0fe) → görünüm DEĞİŞMEZ, yalnız metin koyulaşır. */
210
+ --primary-subtle: 217 92% 95%;
211
+ --primary-subtle-foreground: 217.2 91.2% 40%; /* 6.21:1 */
212
+
202
213
  --primary-50: 240 249 255;
203
214
 
204
215
  /* Tailwind preset'i rounded-lg/md/sm'yi var(--radius)'a bagliyor.
@@ -445,15 +456,42 @@
445
456
  --card-foreground: 213 31% 91%;
446
457
  --popover: 224 71% 4%;
447
458
  --popover-foreground: 213 31% 91%;
459
+ /* #368 rol denetimi: --primary koyu temada BİLİNÇLİ near-white — esas rolü
460
+ ZEMİN (`bg-primary text-primary-foreground` butonu 19.81 taşır, ölçüldü).
461
+ --foreground (91%) ile yalnız 1.19 verdiği için "aktif/vurgulu" ayrımı
462
+ koyu temada RENKLE taşınamaz; stepper/form-wizard bu ayrımı dolgu + Check
463
+ ikonu + font-semibold ile veriyor (stepper.tsx:323-330). Vurgu METNİ
464
+ gereken yerde --primary-subtle(-foreground) çifti kullanılır (#354).
465
+ Değeri maviye çekmek, paketi kullanan TÜM projelerin koyu tema butonlarını
466
+ sürüm yükseltmesinde sessizce değiştirirdi → değer DEĞİŞTİRİLMEDİ. */
448
467
  --primary: 210 40% 98%;
449
468
  --primary-foreground: 222.2 47.4% 1.2%;
450
469
  --secondary: 222.2 84% 4.9%;
451
470
  --secondary-foreground: 210 40% 98%;
452
- --muted: 223 47% 11%;
453
- --muted-foreground: 215.4 16.3% 56.9%;
471
+ /* #368: --muted DEKORATİF YÜZEY rolünde (Skeleton `bg-muted`, kbd, tabs).
472
+ 11%'te gerçek kart zemininden (#111827, bkz. --input notu) ayrışmıyordu:
473
+ 1.01 — skeleton barları koyu temada fiilen GÖRÜNMÜYORDU. Aynı hue/sat
474
+ korunarak yalnız L 11→22 (rgb(15,22,41)→rgb(30,45,82), aynı rengin 2×
475
+ açığı): kart üstünde 1.31, --background üstünde 1.49 — yüzey-yüzey ayrımı
476
+ için ≥1.3 pratik alt sınırı geçer. */
477
+ --muted: 223 47% 22%;
478
+ /* #368: --muted 22% olunca 56.9% metin `bg-muted` üstünde 4.06'ya düşüyordu
479
+ (AA 4.5 altı — #284'ün açık temada onardığı çiftin birebir aynısı).
480
+ 62% ile: muted üstünde 4.80, kart (#111827) üstünde 6.29, --background
481
+ üstünde 7.15 — tümü AA üstü. */
482
+ --muted-foreground: 215.4 16.3% 62%;
454
483
  --accent: 216 34% 17%;
455
484
  --accent-foreground: 210 40% 98%;
456
- --destructive: 0 63% 31%;
485
+ /* #368: --destructive, --success ile AYNI çift-rol tuzağındaydı: açık temada
486
+ `bg-destructive` ZEMİN, koyu temada `text-destructive` METİN (free 7 + pro
487
+ 36 dosya). 31% metin olarak okunmuyordu: gerçek kart zemini (#111827)
488
+ üstünde 1.79 (AA 4.5 ister). 0 84% 60% (= :root --error; koyu tema o
489
+ değeri zaten miras alıyor) metin rolünde kart üstünde 4.69, --background
490
+ üstünde 5.33 taşır; hata kenarlıkları da 1.4.11 eşiğini geçer (3.0 ister,
491
+ ölçülen 4.69 — eskiden 1.80 idi). Trade-off: dolgu rolünde beyaz
492
+ foreground ile 3.61'e iner (büyük metin/UI eşiği 3.0 üstü) — --success'in
493
+ koyu temadaki kararının aynısı: metin rolü önceliklidir. */
494
+ --destructive: 0 84% 60%;
457
495
  /* `--success` iki rolde birden kullanılıyor: açık temada `bg-success` ZEMİN
458
496
  (üstünde açık metin), karanlık temada `text-success` METİN (koyu zemin
459
497
  üstünde). Açık tema için 29%'e koyulaştırıldı; karanlık temada o değer
@@ -474,6 +512,12 @@
474
512
  --warning-subtle-foreground: 34 100% 92%; /* 14.43:1 */
475
513
  --info-subtle: 217 80% 13%;
476
514
  --info-subtle-foreground: 214 95% 93%; /* 14.11:1 */
515
+
516
+ /* #354: koyu temada mevcut görünüm KORUNUYOR — bugünkü bileşimin birebir
517
+ karşılığı (near-white/10 near-black üstüne). Dark zaten geçiyordu; buradaki
518
+ amaç token ailesini tamamlamak, dark'ın rengini değiştirmek değil. */
519
+ --primary-subtle: 225 18% 13%;
520
+ --primary-subtle-foreground: 210 40% 98%; /* 15.73:1 */
477
521
  --border: 216 34% 17%;
478
522
  /* #284: koyu temada form kontrolü kenarlığı 1.36 idi -> 40% ile ~3.1.
479
523
  Not: --input switch.tsx:27'de kapalı track ZEMİNİ olarak da kullanılıyor;
@@ -76,6 +76,13 @@ module.exports = {
76
76
  primary: {
77
77
  DEFAULT: "hsl(var(--primary) / <alpha-value>)",
78
78
  foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
79
+ // #354: `primary` yumuşak-ton yüzeyi — `bg-primary-subtle` +
80
+ // `text-primary-subtle-foreground`. Aile destructive/success/warning/
81
+ // info için vardı, primary EKSİKTİ ve pro Sidebar'ın aktif öğesi bu
82
+ // yüzden ham `primary/10` kompozisyonuna düşüp light temada 4.28:1
83
+ // veriyordu.
84
+ subtle: "hsl(var(--primary-subtle) / <alpha-value>)",
85
+ "subtle-foreground": "hsl(var(--primary-subtle-foreground) / <alpha-value>)",
79
86
  },
80
87
  secondary: {
81
88
  DEFAULT: "hsl(var(--secondary) / <alpha-value>)",