Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ test.describe('header session and appearance controls', () => {
test.skip(!!viewport && viewport.width < 1024, 'desktop appearance popover only');
await page.goto('/');
const button = page.getByRole('button', { name: 'Appearance' });
const target = await button.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');
Expand Down Expand Up @@ -258,19 +262,44 @@ test.describe('mobile navigation', () => {
});
});

test.describe('signed-in settings appearance', () => {
test('uses an sr-only h1 and persists the selected account appearance', async ({ page }) => {
test.describe('signed-in settings layout and appearance', () => {
test('keeps appearance in the header and leaves space above the footer', async ({
page,
viewport
}) => {
await seedMemberSession(page);
await page.goto('/settings');
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 {
await page.getByRole('button', { name: 'Open menu' }).click();
}
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 expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect.poll(() => page.evaluate(() => localStorage.getItem('gitgud-theme'))).toBe('dark');
await expect(page.getByText('Saved', { exact: true })).toBeVisible();

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.getByText(/Currently resolved to dark/i)).toBeVisible();

const lastCard = page.locator('main section').last();
const footer = page.locator('footer');
const [cardBox, footerBox] = await Promise.all([lastCard.boundingBox(), footer.boundingBox()]);
expect(cardBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect(footerBox!.y - (cardBox!.y + cardBox!.height)).toBeGreaterThanOrEqual(60);
});
});

Expand Down
65 changes: 61 additions & 4 deletions src/lib/components/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,28 @@
<button
bind:this={appearanceButton}
type="button"
class="rounded border border-[var(--color-border)] bg-transparent px-3 py-2 text-sm font-bold text-[var(--color-text)] hover:bg-[var(--color-tertiary)]"
class="flex min-h-11 min-w-11 items-center justify-center rounded border border-[var(--color-border)] bg-transparent text-[var(--color-text)] hover:bg-[var(--color-tertiary)]"
aria-label="Appearance"
title="Appearance"
aria-haspopup="true"
aria-expanded={appearanceOpen}
aria-controls="appearance-popover"
on:click={() => (appearanceOpen = !appearanceOpen)}
>
Appearance
<svg
aria-hidden="true"
viewBox="0 0 24 24"
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<circle cx="12" cy="12" r="4"></circle>
<path
d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42"
></path>
</svg>
</button>
{#if appearanceOpen}
<div
Expand Down Expand Up @@ -222,7 +237,28 @@
rel="noopener noreferrer external"
class="text-sm font-medium text-[var(--color-username)]">@{username}</a
>
<a href={resolve('/settings')} class="p-2" aria-label="Settings">Settings</a>
<a
href={resolve('/settings')}
class="flex min-h-11 min-w-11 items-center justify-center rounded-full text-[var(--color-text)] hover:bg-[var(--color-tertiary)] hover:text-[var(--color-accent)]"
title="Settings"
aria-label="Settings"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</a>
<button
class="rounded border border-[var(--color-border)] px-3 py-2 text-sm font-bold disabled:cursor-wait disabled:opacity-70"
on:click={handleLogout}
Expand Down Expand Up @@ -311,7 +347,28 @@
{:else if user}
<div class="flex flex-wrap items-center gap-3">
<a href={githubUrl} target="_blank" rel="noopener noreferrer external">@{username}</a>
<a href={resolve('/settings')} class="flex min-h-11 items-center">Settings</a>
<a
href={resolve('/settings')}
class="flex min-h-11 min-w-11 items-center justify-center rounded-full text-[var(--color-text)] hover:bg-[var(--color-tertiary)] hover:text-[var(--color-accent)]"
title="Settings"
aria-label="Settings"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</a>
<button
class="min-h-11 border border-[var(--color-border)] px-3 disabled:cursor-wait disabled:opacity-70"
on:click={handleLogout}
Expand Down
68 changes: 4 additions & 64 deletions src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@
import { onMount } from 'svelte';
import type { Unsubscriber } from 'svelte/store';
import { currentActor, getCurrentActor, resolveCurrentActor } from '$lib/auth/currentActor';
import {
currentTheme,
setThemePreference,
THEME_PREFERENCES,
type ThemePreference
} from '$lib/services/theme';
import {
fetchUserPreferences,
updateLeaderboardPrivacyForUser,
Expand All @@ -27,11 +21,6 @@
type ImportProvider = 'codeforces' | 'kattis';

const importProviders: ImportProvider[] = ['codeforces', 'kattis'];
const appearanceDescriptions: Record<ThemePreference, string> = {
system: 'Follow your device appearance and update when it changes.',
light: 'Always use the Paper theme.',
dark: 'Always use the Dark Ink theme.'
};

let preferences: UserPreferences = { hideFromLeaderboard: false, theme: 'system' };
let loading = true;
Expand All @@ -52,7 +41,7 @@
let codeforcesPreview: SolveMatchResult | null = null;
let kattisPreview: KattisSolveMatchResult | null = null;
let fileSelectionRevision = 0;
let preferenceSaveRevision = 0;
let privacySaveRevision = 0;
$: importPreview = importProvider === 'codeforces' ? codeforcesPreview : kattisPreview;

function clearImportState(): void {
Expand Down Expand Up @@ -151,30 +140,17 @@
}
}

async function selectAppearance(theme: ThemePreference): Promise<void> {
const revision = ++preferenceSaveRevision;
preferences = { ...preferences, theme };
saving = true;
error = null;
success = null;
const saved = await setThemePreference(theme);
if (revision !== preferenceSaveRevision) return;
if (saved) success = 'Saved';
else error = 'Failed to save';
saving = false;
}

async function toggleHideFromLeaderboard(): Promise<void> {
const userId = getCurrentActor().user?.id;
if (!userId) return;
const revision = ++preferenceSaveRevision;
const revision = ++privacySaveRevision;
const nextValue = !preferences.hideFromLeaderboard;
preferences = { ...preferences, hideFromLeaderboard: nextValue };
saving = true;
error = null;
success = null;
const saved = await updateLeaderboardPrivacyForUser(userId, nextValue);
if (revision !== preferenceSaveRevision) return;
if (revision !== privacySaveRevision) return;
if (saved) success = 'Saved';
else error = 'Failed to save';
saving = false;
Expand Down Expand Up @@ -242,43 +218,7 @@
</div>
</section>

<section class="mt-6 overflow-hidden border-2 border-[var(--color-border)]">
<h2 class="border-b-2 border-[var(--color-border)] bg-[var(--color-tertiary)] p-4 font-bold">
Appearance
</h2>
<fieldset class="bg-[var(--color-secondary)] p-4">
<legend class="sr-only">Choose appearance</legend>
<div class="grid gap-3 md:grid-cols-3">
{#each THEME_PREFERENCES as theme (theme)}
<label
class="flex min-h-28 cursor-pointer gap-3 border-2 border-[var(--color-border)] p-4 has-[:checked]:border-[var(--color-accent)] has-[:checked]:bg-[var(--color-tertiary)]"
>
<input
type="radio"
name="settings-appearance"
value={theme}
checked={preferences.theme === theme}
disabled={saving}
on:change={() => selectAppearance(theme)}
/>
<span>
<span class="block font-bold text-[var(--color-heading)] capitalize">{theme}</span>
<span class="mt-1 block text-sm text-[var(--color-text-muted)]">
{appearanceDescriptions[theme]}
</span>
{#if preferences.theme === theme}
<span class="mt-2 block text-xs text-[var(--color-text)]">
Currently resolved to {$currentTheme}.
</span>
{/if}
</span>
</label>
{/each}
</div>
</fieldset>
</section>

<section class="mt-6 overflow-hidden border-2 border-[var(--color-border)]">
<section class="mt-6 mb-16 overflow-hidden border-2 border-[var(--color-border)] md:mb-20">
<h2 class="border-b-2 border-[var(--color-border)] bg-[var(--color-tertiary)] p-4 font-bold">
Import solved problems
</h2>
Expand Down
21 changes: 21 additions & 0 deletions tests/settings-a11y.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@ test('error status uses the semantic error color', () => {
);
});

test('appearance is controlled from the header instead of duplicated in settings', () => {
assert.doesNotMatch(
SETTINGS,
/>\s*Appearance\s*</,
'settings must not render an Appearance section'
);
assert.doesNotMatch(
SETTINGS,
/name="settings-appearance"/,
'settings must not duplicate theme radios'
);
});

test('the settings page leaves deliberate space above the footer', () => {
assert.match(
SETTINGS,
/<section class="[^"]*mb-16[^"]*md:mb-20[^"]*">\s*<h2[^>]*>\s*Import solved problems/,
'the final settings card must retain responsive footer separation'
);
});

test('the privacy toggle has a keyboard-visible accent ring, not invisible primary', () => {
const toggles = [...SETTINGS.matchAll(/<button[^>]*role="switch"[\s\S]*?>/g)].map((m) => m[0]);
assert.equal(toggles.length, 1, 'expected exactly one role="switch" toggle');
Expand Down
Loading