diff --git a/javascript/site-sveltekit/README.md.jinja b/javascript/site-sveltekit/README.md.jinja index 35b8677..c9e2052 100644 --- a/javascript/site-sveltekit/README.md.jinja +++ b/javascript/site-sveltekit/README.md.jinja @@ -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-{{ site_adapter }}` deployment adapter. diff --git a/javascript/site-sveltekit/playwright.config.ts.jinja b/javascript/site-sveltekit/playwright.config.ts.jinja index 8165435..d3dfc15 100644 --- a/javascript/site-sveltekit/playwright.config.ts.jinja +++ b/javascript/site-sveltekit/playwright.config.ts.jinja @@ -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/javascript/site-sveltekit/src/app.css.jinja b/javascript/site-sveltekit/src/app.css.jinja index 3169048..751a8af 100644 --- a/javascript/site-sveltekit/src/app.css.jinja +++ b/javascript/site-sveltekit/src/app.css.jinja @@ -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/javascript/site-sveltekit/src/app.html.jinja b/javascript/site-sveltekit/src/app.html.jinja index a15015b..0e31c8a 100644 --- a/javascript/site-sveltekit/src/app.html.jinja +++ b/javascript/site-sveltekit/src/app.html.jinja @@ -4,6 +4,26 @@ + + %sveltekit.head% diff --git a/javascript/site-sveltekit/src/routes/+error.svelte.jinja b/javascript/site-sveltekit/src/routes/+error.svelte.jinja new file mode 100644 index 0000000..1938772 --- /dev/null +++ b/javascript/site-sveltekit/src/routes/+error.svelte.jinja @@ -0,0 +1,18 @@ + + + + {page.status} — {{ brand_name }} + + +
+
+

Error / {page.status}

+

This path went quiet.

+

+ {page.error?.message ?? 'The page could not be found.'} +

+ Return home → +
+
diff --git a/javascript/site-sveltekit/src/routes/+layout.svelte.jinja b/javascript/site-sveltekit/src/routes/+layout.svelte.jinja index 597dc30..1ba0973 100644 --- a/javascript/site-sveltekit/src/routes/+layout.svelte.jinja +++ b/javascript/site-sveltekit/src/routes/+layout.svelte.jinja @@ -1,7 +1,29 @@ @@ -13,4 +35,14 @@ + + {@render children()} diff --git a/javascript/site-sveltekit/src/routes/+layout.ts.jinja b/javascript/site-sveltekit/src/routes/+layout.ts.jinja index e14c55d..fdd4912 100644 --- a/javascript/site-sveltekit/src/routes/+layout.ts.jinja +++ b/javascript/site-sveltekit/src/routes/+layout.ts.jinja @@ -1 +1,4 @@ export const prerender = {{ 'true' if site_adapter == 'static' else 'false' }}; +{%- if site_adapter == 'static' %} +export const trailingSlash = 'always'; +{%- endif %} diff --git a/javascript/site-sveltekit/src/routes/sitemap.xml/+server.ts.jinja b/javascript/site-sveltekit/src/routes/sitemap.xml/+server.ts.jinja new file mode 100644 index 0000000..16cd96f --- /dev/null +++ b/javascript/site-sveltekit/src/routes/sitemap.xml/+server.ts.jinja @@ -0,0 +1,18 @@ +import type { RequestHandler } from './$types'; + +export const prerender = true; + +const pages = ['']; + +export const GET: RequestHandler = () => { + const urls = pages.map((page) => `{{ site_url }}/${page}`).join(''); + + return new Response( + `${urls}`, + { + headers: { + 'Content-Type': 'application/xml' + } + } + ); +}; diff --git a/javascript/site-sveltekit/static/favicon.svg.jinja b/javascript/site-sveltekit/static/favicon.svg.jinja new file mode 100644 index 0000000..6c5e898 --- /dev/null +++ b/javascript/site-sveltekit/static/favicon.svg.jinja @@ -0,0 +1,13 @@ + + + {{ brand_name[0] | upper }} + diff --git a/javascript/site-sveltekit/tests/a11y/audit.spec.ts.jinja b/javascript/site-sveltekit/tests/a11y/audit.spec.ts.jinja index 26877bd..f443181 100644 --- a/javascript/site-sveltekit/tests/a11y/audit.spec.ts.jinja +++ b/javascript/site-sveltekit/tests/a11y/audit.spec.ts.jinja @@ -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('{{ brand_name }}'); +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/javascript/site-sveltekit/tests/behavior/theme.spec.ts.jinja b/javascript/site-sveltekit/tests/behavior/theme.spec.ts.jinja new file mode 100644 index 0000000..fcf1a8f --- /dev/null +++ b/javascript/site-sveltekit/tests/behavior/theme.spec.ts.jinja @@ -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/javascript/site-sveltekit/tests/visual/pages.spec.ts.jinja b/javascript/site-sveltekit/tests/visual/pages.spec.ts.jinja new file mode 100644 index 0000000..1d0224f --- /dev/null +++ b/javascript/site-sveltekit/tests/visual/pages.spec.ts.jinja @@ -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 }); + }); + } +}