diff --git a/README.md b/README.md index 2f9d388..bef090c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ checks in parallel on Node.js 24. `test:coverage` measures every production Type only declaration files and does not claim execution coverage for Svelte components. After a production build, `check:performance` checks the existing Vercel output without rebuilding; `test:performance` performs both steps. The performance checks cover immutable JS/CSS and the -deterministic 280-problem homepage HTML fixture budgets. Live read-only Supabase smoke tests run +deterministic full 280-problem homepage HTML fixture budgets. Live read-only Supabase smoke tests run when CI has the required repository variable and secret; otherwise those optional jobs exit cleanly. ## Contributing diff --git a/e2e/homepage-pagination.spec.ts b/e2e/homepage-problems.spec.ts similarity index 57% rename from e2e/homepage-pagination.spec.ts rename to e2e/homepage-problems.spec.ts index 8a11ee1..525cad8 100644 --- a/e2e/homepage-pagination.spec.ts +++ b/e2e/homepage-problems.spec.ts @@ -20,24 +20,22 @@ test.beforeEach(async () => { await setScenario('large'); }); -test('homepage batches complete derived rows accessibly without a hydration refetch', async ({ +test('homepage renders every derived row without pagination or a hydration refetch', async ({ page }) => { const response = await page.goto('/'); const html = (await response?.text()) ?? ''; const serverBody = html.match(/([\s\S]*?)<\/tbody>/)?.[1] ?? ''; - expect(new TextEncoder().encode(html).byteLength).toBeLessThanOrEqual(400_000); - expect((serverBody.match(/ tr'); - await expect(rows).toHaveCount(50); - await expect(page.getByText('50 of 280 problems shown')).toBeVisible(); - const showMore = page.getByRole('button', { name: 'Show 50 more problems' }); - await expect(showMore).toHaveAttribute('type', 'button'); - await expect(showMore).toHaveAttribute('aria-controls', 'problem-table-body'); + await expect(rows).toHaveCount(280); + await expect(page.getByRole('button', { name: /more problems|All problems shown/ })).toHaveCount( + 0 + ); await page.waitForLoadState('networkidle'); - expect(await page.locator('*').count()).toBeLessThanOrEqual(2_500); const readMetadata = await problemReadMetadata(); expect(readMetadata.problemsReadCount).toBe(1); expect(readMetadata.lastProblemsSelect?.split(',').map((column) => column.trim())).toEqual([ @@ -52,34 +50,16 @@ test('homepage batches complete derived rows accessibly without a hydration refe 'type' ]); - await showMore.click(); - await expect(rows).toHaveCount(100); - await expect(page.getByText('100 of 280 problems shown')).toBeVisible(); - - await page.getByLabel('Filter by author').selectOption('beyond-author'); + await page.getByLabel('Filter by author').selectOption('full-list-author'); await expect(rows).toHaveCount(1); - await expect(page.getByRole('link', { name: 'Beyond First Batch' })).toBeVisible(); - await expect(page.getByRole('button', { name: /more problems|All problems shown/ })).toHaveCount( - 0 - ); + await expect(page.getByRole('link', { name: 'Full List Boundary Problem' })).toBeVisible(); await page.getByLabel('Filter by author').selectOption('all'); - await expect(rows).toHaveCount(50); - await page.getByRole('button', { name: /Difficulty, not sorted/i }).click(); - await expect(rows.first().getByRole('link', { name: 'Beyond First Batch' })).toBeVisible(); - - for (const count of [100, 150, 200, 250]) { - await page.getByRole('button', { name: 'Show 50 more problems' }).click(); - await expect(rows).toHaveCount(count); - } - const finalBatch = page.getByRole('button', { name: 'Show 30 more problems' }); - await finalBatch.click(); await expect(rows).toHaveCount(280); - const complete = page.getByRole('button', { name: 'All problems shown' }); - await expect(complete).toBeVisible(); - await expect(complete).toBeDisabled(); - await expect(complete).toBeFocused(); - await expect(page.getByRole('status')).toHaveText('280 of 280 problems shown'); + await page.getByRole('button', { name: /Difficulty, not sorted/i }).click(); + await expect( + rows.first().getByRole('link', { name: 'Full List Boundary Problem' }) + ).toBeVisible(); expect((await problemReadMetadata()).problemsReadCount).toBe(1); }); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 0d333b5..64b9696 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -157,33 +157,36 @@ test.describe('header session and appearance controls', () => { await expect(error).toHaveCount(0); }); - test('desktop appearance popover uses radios and Escape restores focus', async ({ - page, - viewport - }) => { - test.skip(!!viewport && viewport.width < 1024, 'desktop appearance popover only'); + test('desktop theme icon cycles System, Light, Dark, then System', async ({ page, viewport }) => { + test.skip(!!viewport && viewport.width < 1024, 'desktop theme control only'); await page.goto('/'); - const button = page.getByRole('button', { name: 'Appearance' }); - const target = await button.boundingBox(); + const system = page.getByRole('button', { name: 'Theme: System. Switch to Light' }); + const target = await system.boundingBox(); expect(target?.height).toBeGreaterThanOrEqual(44); expect(target?.width).toBeGreaterThanOrEqual(44); - await expect(button).toHaveText(''); - await button.click(); - await expect(page.getByRole('radio', { name: 'System' })).toBeChecked(); - await page.keyboard.press('Escape'); - await expect(page.locator('#appearance-popover')).toHaveCount(0); - await expect(button).toBeFocused(); + await expect(system).toHaveText(''); + await system.click(); + await page.getByRole('button', { name: 'Theme: Light. Switch to Dark' }).click(); + await page.getByRole('button', { name: 'Theme: Dark. Switch to System' }).click(); + await expect( + page.getByRole('button', { name: 'Theme: System. Switch to Light' }) + ).toBeVisible(); + await expect(page.getByRole('radio')).toHaveCount(0); }); - test('mobile appearance choices meet the 44px target minimum', async ({ page, viewport }) => { - test.skip(!!viewport && viewport.width >= 1024, 'mobile appearance controls only'); + test('mobile theme icon remains secondary and meets the 44px target minimum', async ({ + page, + viewport + }) => { + test.skip(!!viewport && viewport.width >= 1024, 'mobile theme control only'); await page.goto('/'); await page.getByRole('button', { name: 'Open menu' }).click(); - const system = page.getByRole('radio', { name: 'System' }); - const target = system.locator('..'); - const box = await target.boundingBox(); + const theme = page.getByRole('button', { name: 'Theme: System. Switch to Light' }); + const box = await theme.boundingBox(); expect(box?.height).toBeGreaterThanOrEqual(44); expect(box?.width).toBeGreaterThanOrEqual(44); + await expect(theme).toHaveText(''); + await expect(page.getByRole('radio')).toHaveCount(0); }); }); @@ -272,27 +275,27 @@ test.describe('signed-in settings layout and appearance', () => { await expect(page.locator('h1.sr-only')).toHaveText('Settings'); await expect(page.getByRole('heading', { name: 'Appearance' })).toHaveCount(0); - const appearanceButton = page.getByRole('button', { name: 'Appearance' }); - if (await appearanceButton.isVisible()) await appearanceButton.click(); - else { + let themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' }); + if (!(await themeButton.isVisible())) { await page.getByRole('button', { name: 'Open menu' }).click(); + themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' }); } const settingsLink = page.getByRole('link', { name: 'Settings' }); const settingsTarget = await settingsLink.boundingBox(); expect(settingsTarget?.height).toBeGreaterThanOrEqual(44); expect(settingsTarget?.width).toBeGreaterThanOrEqual(44); await expect(settingsLink).toHaveText(''); - await page.getByRole('radio', { name: 'Dark' }).check(); + await themeButton.click(); + await page.getByRole('button', { name: 'Theme: Light. Switch to Dark' }).click(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); await expect.poll(() => page.evaluate(() => localStorage.getItem('gitgud-theme'))).toBe('dark'); await page.reload(); if (viewport && viewport.width < 1024) { await page.getByRole('button', { name: 'Open menu' }).click(); - } else { - await page.getByRole('button', { name: 'Appearance' }).click(); } - await expect(page.getByRole('radio', { name: 'Dark' })).toBeChecked(); + await expect(page.getByRole('button', { name: 'Theme: Dark. Switch to System' })).toBeVisible(); + await expect(page.getByRole('radio', { name: /^(System|Light|Dark)$/ })).toHaveCount(0); const lastCard = page.locator('main section').last(); const footer = page.locator('footer'); @@ -488,10 +491,12 @@ test.describe('theme startup', () => { await page.emulateMedia({ colorScheme: 'dark' }); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); - const appearanceButton = page.getByRole('button', { name: 'Appearance' }); - if (await appearanceButton.isVisible()) await appearanceButton.click(); - else await page.getByRole('button', { name: 'Open menu' }).click(); - await page.getByRole('radio', { name: 'Light' }).check(); + let themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' }); + if (!(await themeButton.isVisible())) { + await page.getByRole('button', { name: 'Open menu' }).click(); + themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' }); + } + await themeButton.click(); await page.emulateMedia({ colorScheme: 'dark' }); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); }); diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts index e0fec14..02dd36b 100644 --- a/e2e/support/fixtures.ts +++ b/e2e/support/fixtures.ts @@ -97,14 +97,14 @@ export const LARGE_PROBLEMS: ProblemRow[] = Array.from({ length: 280 }, (_, inde const isLast = number === 280; return { id: `large-${String(number).padStart(3, '0')}`, - name: isLast ? 'Beyond First Batch' : `Large Fixture Problem ${number}`, + name: isLast ? 'Full List Boundary Problem' : `Large Fixture Problem ${number}`, difficulty: isLast ? 700 : 1000 + number, url: number % 2 === 0 ? `https://open.kattis.com/problems/large-${number}` : `https://codeforces.com/contest/2000/problem/${number}`, - added_by: isLast ? 'beyond-author' : `author-${number % 10}`, - added_by_url: `https://example.test/users/${isLast ? 'beyond-author' : `author-${number % 10}`}`, + added_by: isLast ? 'full-list-author' : `author-${number % 10}`, + added_by_url: `https://example.test/users/${isLast ? 'full-list-author' : `author-${number % 10}`}`, likes: 281 - number, dislikes: 0, type: number % 3 === 0 ? 'graph' : 'math' diff --git a/scripts/performance-budget.mjs b/scripts/performance-budget.mjs index 25e1026..282aa86 100644 --- a/scripts/performance-budget.mjs +++ b/scripts/performance-budget.mjs @@ -16,10 +16,10 @@ export const STATIC_BUDGETS = Object.freeze({ }); /** @type {Readonly>} */ export const HOMEPAGE_FIXTURE_BUDGETS = Object.freeze({ - rows: 50, + rows: 280, totalProblems: 280, - htmlBytes: 24_000, - gzipBytes: 2_000 + htmlBytes: 125_000, + gzipBytes: 6_000 }); /** @@ -87,9 +87,7 @@ export function renderHomepageTableFixture(rowCount = HOMEPAGE_FIXTURE_BUDGETS.r const name = escapeHtml(`Fixture Problem ${id} `); return `${source}${name}${800 + (index % 28) * 100}dynamic programming@author-${index % 20}`; }); - const remaining = Math.max(HOMEPAGE_FIXTURE_BUDGETS.totalProblems - rowCount, 0); - const nextCount = Math.min(rowCount, remaining); - return `Problems fixture

