free-coding-models 0.5.32 → 0.5.35

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 (36) hide show
  1. package/LICENSE +43 -21
  2. package/README.md +2 -1
  3. package/changelog/v0.5.33.md +4 -0
  4. package/changelog/v0.5.34.md +4 -0
  5. package/changelog/v0.5.35.md +4 -0
  6. package/package.json +3 -2
  7. package/sources.js +12 -52
  8. package/src/core/router-daemon.js +56 -49
  9. package/src/core/schema-normalizer.js +172 -0
  10. package/src/core/tool-bootstrap.js +11 -0
  11. package/src/core/tool-launchers.js +37 -2
  12. package/src/core/tool-metadata.js +3 -0
  13. package/web/dist/assets/index-B5tTa7_O.js +40 -0
  14. package/web/dist/assets/index-r8niexgJ.css +1 -0
  15. package/web/dist/index.html +2 -2
  16. package/web/src/App.jsx +93 -105
  17. package/web/src/components/changelog/ChangelogView.module.css +13 -14
  18. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  19. package/web/src/components/dashboard/FilterBar.module.css +68 -21
  20. package/web/src/components/dashboard/ModelTable.jsx +118 -42
  21. package/web/src/components/dashboard/ModelTable.module.css +82 -27
  22. package/web/src/components/help/HelpView.module.css +13 -14
  23. package/web/src/components/install/InstallEndpointsView.module.css +13 -13
  24. package/web/src/components/installed/InstalledModelsView.module.css +13 -13
  25. package/web/src/components/layout/Header.jsx +28 -28
  26. package/web/src/components/layout/Header.module.css +56 -30
  27. package/web/src/components/palette/CommandPalette.jsx +14 -14
  28. package/web/src/components/playground/PlaygroundView.module.css +13 -14
  29. package/web/src/components/recommend/RecommendView.module.css +13 -11
  30. package/web/src/components/router/RouterView.module.css +13 -13
  31. package/web/src/components/tools/ToolPicker.module.css +27 -14
  32. package/web/src/global.css +58 -31
  33. package/web/src/hooks/urlState.constants.js +1 -1
  34. package/web/src/hooks/useUrlState.js +19 -3
  35. package/web/dist/assets/index-C_tCF0A5.js +0 -40
  36. package/web/dist/assets/index-CsFt3qt5.css +0 -1
@@ -4,28 +4,27 @@
4
4
  */
5
5
 
6
6
  .backdrop {
7
- position: fixed;
8
- inset: 0;
9
- background: rgba(0, 0, 0, 0.55);
10
- backdrop-filter: blur(4px);
11
- -webkit-backdrop-filter: blur(4px);
12
- z-index: 500;
13
7
  display: flex;
14
- align-items: center;
15
- justify-content: center;
16
- padding: 5vh 4vw;
8
+ flex-direction: column;
9
+ flex: 1;
10
+ min-height: 0;
11
+ width: 100%;
12
+ padding: 24px;
17
13
  }
18
14
 
