Lovable sync 1780254773#549
Conversation
X-Lovable-Edit-ID: edt-2861a40d-d258-4d19-aa54-31352a773f86 Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
X-Lovable-Edit-ID: edt-dcfc3e5b-15a6-4368-9747-c1e2a3a946b2 Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
X-Lovable-Edit-ID: edt-a613bb01-3be5-4e73-afb3-e58acf636175 Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
X-Lovable-Edit-ID: edt-6fa19df9-0496-4719-b1c5-a88eab685b36 Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
X-Lovable-Edit-ID: edt-70a1ae4f-e6ec-41f0-873e-f30970596f1d Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
X-Lovable-Edit-ID: edt-f6465b77-0411-4059-9764-f972bb93fbed Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
X-Lovable-Edit-ID: edt-ad1f9ed9-044b-4064-a71b-751803bc9545 Co-authored-by: adm01-debug <231131902+adm01-debug@users.noreply.github.com>
|
Warning Review limit reached
More reviews will be available in 37 minutes and 3 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (16)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
There was a problem hiding this comment.
Pull request overview
This PR refactors the /novidades (novelties) page to use @tanstack/react-virtual with client-side pagination (20/page) and tightens accessibility/skeletons, and hardens the /estoque (stock) dashboard against legacy "bridge" failures by surfacing the React Query error instead of silently breaking. It also expands the Playwright visual/a11y suite (sharded in CI) and whitelists three more tables for the REST‑native external DB path.
Changes:
- New
VirtualizedNoveltyGrid+ paginatedNoveltyProductGrid; redesigned header and a11y/keyboard support onNoveltyGridCard; stable skeleton heights. StockDashboardshows a "Falha ao carregar estoque" error card with retry;useVariantStockreturnserrorwith retry/backoff;fetchPaginatedFromBridgethrows (with friendly 410 message) instead of swallowing errors;VariantStockTablegains anisLoadingskeleton mode.- E2E: new novelty visual/a11y/variation specs, new
/estoqueregression spec, Playwrightworkers=2in CI, sharded visual-baseline workflow; REST native whitelist +=variant_supplier_sources,supplier_branches,categories.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/novelties/VirtualizedNoveltyGrid.tsx | New virtualized grid using tanstack/react-virtual. |
| src/components/novelties/NoveltyProductGrid.tsx | Switch to virtualized grid + add pagination UI; bump fetch limit to 400. |
| src/components/novelties/NoveltyCards.tsx | Add keyboard/ARIA props and memoized click handler to grid card. |
| src/components/loading/ModernSkeletons.tsx | Fix skeleton min-heights to match real card layout. |
| src/components/inventory/VariantStockTable.tsx | Add isLoading skeleton table mode. |
| src/components/inventory/StockDashboard.tsx | Render error card with retry; pass isFetching to table skeleton. |
| src/hooks/products/useVariantStock.ts | Expose error; add retry: 3 with exponential backoff. |
| src/hooks/stock/stockFetcher.ts | Throw friendly 410 message on bridge error instead of break. |
| src/lib/external-db/rest-native.ts | Whitelist 3 additional tables for REST-native reads. |
| src/pages/products/NoveltiesPage.tsx | Restyle header; add novelty-description testid. |
| src/pages/auth/Auth.tsx | Remove SupabaseConnectionDebug import/usage. |
| playwright.config.ts | Increase CI workers from 1 to 2. |
| .github/workflows/visual-tests.yml | Shard visual baseline runs (2 shards); add 2 new specs. |
| tests/e2e/stock-regression.spec.ts | New /estoque regression spec. |
| e2e/routes/app/novelty-grid-visual.spec.ts | New visual + axe-core a11y spec across viewports. |
| e2e/routes/app/novelty-card-variations.spec.ts | New card-variation visual spec with mocked novelties API. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (isLoading) { | ||
| return ( | ||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead className="w-[300px]">Produto</TableHead> | ||
| <TableHead className="hidden md:table-cell">Cores</TableHead> | ||
| <TableHead>Estoque Total</TableHead> | ||
| <TableHead className="hidden sm:table-cell w-[120px]">Progresso</TableHead> | ||
| <TableHead className="hidden lg:table-cell">Reservado</TableHead> | ||
| <TableHead>Disponível</TableHead> | ||
| <TableHead className="hidden md:table-cell">Trânsito</TableHead> | ||
| <TableHead>Status</TableHead> | ||
| <TableHead className="hidden sm:table-cell">Previsão</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {[...Array(10)].map((_, i) => ( | ||
| <TableRow key={i}> | ||
| <TableCell> | ||
| <div className="flex items-center gap-2"> | ||
| <div className="h-6 w-6 rounded-md bg-muted animate-pulse" /> | ||
| <div className="space-y-2"> | ||
| <div className="h-4 w-32 bg-muted animate-pulse rounded" /> | ||
| <div className="h-3 w-20 bg-muted animate-pulse rounded" /> | ||
| </div> | ||
| </div> | ||
| </TableCell> | ||
| <TableCell className="hidden md:table-cell"> | ||
| <div className="flex gap-1"> | ||
| {[...Array(3)].map((_, j) => ( | ||
| <div key={j} className="h-5 w-5 rounded-full bg-muted animate-pulse" /> | ||
| ))} | ||
| </div> | ||
| </TableCell> | ||
| <TableCell><div className="h-4 w-12 bg-muted animate-pulse rounded" /></TableCell> | ||
| <TableCell className="hidden sm:table-cell"><div className="h-2 w-full bg-muted animate-pulse rounded" /></TableCell> | ||
| <TableCell className="hidden lg:table-cell"><div className="h-4 w-8 bg-muted animate-pulse rounded" /></TableCell> | ||
| <TableCell><div className="h-4 w-12 bg-muted animate-pulse rounded" /></TableCell> | ||
| <TableCell className="hidden md:table-cell"><div className="h-4 w-8 bg-muted animate-pulse rounded" /></TableCell> | ||
| <TableCell><div className="h-6 w-20 bg-muted animate-pulse rounded-full" /></TableCell> | ||
| <TableCell className="hidden sm:table-cell"><div className="h-4 w-10 bg-muted animate-pulse rounded" /></TableCell> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| ); | ||
| } |
| </div> | ||
|
|
||
| {totalPages > 1 && ( | ||
| <div className="mt-6 flex justify-center py-4"> | ||
| <Pagination> | ||
| <PaginationContent> | ||
| <PaginationItem> | ||
| <PaginationPrevious | ||
| onClick={() => setCurrentPage(p => Math.max(1, p - 1))} | ||
| className={cn(currentPage === 1 && "pointer-events-none opacity-50", "cursor-pointer")} | ||
| /> | ||
| </PaginationItem> | ||
|
|
||
| {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { | ||
| // Simplificado: mostra todas as páginas se forem poucas, senão mostra com ellipsis | ||
| // Aqui vamos mostrar apenas as 5 primeiras para o teste ou uma lógica simples | ||
| if (totalPages > 7) { | ||
| if (page !== 1 && page !== totalPages && Math.abs(page - currentPage) > 1) { | ||
| if (page === 2 || page === totalPages - 1) return <PaginationItem key={page}><PaginationEllipsis /></PaginationItem>; | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <PaginationItem key={page}> | ||
| <PaginationLink | ||
| isActive={currentPage === page} | ||
| onClick={() => setCurrentPage(page)} | ||
| className="cursor-pointer" | ||
| > | ||
| {page} | ||
| </PaginationLink> | ||
| </PaginationItem> | ||
| ); | ||
| })} | ||
|
|
||
| <PaginationItem> | ||
| <PaginationNext | ||
| onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} | ||
| className={cn(currentPage === totalPages && "pointer-events-none opacity-50", "cursor-pointer")} | ||
| /> | ||
| </PaginationItem> | ||
| </PaginationContent> | ||
| </Pagination> | ||
| </div> | ||
| )} | ||
| </motion.div> |
| const numCols = useResponsiveColumns(gridColumns); | ||
| const rowCount = Math.ceil(products.length / numCols); | ||
|
|
||
| const virtualizer = useVirtualizer({ | ||
| count: rowCount, | ||
| getScrollElement: () => parentRef.current, | ||
| estimateSize: () => 480, | ||
| overscan: 3, | ||
| measureElement: (el) => el.getBoundingClientRect().height, | ||
| }); | ||
|
|
||
| const effectiveCols = gridColumns; | ||
|
|
||
| return ( | ||
| <div | ||
| ref={parentRef} | ||
| className="overflow-auto" | ||
| style={{ maxHeight: 'calc(100vh - 280px)' }} | ||
| role="list" | ||
| aria-label="Grade de novidades" | ||
| > | ||
| <div | ||
| style={{ | ||
| height: `${virtualizer.getTotalSize()}px`, | ||
| width: '100%', | ||
| position: 'relative', | ||
| }} | ||
| > | ||
| {virtualizer.getVirtualItems().map((virtualRow) => { | ||
| const startIdx = virtualRow.index * numCols; | ||
| const rowProducts = products.slice(startIdx, startIdx + numCols); | ||
|
|
||
| return ( | ||
| <div | ||
| key={virtualRow.key} | ||
| data-index={virtualRow.index} | ||
| ref={virtualizer.measureElement} | ||
| style={{ | ||
| position: 'absolute', | ||
| top: 0, | ||
| left: 0, | ||
| width: '100%', | ||
| transform: `translateY(${virtualRow.start}px)`, | ||
| }} | ||
| className={`grid ${getGridColsClass(effectiveCols)} ${getGridGapClass(effectiveCols)} pb-8`} |
| return ( | ||
| <div | ||
| ref={parentRef} | ||
| className="overflow-auto" | ||
| style={{ maxHeight: 'calc(100vh - 280px)' }} | ||
| role="list" | ||
| aria-label="Grade de novidades" | ||
| > |
| onClick={handleClick} | ||
| role="article" | ||
| aria-label={`${product.product_name} — ${getStockStatusLabel(stockStatus)}, ${formatPrice(product.base_price ?? 0)}`} | ||
| aria-selected={selectionMode ? isSelected : undefined} | ||
| tabIndex={0} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter' || e.key === ' ') { | ||
| e.preventDefault(); | ||
| handleClick(); | ||
| } | ||
| }} |
| for (const viewport of viewports) { | ||
| test.describe(`Viewport: ${viewport.name}`, () => { | ||
| test.use({ viewport: { width: viewport.width, height: viewport.height } }); | ||
|
|
||
| test('Header Immediate Rendering & Visual', async ({ page }) => { | ||
| await page.goto('/novidades', { waitUntil: 'domcontentloaded' }); | ||
|
|
||
| const header = page.locator('div.flex.flex-col.gap-4').first(); | ||
| const title = header.locator('[data-testid="page-title-novidades"]'); | ||
| const desc = header.locator('[data-testid="novelty-description"]'); | ||
|
|
||
| await expect(title).toBeVisible(); | ||
| await expect(title).toHaveText('Novidades'); | ||
| await expect(desc).toHaveText('Produtos recém-chegados ao catálogo nos últimos 30 dias'); | ||
|
|
||
| await expect(header).toHaveScreenshot(`novelty-header-only-${viewport.name}.png`); | ||
| }); | ||
|
|
||
| test('Grid Visual Regression & Scroll', async ({ page }) => { | ||
| await page.goto('/novidades'); | ||
| const grid = page.locator('div[role="list"]'); | ||
| await grid.waitFor({ state: 'visible' }); | ||
| await page.waitForTimeout(1000); | ||
|
|
||
| await expect(grid).toHaveScreenshot(`novelty-grid-initial-${viewport.name}.png`, { | ||
| maxDiffPixelRatio: 0.02, | ||
| }); | ||
|
|
||
| await grid.evaluate(el => el.scrollTop = 1000); | ||
| await page.waitForTimeout(800); | ||
|
|
||
| await expect(grid).toHaveScreenshot(`novelty-grid-scrolled-${viewport.name}.png`, { | ||
| maxDiffPixelRatio: 0.02, | ||
| }); | ||
| }); | ||
|
|
||
| test('Accessibility Scan', async ({ page }) => { | ||
| await page.goto('/novidades'); | ||
| const grid = page.locator('div[role="list"]'); | ||
| await grid.waitFor({ state: 'visible' }); | ||
|
|
||
| const results = await new AxeBuilder({ page }) | ||
| .include('div[role="list"]') | ||
| .analyze(); | ||
|
|
||
| if (results.violations.length > 0) { | ||
| console.error(`A11y Violations for viewport ${viewport.name}:`); | ||
| results.violations.forEach(v => { | ||
| console.error(`- [${v.id}] ${v.help} (${v.impact})`); | ||
| console.error(` URL: ${v.helpUrl}`); | ||
| console.error(` Nodes: ${v.nodes.length}`); | ||
| }); | ||
| } | ||
|
|
||
| expect(results.violations, `A11y violations found in ${viewport.name}: ${results.violations.map(v => v.id).join(', ')}`).toEqual([]); | ||
| }); | ||
|
|
||
| test('Browser Preferences - Accessibility Consistency', async ({ page }) => { | ||
| // Simulate high contrast / large font via CSS injection | ||
| await page.addStyleTag({ | ||
| content: ` | ||
| html { font-size: 20px !important; } | ||
| * { transition: none !important; animation: none !important; } | ||
| ` | ||
| }); | ||
|
|
||
| await page.goto('/novidades'); | ||
| const grid = page.locator('div[role="list"]'); | ||
| await grid.waitFor({ state: 'visible' }); | ||
|
|
||
| await expect(grid).toHaveScreenshot(`novelty-grid-a11y-prefs-${viewport.name}.png`); | ||
| }); | ||
|
|
||
| test('Keyboard Navigation', async ({ page }) => { | ||
| await page.goto('/novidades'); | ||
| await page.keyboard.press('Tab'); | ||
|
|
||
| const activeElement = await page.evaluate(() => document.activeElement?.tagName); | ||
| expect(activeElement).toBeDefined(); | ||
|
|
||
| await page.keyboard.press('Tab'); | ||
| await expect(page).toHaveScreenshot(`novelty-keyboard-focus-${viewport.name}.png`); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| test('Skeleton State & Layout Stability', async ({ page }) => { | ||
| // Intercept with delay to see skeleton | ||
| await page.route('**/api/external-db', async route => { | ||
| if (route.request().postDataJSON()?.operation === 'select') { | ||
| await new Promise(resolve => setTimeout(resolve, 2000)); | ||
| } | ||
| await route.continue(); | ||
| }); | ||
|
|
||
| await page.goto('/novidades'); | ||
| const skeleton = page.locator('.animate-spin').first(); | ||
| await expect(skeleton).toBeVisible(); | ||
|
|
||
| // Capture skeleton grid | ||
| const grid = page.locator('div.grid').filter({ has: page.locator('.animate-pulse') }).first(); | ||
| await expect(grid).toHaveScreenshot('novelty-skeleton-state.png'); | ||
|
|
||
| // Wait for data and check stability | ||
| await page.waitForSelector('div[role="list"]'); | ||
| const realGrid = page.locator('div[role="list"]'); | ||
| await expect(realGrid).toBeVisible(); | ||
| await expect(realGrid).toHaveScreenshot('novelty-data-loaded-stability.png'); | ||
| }); | ||
|
|
||
| test('Pagination & Alignment Check', async ({ page }) => { | ||
| // Mock enough products for multiple pages | ||
| await page.route('**/api/external-db', async route => { | ||
| const body = route.request().postDataJSON(); | ||
| if (body?.operation === 'select' && body?.table === 'products') { | ||
| const mockProducts = Array.from({ length: 45 }, (_, i) => ({ | ||
| id: `page-mock-${i}`, | ||
| name: `Product ${i} ${i % 3 === 0 ? 'with a very very very long name to test wrapping and alignment consistency across the grid' : ''}`, | ||
| sku: `SKU-${i}`, | ||
| primary_image_url: null, | ||
| sale_price: i % 5 === 0 ? null : 100 + i, | ||
| category_id: 'cat-1', | ||
| supplier_id: 'sup-1', | ||
| created_at: new Date().toISOString(), | ||
| stock_quantity: 100, | ||
| min_quantity: 10 | ||
| })); | ||
| await route.fulfill({ | ||
| status: 200, | ||
| contentType: 'application/json', | ||
| body: JSON.stringify({ records: mockProducts, count: 45 }) | ||
| }); | ||
| } else { | ||
| await route.continue(); | ||
| } | ||
| }); | ||
|
|
||
| await page.goto('/novidades'); | ||
| const paginator = page.locator('nav[aria-label="pagination"]'); | ||
| await paginator.waitFor(); | ||
|
|
||
| // Check first page alignment | ||
| const firstPageItems = page.locator('div[role="listitem"]'); | ||
| await expect(firstPageItems).toHaveCount(20); | ||
| await expect(page).toHaveScreenshot('novelty-pagination-page-1.png'); | ||
|
|
||
| // Click next | ||
| await page.click('a[aria-label="Go to next page"]'); | ||
| await page.waitForTimeout(500); | ||
| await expect(page.locator('div[role="listitem"]')).toHaveCount(20); | ||
| await expect(page).toHaveScreenshot('novelty-pagination-page-2.png'); | ||
|
|
||
| // Click last page (3) | ||
| await page.click('a:text("3")'); | ||
| await page.waitForTimeout(500); | ||
| await expect(page.locator('div[role="listitem"]')).toHaveCount(5); | ||
| await expect(page).toHaveScreenshot('novelty-pagination-last-page.png'); | ||
| }); |
| test('Header Immediate Rendering & Visual', async ({ page }) => { | ||
| await page.goto('/novidades', { waitUntil: 'domcontentloaded' }); | ||
|
|
||
| const header = page.locator('div.flex.flex-col.gap-4').first(); | ||
| const title = header.locator('[data-testid="page-title-novidades"]'); | ||
| const desc = header.locator('[data-testid="novelty-description"]'); | ||
|
|
||
| await expect(title).toBeVisible(); | ||
| await expect(title).toHaveText('Novidades'); | ||
| await expect(desc).toHaveText('Produtos recém-chegados ao catálogo nos últimos 30 dias'); | ||
|
|
||
| await expect(header).toHaveScreenshot(`novelty-header-only-${viewport.name}.png`); | ||
| }); | ||
|
|
||
| test('Grid Visual Regression & Scroll', async ({ page }) => { | ||
| await page.goto('/novidades'); | ||
| const grid = page.locator('div[role="list"]'); | ||
| await grid.waitFor({ state: 'visible' }); | ||
| await page.waitForTimeout(1000); | ||
|
|
| await expect(grid).toHaveScreenshot('novelty-card-variations.png', { | ||
| maxDiffPixelRatio: 0.05 | ||
| }); |
| 'product_relationships', 'product_groups', 'product_group_members', | ||
| 'v_price_history_safe', | ||
| 'system_kill_switches', | ||
| 'variant_supplier_sources', 'supplier_branches', 'categories', |
| await expect(grid).toHaveScreenshot(`novelty-grid-initial-${viewport.name}.png`, { | ||
| maxDiffPixelRatio: 0.02, | ||
| }); | ||
|
|
||
| await grid.evaluate(el => el.scrollTop = 1000); | ||
| await page.waitForTimeout(800); | ||
|
|
||
| await expect(grid).toHaveScreenshot(`novelty-grid-scrolled-${viewport.name}.png`, { | ||
| maxDiffPixelRatio: 0.02, | ||
| }); |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 49ba02bbc1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }: VariantStockTableProps & { isLoading?: boolean }) { | ||
| const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set()); | ||
|
|
||
| if (isLoading) { |
There was a problem hiding this comment.
Keep hooks before the loading return
When isFetching becomes true after this table has already rendered data (for example after fetchStockData invalidates the query), this early return skips the later useState, useSearchParams, useRef, useEffect, and useMemo calls. React will then detect a different hook order and can throw instead of showing the stock table; move the loading branch after the hooks or render the skeleton without returning before them.
Useful? React with 👍 / 👎.
| </div> | ||
| </div> | ||
|
|
||
| {totalPages > 1 && ( |
There was a problem hiding this comment.
Render pagination outside the fetching overlay
This pagination block is nested inside the isFetching && products.length > 0 overlay that starts above it, so once the novelty query settles the page controls disappear. With more than 20 novelties, users only see the first page after loading and cannot navigate to the remaining results; move the pagination outside the AnimatePresence/fetching conditional.
Useful? React with 👍 / 👎.
|
|
||
| test('Pagination & Alignment Check', async ({ page }) => { | ||
| // Mock enough products for multiple pages | ||
| await page.route('**/api/external-db', async route => { |
There was a problem hiding this comment.
Mock the endpoint the novelties page actually calls
These fixtures are registered on **/api/external-db, but /novidades loads data through useNoveltiesWithDetails/invokeExternalDb: products is REST-native eligible and otherwise falls back to functions/v1/external-db-bridge, not this URL (the same mismatch appears in the new card-variations spec's functions/v1/novelties mock). In CI the 45 mocked products are never supplied, so the paginator/count assertions depend on live or empty data and will fail or become flaky; route the actual Supabase REST/bridge request instead.
Useful? React with 👍 / 👎.
| ); | ||
| } | ||
|
|
||
| if (error) { |
There was a problem hiding this comment.
Preserve stale stock data on refresh errors
When a refresh fails after the dashboard has already loaded data, React Query can keep the previous productStocks while also exposing the refetch error. This unconditional error return then replaces the entire stock dashboard with the failure card after a transient/manual refresh failure, even though usable stale data is still available; only show the full-page error when there is no loaded data, or surface background refetch errors non-destructively.
Useful? React with 👍 / 👎.
|
Closing in favour of #553, which implements all features from this PR (and from #547, #550, #551, #552) on current
This branch also had merge conflicts with main due to the older base SHA. Generated by Claude Code |
📋 Descrição
🎯 Tipo de mudança
🔗 Issues relacionadas
Closes #
Refs #
🌐 Sistemas afetados
🧪 Como testar
✅ Checklist pré-merge
Qualidade
npx tsc --noEmitpassa sem errosnpm run test)Segurança
console.logcom payloads sensíveis (usarlogger.*)Documentação
mem://) se a mudança afetar arquitetura/regras_backup_*_YYYYMMDDse destrutivasUI
📸 Screenshots (se UI)
🔄 Plano de rollback
Summary by cubic
Virtualized and paginated novelty grid with improved accessibility and stable skeletons to prevent layout shift. Hardened stock dashboard error handling to avoid white screens, and expanded E2E visual/a11y coverage with sharded CI runs.
New Features
@tanstack/react-virtualwith 20 items per page and smooth scrolling.workers=2in CI, new visual/a11y specs with@playwright/testandaxe-core/playwright.variant_supplier_sources,supplier_branches, andcategories.Bug Fixes
useVariantStocknow surfaces errors).Written for commit 49ba02b. Summary will update on new commits.