diff --git a/.copier-answers.yaml b/.copier-answers.yaml
index 2d2834a..958898a 100644
--- a/.copier-answers.yaml
+++ b/.copier-answers.yaml
@@ -1,5 +1,5 @@
# Changes here will be overwritten by Copier
-_commit: 7522e24
+_commit: 195919b
_src_path: https://github.com/python-project-templates/base.git
add_extension: site-sveltekit
brand_name: Example
@@ -8,3 +8,4 @@ project_description: A fast, accessible SvelteKit website.
project_name: javascript-template-site-sveltekit
site_adapter: static
site_url: https://example.com
+
diff --git a/README.md b/README.md
index 8f0a2a8..2ac9cad 100644
--- a/README.md
+++ b/README.md
@@ -18,4 +18,7 @@ pnpm test
pnpm build
```
+Visual regression snapshots live beside `tests/visual/` and are skipped when `CI` is set. Record or
+refresh them locally with `pnpm test:e2e:update`.
+
This site uses SvelteKit's `adapter-static` deployment adapter.
diff --git a/playwright.config.ts b/playwright.config.ts
index 8165435..d3dfc15 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -2,10 +2,16 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests',
+ testMatch: ['**/*.spec.ts'],
+ testIgnore: process.env.CI ? ['**/visual/**'] : [],
+ snapshotPathTemplate: '{testDir}/{testFilePath}-snapshots/{arg}-{projectName}{ext}',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
+ expect: {
+ toHaveScreenshot: { maxDiffPixelRatio: 0.01, threshold: 0.2, animations: 'disabled', caret: 'hide' }
+ },
use: {
baseURL: 'http://127.0.0.1:4177',
trace: 'retain-on-failure',
@@ -18,9 +24,7 @@ export default defineConfig({
timeout: 120_000
},
projects: [
- {
- name: 'desktop',
- use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } }
- }
+ { name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } } },
+ { name: 'mobile', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } }
]
});
diff --git a/src/app.css b/src/app.css
index 3169048..751a8af 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,13 +1,35 @@
@import 'tailwindcss';
:root {
- color: #171717;
- background: #ffffff;
- font-family: system-ui, sans-serif;
+ --paper: #ffffff;
+ --ink: #171717;
+ --muted: #5c5c5c;
+ --line: #e4e4e4;
+
+ color-scheme: light;
+}
+
+:root[data-theme='dark'] {
+ --paper: #0b0b0b;
+ --ink: #f2f2f2;
+ --muted: #a1a1a1;
+ --line: #262626;
+
+ color-scheme: dark;
+}
+
+@theme inline {
+ --color-paper: var(--paper);
+ --color-ink: var(--ink);
+ --color-muted: var(--muted);
+ --color-line: var(--line);
}
body {
margin: 0;
+ color: var(--ink);
+ background: var(--paper);
+ font-family: system-ui, sans-serif;
}
a {
diff --git a/src/app.html b/src/app.html
index a15015b..0e31c8a 100644
--- a/src/app.html
+++ b/src/app.html
@@ -4,6 +4,26 @@
+
+
%sveltekit.head%
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..bc24881
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,18 @@
+
+
+
+ {page.status} — Example
+
+
+
+
+
Error / {page.status}
+
This path went quiet.
+
+ {page.error?.message ?? 'The page could not be found.'}
+
+
Return home →
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 373a21f..e8d4647 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,7 +1,29 @@
@@ -13,4 +35,14 @@
+
+
{@render children()}
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts
index 189f71e..ba58d86 100644
--- a/src/routes/+layout.ts
+++ b/src/routes/+layout.ts
@@ -1 +1,2 @@
export const prerender = true;
+export const trailingSlash = 'always';
diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts
new file mode 100644
index 0000000..bbf35ed
--- /dev/null
+++ b/src/routes/sitemap.xml/+server.ts
@@ -0,0 +1,18 @@
+import type { RequestHandler } from './$types';
+
+export const prerender = true;
+
+const pages = [''];
+
+export const GET: RequestHandler = () => {
+ const urls = pages.map((page) => `https://example.com/${page}`).join('');
+
+ return new Response(
+ `${urls}`,
+ {
+ headers: {
+ 'Content-Type': 'application/xml'
+ }
+ }
+ );
+};
diff --git a/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..2a7bdfb
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1,13 @@
+
diff --git a/tests/a11y/audit.spec.ts b/tests/a11y/audit.spec.ts
index 040bf0f..f443181 100644
--- a/tests/a11y/audit.spec.ts
+++ b/tests/a11y/audit.spec.ts
@@ -1,10 +1,21 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
-test('home page has no automatically detectable accessibility violations', async ({ page }) => {
- await page.goto('/');
- await expect(page).toHaveTitle('Example');
+const routes = ['/'];
- const results = await new AxeBuilder({ page }).analyze();
- expect(results.violations).toEqual([]);
-});
+for (const route of routes) {
+ for (const theme of ['light', 'dark'] as const) {
+ test(`${route} has no serious or critical accessibility violations in ${theme} mode`, async ({ page }) => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+ await page.addInitScript((value) => localStorage.setItem('theme', value), theme);
+ await page.goto(route);
+
+ const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
+ const violations = results.violations.filter((violation) =>
+ ['serious', 'critical'].includes(violation.impact ?? '')
+ );
+
+ expect(violations).toEqual([]);
+ });
+ }
+}
diff --git a/tests/behavior/theme.spec.ts b/tests/behavior/theme.spec.ts
new file mode 100644
index 0000000..fcf1a8f
--- /dev/null
+++ b/tests/behavior/theme.spec.ts
@@ -0,0 +1,15 @@
+import { expect, test } from '@playwright/test';
+
+test('theme selection persists across navigation', async ({ page }) => {
+ await page.goto('/');
+ await page.evaluate(() => localStorage.removeItem('theme'));
+ await page.reload();
+
+ const initial = await page.locator('html').getAttribute('data-theme');
+ await page.getByTestId('theme-toggle').click();
+ const expected = initial === 'dark' ? 'light' : 'dark';
+
+ await expect(page.locator('html')).toHaveAttribute('data-theme', expected);
+ await page.reload();
+ await expect(page.locator('html')).toHaveAttribute('data-theme', expected);
+});
diff --git a/tests/visual/pages.spec.ts b/tests/visual/pages.spec.ts
new file mode 100644
index 0000000..1d0224f
--- /dev/null
+++ b/tests/visual/pages.spec.ts
@@ -0,0 +1,13 @@
+import { expect, test } from '@playwright/test';
+
+const routes = [{ name: 'home', path: '/' }];
+
+for (const route of routes) {
+ for (const theme of ['light', 'dark'] as const) {
+ test(`${route.name} in ${theme} mode`, async ({ page }) => {
+ await page.addInitScript((value) => localStorage.setItem('theme', value), theme);
+ await page.goto(route.path);
+ await expect(page).toHaveScreenshot(`${route.name}-${theme}.png`, { fullPage: true });
+ });
+ }
+}