Problems

${rows.join('')}
SolvedSourceProblemDifficultyTopicRecommenderFeedback

${rowCount} of ${HOMEPAGE_FIXTURE_BUDGETS.totalProblems} problems shown

`; + return `Problems fixture

Problems

${rows.join('')}
SolvedSourceProblemDifficultyTopicRecommenderFeedback
`; } export function measureHomepageFixture(rowCount = HOMEPAGE_FIXTURE_BUDGETS.rows) { @@ -122,8 +120,8 @@ export function budgetFailures( budgets.largestJavaScriptGzipBytes ], ['largest immutable CSS gzip', summary.largestCss?.gzipBytes ?? 0, budgets.largestCssGzipBytes], - ['initial homepage fixture HTML', fixture.htmlBytes, fixtureBudgets.htmlBytes], - ['initial homepage fixture gzip', fixture.gzipBytes, fixtureBudgets.gzipBytes] + ['full homepage fixture HTML', fixture.htmlBytes, fixtureBudgets.htmlBytes], + ['full homepage fixture gzip', fixture.gzipBytes, fixtureBudgets.gzipBytes] ]; return checks .filter(([, actual, budget]) => actual > budget) diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index 14e1f9a..e9d4d07 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -3,18 +3,12 @@ import { resolve } from '$app/paths'; import { page } from '$app/state'; import { currentActor, signInWithGithub, signOut } from '$lib/auth/currentActor'; - import { - currentThemePreference, - setThemePreference, - THEME_PREFERENCES, - type ThemePreference - } from '$lib/services/theme'; + import ThemeCycleButton from '$lib/components/ThemeCycleButton.svelte'; + import { nextThemePreference, type ThemePreference } from '$lib/services/appearance'; + import { currentThemePreference, setThemePreference } from '$lib/services/theme'; let mobileMenuOpen = false; let mobileMenuButton: HTMLButtonElement | null = null; - let appearanceOpen = false; - let appearanceButton: HTMLButtonElement | null = null; - let appearancePanel: HTMLDivElement | null = null; let loginBusy = false; let loginError = page.url.pathname === '/' && page.url.searchParams.get('auth_error') === 'true'; let logoutBusy = false; @@ -67,7 +61,7 @@ } } - async function selectAppearance(preference: ThemePreference): Promise { + async function selectThemePreference(preference: ThemePreference): Promise { const revision = ++themeSaveRevision; const userId = user?.id; themeSaveError = false; @@ -80,40 +74,27 @@ } } - function retryThemeSave(): void { - if (failedThemePreference) void selectAppearance(failedThemePreference); + function cycleTheme(): void { + void selectThemePreference(nextThemePreference($currentThemePreference)); } - function handleWindowKeydown(event: KeyboardEvent): void { - if (event.key !== 'Escape') return; - if (appearanceOpen) { - appearanceOpen = false; - appearanceButton?.focus(); - } else if (mobileMenuOpen) { - mobileMenuOpen = false; - mobileMenuButton?.focus(); - } + function retryThemeSave(): void { + if (failedThemePreference) void selectThemePreference(failedThemePreference); } - function handleWindowPointerDown(event: PointerEvent): void { - const target = event.target as Node; - if ( - appearanceOpen && - !appearancePanel?.contains(target) && - !appearanceButton?.contains(target) - ) { - appearanceOpen = false; - } + function handleWindowKeydown(event: KeyboardEvent): void { + if (event.key !== 'Escape' || !mobileMenuOpen) return; + mobileMenuOpen = false; + mobileMenuButton?.focus(); } afterNavigate(() => { mobileMenuOpen = false; - appearanceOpen = false; loginError = page.url.pathname === '/' && page.url.searchParams.get('auth_error') === 'true'; }); - +
-
- - {#if appearanceOpen} -
-
- Appearance - {#each THEME_PREFERENCES as preference (preference)} - - {/each} -
-
- {/if} -
-
+ {#if !$currentActor.initialized} -

- {visibleRows.length} of {fullRows.length} problems shown -

-
- {/if} diff --git a/src/lib/components/ThemeCycleButton.svelte b/src/lib/components/ThemeCycleButton.svelte new file mode 100644 index 0000000..4dc8696 --- /dev/null +++ b/src/lib/components/ThemeCycleButton.svelte @@ -0,0 +1,65 @@ + + + diff --git a/src/lib/components/problemPagination.ts b/src/lib/components/problemPagination.ts deleted file mode 100644 index 11e75ca..0000000 --- a/src/lib/components/problemPagination.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function initialProblemVisibleCount(rowBatchSize: number | null): number { - return rowBatchSize && rowBatchSize > 0 ? rowBatchSize : Number.POSITIVE_INFINITY; -} - -export function nextProblemVisibleCount( - currentCount: number, - totalCount: number, - rowBatchSize: number -): number { - return Math.min(currentCount + rowBatchSize, totalCount); -} - -export function nextProblemBatchCount( - currentCount: number, - totalCount: number, - rowBatchSize: number -): number { - return Math.min(rowBatchSize, Math.max(totalCount - currentCount, 0)); -} diff --git a/src/lib/services/appearance.ts b/src/lib/services/appearance.ts index 4f7d0fa..1f8e04d 100644 --- a/src/lib/services/appearance.ts +++ b/src/lib/services/appearance.ts @@ -6,6 +6,11 @@ export function normalizeThemePreference(value: unknown): ThemePreference { return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'; } +export function nextThemePreference(preference: ThemePreference): ThemePreference { + const index = THEME_PREFERENCES.indexOf(preference); + return THEME_PREFERENCES[(index + 1) % THEME_PREFERENCES.length]; +} + export function resolveTheme( preference: ThemePreference, systemPrefersDark: boolean diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2d1529b..736a2a7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,5 +9,4 @@ pageTitle="Problems" defaultSolvedFilterState="all" initialProblems={data.problems} - rowBatchSize={50} /> diff --git a/tests/performance-budget.test.ts b/tests/performance-budget.test.ts index 6e71a8e..36f620d 100644 --- a/tests/performance-budget.test.ts +++ b/tests/performance-budget.test.ts @@ -32,12 +32,12 @@ test('asset collection includes only immutable JavaScript and CSS in stable orde assert.equal(summarizeAssets(assets).assetCount, 2); }); -test('homepage fixture models the deterministic initial 50-row batch', () => { +test('homepage fixture models the complete deterministic problem list', () => { const html = renderHomepageTableFixture(); - assert.equal((html.match(/data-problem-id=/g) ?? []).length, 50); + assert.equal((html.match(/data-problem-id=/g) ?? []).length, 280); assert.match(html, /Fixture Problem 1 <quality>/); - assert.match(html, /aria-controls="problem-table-body"/); - assert.match(html, />50 of 280 problems shown { { totalGzipBytes: 100, largestJavaScriptGzipBytes: 50, largestCssGzipBytes: 21 }, { htmlBytes: 300, gzipBytes: 29 } ), - ['total immutable JS/CSS gzip: 101 > 100 bytes', 'initial homepage fixture gzip: 30 > 29 bytes'] + ['total immutable JS/CSS gzip: 101 > 100 bytes', 'full homepage fixture gzip: 30 > 29 bytes'] ); }); diff --git a/tests/problem-pagination.test.ts b/tests/problem-pagination.test.ts deleted file mode 100644 index eedd51c..0000000 --- a/tests/problem-pagination.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { test } from 'node:test'; -import { - initialProblemVisibleCount, - nextProblemBatchCount, - nextProblemVisibleCount -} from '../src/lib/components/problemPagination.ts'; - -test('problem pagination advances in bounded batches', () => { - assert.equal(initialProblemVisibleCount(50), 50); - assert.equal(initialProblemVisibleCount(null), Number.POSITIVE_INFINITY); - assert.equal(nextProblemVisibleCount(50, 121, 50), 100); - assert.equal(nextProblemVisibleCount(100, 121, 50), 121); - assert.equal(nextProblemBatchCount(50, 121, 50), 50); - assert.equal(nextProblemBatchCount(100, 121, 50), 21); - assert.equal(nextProblemBatchCount(121, 121, 50), 0); -}); - -test('ProblemDisplay slices derived rows and resets only from filter and sort handlers', () => { - const source = readFileSync('src/lib/components/ProblemDisplay.svelte', 'utf8'); - assert.match(source, /\$: fullRows = collection\.rows;/); - assert.match(source, /\$: visibleRows = fullRows\.slice\(0, visibleRowCount\);/); - - for (const handler of [ - 'handleTopicSelect', - 'handleDifficultySort', - 'handleSolvedFilter', - 'handleAuthorFilter', - 'handleSourceFilter' - ]) { - assert.match(source, new RegExp(`function ${handler}\\([\\s\\S]*?resetVisibleRows\\(\\);`)); - } - - for (const handler of ['handleLike', 'handleToggleSolved']) { - const body = source.match(new RegExp(`function ${handler}\\([\\s\\S]*?\\n}`))?.[0] ?? ''; - assert.doesNotMatch(body, /resetVisibleRows/); - } -}); - -test('homepage opts into batches while profile pages retain the unlimited default', () => { - const homepage = readFileSync('src/routes/+page.svelte', 'utf8'); - const profile = readFileSync('src/routes/user/[userId]/+page.svelte', 'utf8'); - assert.match(homepage, /rowBatchSize=\{50\}/); - assert.doesNotMatch(profile, /rowBatchSize/); -}); - -test('show-more markup links a real button to the problem tbody and announces progress', () => { - const display = readFileSync('src/lib/components/ProblemDisplay.svelte', 'utf8'); - const table = readFileSync('src/lib/components/ProblemTable.svelte', 'utf8'); - assert.match(table, //); - assert.match(display, /type="button"/); - assert.match(display, /aria-controls=\{problemTableBodyId\}/); - assert.match(display, /`Show \$\{nextBatchCount\} more problems`/); - assert.match(display, /role="status"/); - assert.match(display, /aria-live="polite"/); - assert.match(display, /aria-disabled=\{nextBatchCount === 0\}/); -}); diff --git a/tests/theme-service.test.ts b/tests/theme-service.test.ts index 477993d..eababa6 100644 --- a/tests/theme-service.test.ts +++ b/tests/theme-service.test.ts @@ -2,7 +2,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { get, writable } from 'svelte/store'; import { createThemeService } from '../src/lib/services/theme.ts'; -import type { ResolvedTheme, ThemePreference } from '../src/lib/services/appearance.ts'; +import { + nextThemePreference, + type ResolvedTheme, + type ThemePreference +} from '../src/lib/services/appearance.ts'; import type { UserPreferences } from '../src/lib/services/user.ts'; function setup( @@ -70,6 +74,12 @@ function setup( }; } +test('theme preferences cycle from System to Light to Dark', () => { + assert.equal(nextThemePreference('system'), 'light'); + assert.equal(nextThemePreference('light'), 'dark'); + assert.equal(nextThemePreference('dark'), 'system'); +}); + test('initialization normalizes missing and invalid local preferences to System', () => { for (const value of [null, 'paper']) { const context = setup({ stored: value, systemDark: true });