19
15
  .modal {
20
- width: min(720px, 95vw);
21
- max-height: 88vh;
22
- background: var(--color-bg-elevated);
23
- border: 1px solid var(--color-border-hover);
16
+ width: 100%;
17
+ max-width: 720px;
18
+ margin: 0 auto;
19
+ background: var(--color-bg-card, #080908);
20
+ border: 1px solid var(--color-border, #161a16);
24
21
  border-radius: 14px;
25
- box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
22
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
26
23
  display: flex;
27
24
  flex-direction: column;
28
25
  overflow: hidden;
26
+ flex: 1;
27
+ min-height: 0;
29
28
  }
30
29
 
31
30
  .header {
@@ -9,7 +9,7 @@
9
9
  * — click the trigger to expand the chip row, click again or outside to close.
10
10
  */
11
11
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
12
- import { IconRefresh, IconX, IconFilter, IconChevronDown } from '@tabler/icons-react'
12
+ import { IconRefresh, IconX, IconFilter, IconChevronDown, IconSearch } from '@tabler/icons-react'
13
13
  import { getToolMeta } from '../../../../src/core/tool-metadata.js'
14
14
  import ProviderDropdown from './ProviderDropdown.jsx'
15
15
  import styles from './FilterBar.module.css'
@@ -158,7 +158,7 @@ export default function FilterBar({
158
158
  filterHealth, setFilterHealth,
159
159
  visibilityMode, setVisibilityMode,
160
160
  customTextFilter, setCustomTextFilter,
161
- searchQuery,
161
+ searchQuery, onSearchChange,
162
162
  onResetView,
163
163
  providers,
164
164
  pingMode, setPingMode,
@@ -254,6 +254,21 @@ export default function FilterBar({
254
254
  />
255
255
  </div>
256
256
 
257
+ <div className={styles.group}>
258
+ <div className={styles.searchBar}>
259
+ <span className={styles.searchIcon}><IconSearch size={13} stroke={1.5} /></span>
260
+ <input
261
+ type="text"
262
+ className={styles.searchInput}
263
+ placeholder="Search models, providers..."
264
+ value={searchQuery || ''}
265
+ onChange={(e) => onSearchChange?.(e.target.value)}
266
+ autoComplete="off"
267
+ aria-label="Search models"
268
+ />
269
+ </div>
270
+ </div>
271
+
257
272
  <div className={styles.group}>
258
273
  <label className={styles.filterLabel}>Endpoint target</label>
259
274
  <div className={styles.toolStatus} title="Active endpoint install target from the Header picker">
@@ -2,12 +2,19 @@
2
2
  * @file web/src/components/dashboard/FilterBar.module.css
3
3
  * @description Filter bar styles — M1 parity chips (tier, status, verdict, health,
4
4
  * visibility, provider, custom text filter, reset, ping mode).
5
- * 📖 Sticky below the header (`top: 60px` = header height) so the filter chips
6
- * 📖 stay visible while the user scrolls a long table.
5
+ * 📖 The bar is a STATIC header above the model table (flex-shrink:0). It used
6
+ * 📖 to be `position: sticky; top: 60px` for the old whole-page-scroll layout,
7
+ * 📖 but the dashboard now scrolls the table INTERNALLY (main.dashboardView is
8
+ * 📖 the bounded box, .scrollInner scrolls). With main as the scroll container,
9
+ * 📖 `top: 60px` stuck the bar 60px DOWN inside main, dropping it over the
10
+ * 📖 table's green <thead>. Since the bar lives OUTSIDE the internal scroller,
11
+ * 📖 it is naturally always visible — no sticky needed. Keep z-index modest so
12
+ * 📖 it never fights the sticky table header (z-index: 10) if they ever meet.
7
13
  */
8
14
 
9
15
  .filters {
10
- position: sticky; top: 60px; z-index: 99;
16
+ position: relative; z-index: 5;
17
+ flex-shrink: 0;
11
18
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
12
19
  padding: 10px 20px;
13
20
  background: var(--color-bg-card);
@@ -19,7 +26,7 @@
19
26
  .group { display: flex; align-items: center; gap: 6px; }
20
27
 
21
28
  .filterLabel {
22
- font-size: 10px; font-weight: 700; color: var(--color-text-muted);
29
+ font-size: 12px; font-weight: 700; color: var(--color-text-muted);
23
30
  text-transform: uppercase; letter-spacing: 0.5px;
24
31
  white-space: nowrap;
25
32
  }
@@ -31,7 +38,7 @@
31
38
 
32
39
  .filterTrigger {
33
40
  display: inline-flex; align-items: center; gap: 4px;
34
- font-size: 11px; font-family: var(--font-mono);
41
+ font-size: 13px; font-family: var(--font-mono);
35
42
  padding: 4px 8px; border-radius: 5px;
36
43
  border: 1px solid var(--color-border);
37
44
  background: var(--color-surface);
@@ -59,7 +66,7 @@
59
66
  }
60
67
 
61
68
  .filterTriggerLabel {
62
- font-size: 10px; font-weight: 700;
69
+ font-size: 11px; font-weight: 700;
63
70
  text-transform: uppercase; letter-spacing: 0.5px;
64
71
  color: var(--color-text-muted);
65
72
  }
@@ -103,7 +110,7 @@
103
110
  }
104
111
 
105
112
  .filterChip {
106
- font-size: 11px; font-weight: 600; font-family: var(--font-mono);
113
+ font-size: 13px; font-weight: 600; font-family: var(--font-mono);
107
114
  padding: 4px 9px;
108
115
  border: 1px solid var(--color-border);
109
116
  background: var(--color-bg-card);
@@ -136,7 +143,7 @@
136
143
  .select, .providerSelect {
137
144
  background: var(--color-surface); color: var(--color-text);
138
145
  border: 1px solid var(--color-border); border-radius: 5px;
139
- padding: 4px 8px; font-size: 11px; font-family: var(--font-sans);
146
+ padding: 4px 8px; font-size: 13px; font-family: var(--font-sans);
140
147
  cursor: pointer; outline: none;
141
148
  min-width: 130px;
142
149
  }
@@ -151,11 +158,11 @@
151
158
  border-radius: 5px;
152
159
  background: var(--color-surface);
153
160
  color: var(--color-text);
154
- font-size: 11px;
161
+ font-size: 13px;
155
162
  white-space: nowrap;
156
163
  }
157
164
  .toolStatus strong {
158
- font-size: 11px;
165
+ font-size: 13px;
159
166
  font-weight: 800;
160
167
  }
161
168
 
@@ -164,7 +171,7 @@
164
171
 
165
172
  .resetBtn {
166
173
  display: inline-flex; align-items: center; gap: 5px;
167
- font-size: 11px; font-weight: 700; font-family: var(--font-mono);
174
+ font-size: 13px; font-weight: 700; font-family: var(--font-mono);
168
175
  padding: 4px 9px; border-radius: 5px;
169
176
  border: 1px solid var(--color-border-hover);
170
177
  background: var(--color-accent-dim);
@@ -178,7 +185,7 @@
178
185
  .resetBadge {
179
186
  display: inline-flex; align-items: center; justify-content: center;
180
187
  min-width: 16px; height: 16px;
181
- font-size: 9px; font-weight: 800;
188
+ font-size: 11px; font-weight: 800;
182
189
  background: var(--color-bg);
183
190
  color: var(--color-accent);
184
191
  border-radius: 999px;
@@ -188,7 +195,7 @@
188
195
  /* ── Custom text filter chip (TUI Ctrl+P "Apply text filter") ── */
189
196
  .customFilterChip {
190
197
  display: inline-flex; align-items: center; gap: 6px;
191
- font-size: 11px; font-weight: 600; font-family: var(--font-mono);
198
+ font-size: 13px; font-weight: 600; font-family: var(--font-mono);
192
199
  padding: 3px 4px 3px 8px;
193
200
  border: 1px solid var(--color-accent);
194
201
  background: var(--color-accent-dim);
@@ -217,18 +224,18 @@
217
224
  /* ── Next ping countdown (TUI parity) ── */
218
225
  .nextPing {
219
226
  display: flex; align-items: center; gap: 6px;
220
- font-size: 11px; font-family: var(--font-mono);
227
+ font-size: 13px; font-family: var(--font-mono);
221
228
  padding: 4px 8px;
222
229
  border: 1px solid var(--color-border);
223
230
  border-radius: 5px;
224
231
  background: var(--color-surface);
225
232
  }
226
233
  .nextPingLabel {
227
- font-size: 10px; font-weight: 500; color: var(--color-text-muted);
234
+ font-size: 12px; font-weight: 500; color: var(--color-text-muted);
228
235
  text-transform: uppercase; letter-spacing: 0.5px;
229
236
  }
230
237
  .nextPingTime {
231
- font-size: 13px; font-weight: 700; color: var(--color-text);
238
+ font-size: 15px; font-weight: 700; color: var(--color-text);
232
239
  min-width: 32px;
233
240
  text-align: right;
234
241
  font-variant-numeric: tabular-nums;
@@ -240,7 +247,7 @@
240
247
 
241
248
  .live {
242
249
  display: flex; align-items: center; gap: 5px;
243
- font-size: 10px; font-weight: 700; color: var(--color-accent);
250
+ font-size: 12px; font-weight: 700; color: var(--color-accent);
244
251
  text-transform: uppercase; letter-spacing: 1px;
245
252
  }
246
253
  .liveDot {
@@ -261,16 +268,16 @@
261
268
  }
262
269
  .benchmarkLabel {
263
270
  display: flex; align-items: center; gap: 5px;
264
- font-size: 11px; font-weight: 700;
271
+ font-size: 13px; font-weight: 700;
265
272
  color: #b400ff; font-family: var(--font-mono);
266
273
  white-space: nowrap;
267
274
  }
268
275
  .benchmarkSpinner {
269
276
  display: inline-block;
270
277
  animation: spinSp 0.8s linear infinite;
271
- font-size: 12px; line-height: 1;
278
+ font-size: 14px; line-height: 1;
272
279
  }
273
- .benchmarkCount { font-size: 10px; opacity: 0.8; }
280
+ .benchmarkCount { font-size: 12px; opacity: 0.8; }
274
281
  .benchmarkTrack {
275
282
  width: 120px; height: 5px;
276
283
  background: rgba(255,255,255,0.1);
@@ -284,7 +291,7 @@
284
291
  transition: width 0.5s ease;
285
292
  }
286
293
  .benchmarkPct {
287
- font-size: 11px; font-weight: 700;
294
+ font-size: 13px; font-weight: 700;
288
295
  color: #b400ff; font-family: var(--font-mono);
289
296
  min-width: 32px; text-align: right;
290
297
  }
@@ -293,3 +300,43 @@
293
300
  from { transform: rotate(0deg); }
294
301
  to { transform: rotate(360deg); }
295
302
  }
303
+
304
+ /* ─── Search Bar ─── */
305
+ .searchBar {
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 5px;
309
+ background: var(--color-surface);
310
+ border: 1px solid var(--color-border);
311
+ border-radius: 4px;
312
+ padding: 0 6px;
313
+ height: 24px;
314
+ width: 125px;
315
+ transition: border-color 150ms, box-shadow 150ms, width 200ms var(--ease-out);
316
+ }
317
+ .searchBar:focus-within {
318
+ border-color: var(--color-accent);
319
+ box-shadow: 0 0 0 2px var(--color-accent-glow);
320
+ width: 180px;
321
+ }
322
+ .searchIcon {
323
+ display: flex;
324
+ align-items: center;
325
+ color: var(--color-text-muted);
326
+ flex-shrink: 0;
327
+ }
328
+ .searchInput {
329
+ flex: 1;
330
+ background: none;
331
+ border: none;
332
+ outline: none;
333
+ color: var(--color-text);
334
+ font-size: 10.5px;
335
+ font-family: var(--font-sans);
336
+ min-width: 0;
337
+ padding: 0;
338
+ }
339
+ .searchInput::placeholder {
340
+ color: var(--color-text-dim);
341
+ font-size: 10px;
342
+ }
@@ -13,13 +13,14 @@
13
13
  * 📖 Custom widths persist in localStorage via the useColumnSizing hook and survive reloads.
14
14
  * 📖 A "Reset columns" button appears in the toolbar only when the user has custom widths.
15
15
  */
16
- import { useMemo, useEffect, useState, useCallback, Fragment } from 'react'
16
+ import { useMemo, useEffect, useState, useCallback, useRef } from 'react'
17
17
  import {
18
18
  useReactTable,
19
19
  getCoreRowModel,
20
20
  flexRender,
21
21
  createColumnHelper,
22
22
  } from '@tanstack/react-table'
23
+ import { useVirtualizer } from '@tanstack/react-virtual'
23
24
  import { IconStar, IconStarFilled, IconPlayerPlayFilled } from '@tabler/icons-react'
24
25
  import { useColumnSizing } from '../../hooks/useColumnSizing.js'
25
26
 
@@ -176,10 +177,12 @@ function TrendCellRenderer({ row }) {
176
177
  // 📖 the cells always see fresh values.
177
178
  const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode }) => [
178
179
  // 📖 Star column — leftmost, mirrors the TUI's `F` key.
180
+ // 📖 First column = bookmarks (TUI `F` key). Widened from 32→104 so the
181
+ // 📖 "BOOKMARKS" header label fits horizontally without clipping.
179
182
  colHelper.display({
180
183
  id: 'fav',
181
- header: '',
182
- size: 32,
184
+ header: 'BOOKMARKS',
185
+ size: 104,
183
186
  enableSorting: false,
184
187
  cell: ({ row }) => {
185
188
  const m = row.original
@@ -351,6 +354,12 @@ export default function ModelTable({
351
354
  // 📖 Expand row state — only one row expanded at a time (accordion)
352
355
  const [expandedRowId, setExpandedRowId] = useState(null)
353
356
 
357
+ // 📖 scrollRef is the internal scroll container — shared by the sticky
358
+ // 📖 <thead> header and the row virtualizer (only visible rows are in the
359
+ // 📖 DOM; off-screen rows stay in React state so live pings/probes keep
360
+ // 📖 updating them and re-render correctly when scrolled into view).
361
+ const scrollRef = useRef(null)
362
+
354
363
  const toggleExpand = useCallback((model) => {
355
364
  const key = `${model.providerKey}/${model.modelId}`
356
365
  setExpandedRowId((prev) => prev === key ? null : key)
@@ -402,6 +411,42 @@ export default function ModelTable({
402
411
 
403
412
  const rows = table.getRowModel().rows
404
413
 
414
+ // 📖 Flatten rows + any expanded detail panel into a single virtualized
415
+ // 📖 list. One <tr> per item keeps TanStack Virtual's dynamic measurement
416
+ // 📖 clean (one measured element per virtual item). The expand item only
417
+ // 📖 exists for the single accordion-open row, so count changes by at most 1.
418
+ const flatRows = useMemo(() => {
419
+ const out = []
420
+ for (const row of rows) {
421
+ const m = row.original
422
+ const key = `${m.providerKey}/${m.modelId}`
423
+ out.push({ type: 'row', row, key })
424
+ if (expandedRowId === key) {
425
+ out.push({ type: 'expand', row, key: `${key}__expand` })
426
+ }
427
+ }
428
+ return out
429
+ }, [rows, expandedRowId])
430
+
431
+ // 📖 Row virtualizer: renders only the ~15-25 rows in the viewport (+overscan)
432
+ // 📖 instead of all 190+. Spacer <tr>s above/below preserve the full scroll
433
+ // 📖 height. This is a RENDER-only optimization — ALL model data (including
434
+ // 📖 live pings/probes for off-screen rows) still lives in React state via
435
+ // 📖 useSocket, so updates continue uninterrupted and show fresh values the
436
+ // 📖 moment a row scrolls back into view. Nothing about probing changes.
437
+ const rowVirtualizer = useVirtualizer({
438
+ count: flatRows.length,
439
+ getScrollElement: () => scrollRef.current,
440
+ estimateSize: (i) => (flatRows[i]?.type === 'expand' ? 260 : 47),
441
+ overscan: 10,
442
+ getItemKey: (i) => flatRows[i].key,
443
+ })
444
+ const virtualItems = rowVirtualizer.getVirtualItems()
445
+ const totalSize = rowVirtualizer.getTotalSize()
446
+ const padTop = virtualItems.length ? Math.max(0, virtualItems[0].start) : 0
447
+ const lastVi = virtualItems.length ? virtualItems[virtualItems.length - 1] : null
448
+ const padBottom = lastVi ? Math.max(0, totalSize - lastVi.end) : 0
449
+
405
450
  // 📖 Total table width = sum of all column sizes. Used to drop the min-width
406
451
  // 📖 override once the user has manually tuned the columns, so horizontal
407
452
  // 📖 scrolling reflects the user's layout rather than the 1200px default.
@@ -433,6 +478,8 @@ export default function ModelTable({
433
478
 
434
479
  return (
435
480
  <div className={styles.container}>
481
+ {/* 📖 resizeToolbar lives OUTSIDE the scroller so it never overlaps the
482
+ 📖 sticky table header (thead th sticks to the top of scrollInner). */}
436
483
  {hasCustomSizing && (
437
484
  <div className={styles.resizeToolbar}>
438
485
  <span className={styles.resizeToolbarHint}>
@@ -448,6 +495,10 @@ export default function ModelTable({
448
495
  </button>
449
496
  </div>
450
497
  )}
498
+ {/* 📖 scrollInner is the actual scroller. It is the scroll element for
499
+ 📖 both the sticky <thead> header and the row virtualizer. Bounded by
500
+ 📖 .dashboardView (viewport height) so the table scrolls internally. */}
501
+ <div className={styles.scrollInner} ref={scrollRef}>
451
502
  <table
452
503
  className={styles.table}
453
504
  style={hasCustomSizing ? { minWidth: `${totalTableWidth}px` } : undefined}
@@ -508,10 +559,45 @@ export default function ModelTable({
508
559
  ))}
509
560
  </thead>
510
561
  <tbody>
511
- {rows.map((row, i) => {
562
+ {/* 📖 Top spacer: represents the scroll height of all virtualized-out
563
+ 📖 rows ABOVE the viewport. aria-hidden + empty cell keeps it
564
+ 📖 invisible while preserving table column layout. */}
565
+ {padTop > 0 && (
566
+ <tr aria-hidden="true" style={{ height: padTop }}>
567
+ <td style={{ height: padTop, padding: 0, border: 0 }} />
568
+ </tr>
569
+ )}
570
+ {virtualItems.map((vi) => {
571
+ const item = flatRows[vi.index]
572
+ // 📖 Expanded detail panel row — its own virtual item so dynamic
573
+ // 📖 measurement stays one-element-per-item (clean heights).
574
+ if (item.type === 'expand') {
575
+ return (
576
+ <tr
577
+ key={item.key}
578
+ className={styles.expandRowWrapper}
579
+ data-index={vi.index}
580
+ ref={rowVirtualizer.measureElement}
581
+ >
582
+ <td colSpan={item.row.getVisibleCells().length} className={styles.expandRowCell}>
583
+ <ExpandedDetailRow
584
+ model={item.row.original}
585
+ favorites={favorites}
586
+ onBenchmark={onBenchmarkRow}
587
+ onLaunch={onLaunch}
588
+ onToast={onToast}
589
+ toolMode={toolMode}
590
+ onSetToolMode={onSetToolMode}
591
+ onCycleToolMode={onCycleToolMode}
592
+ onOpenFallback={onOpenFallback}
593
+ />
594
+ </td>
595
+ </tr>
596
+ )
597
+ }
598
+ const row = item.row
512
599
  const m = row.original
513
- const rowKey = `${m.providerKey}/${m.modelId}`
514
- const isExpanded = expandedRowId === rowKey
600
+ const isExpanded = expandedRowId === item.key
515
601
  const rankIdx = [...top3Ids].indexOf(m.modelId)
516
602
  const rowClasses = []
517
603
  if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
@@ -530,47 +616,37 @@ export default function ModelTable({
530
616
  rowClasses.push(styles.notInSetRow)
531
617
  }
532
618
  return (
533
- <Fragment key={row.id}>
534
- <tr
535
- className={rowClasses.join(' ')}
536
- onClick={() => toggleExpand(m)}
537
- >
538
- {row.getVisibleCells().map(cell => {
539
- const sizePx = `${cell.column.getSize()}px`
540
- return (
541
- <td
542
- key={cell.id}
543
- className={styles.td}
544
- style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
545
- >
546
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
547
- </td>
548
- )
549
- })}
550
- </tr>
551
- {/* 📖 Expanded detail row — 3-column panel with info, playground, AI latency */}
552
- {isExpanded && (
553
- <tr className={styles.expandRowWrapper}>
554
- <td colSpan={row.getVisibleCells().length} className={styles.expandRowCell}>
555
- <ExpandedDetailRow
556
- model={m}
557
- favorites={favorites}
558
- onBenchmark={onBenchmarkRow}
559
- onLaunch={onLaunch}
560
- onToast={onToast}
561
- toolMode={toolMode}
562
- onSetToolMode={onSetToolMode}
563
- onCycleToolMode={onCycleToolMode}
564
- onOpenFallback={onOpenFallback}
565
- />
619
+ <tr
620
+ key={item.key}
621
+ className={rowClasses.join(' ')}
622
+ onClick={() => toggleExpand(m)}
623
+ data-index={vi.index}
624
+ ref={rowVirtualizer.measureElement}
625
+ >
626
+ {row.getVisibleCells().map(cell => {
627
+ const sizePx = `${cell.column.getSize()}px`
628
+ return (
629
+ <td
630
+ key={cell.id}
631
+ className={styles.td}
632
+ style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
633
+ >
634
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
566
635
  </td>
567
- </tr>
568
- )}
569
- </Fragment>
636
+ )
637
+ })}
638
+ </tr>
570
639
  )
571
640
  })}
641
+ {/* 📖 Bottom spacer: scroll height of all virtualized-out rows BELOW. */}
642
+ {padBottom > 0 && (
643
+ <tr aria-hidden="true" style={{ height: padBottom }}>
644
+ <td style={{ height: padBottom, padding: 0, border: 0 }} />
645
+ </tr>
646
+ )}
572
647
  </tbody>
573
648
  </table>
649
+ </div>
574
650
  </div>
575
651
  )
576
652
  }