Compare commits

...

3 Commits

5 changed files with 140 additions and 2 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ describe('Exporter a11y/responsive enhancements', () => {
test('hero image alt derives from heading', () => {
const out = exportPage(makePage())
expect(out.html).toContain('<h1 id="hero-heading">Welcome</h1>')
expect(out.html).toContain('<img src="https://example.com/img.png" alt="Welcome" />')
expect(out.html).toMatch(/<img[^>]*src="https:\/\/example\.com\/img\.png"[^>]*alt="Welcome"[^>]*\/>/)
})
test('responsive and a11y CSS tokens exist', () => {
+14 -1
View File
@@ -108,7 +108,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
${subheading ? `<p>${escapeHtml(subheading)}</p>` : ''}
</div>
<div class="media">
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" />
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="lazy" decoding="async" />
</div>
</div>
</section>
@@ -237,6 +237,7 @@ function buildHead(page: Page) {
return `
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="canonical" href="/" />
<title>${escapeHtml(page.seo.title)}</title>
<meta name="description" content="${escapeHtml(page.seo.description)}" />
<meta property="og:title" content="${escapeHtml(page.seo.title)}" />
@@ -272,6 +273,7 @@ function buildCss(page: Page) {
.testimonial blockquote{ margin:0; font-style:italic; color:#1f2937 }
.testimonial figcaption{ color:#475569; margin-top:.25rem }
@media (max-width: 640px){ .container{ padding: 1rem } h1{ font-size: 1.5rem } }
@media (prefers-color-scheme: dark){ body{ color:#e5e7eb; background:#0b1020 } .feature-item{ background:#0f172a; border-color:#1f2937 } }
@media (prefers-reduced-motion: reduce){ *{ transition: none !important; animation: none !important } }
`
}
@@ -282,6 +284,17 @@ function buildJs() {
(function(){
var ts = document.querySelector('input[name="_ts"]');
if(ts){ ts.value = String(Date.now()); }
// Ensure skip-link moves focus into main for a11y
var skip = document.querySelector('a.skip-link');
var main = document.getElementById('main');
if (skip && main) {
skip.addEventListener('click', function(){
if (!main.hasAttribute('tabindex')) {
main.setAttribute('tabindex', '-1');
}
main.focus();
});
}
})();
`
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { exportPage } from '@/lib/exporter/html'
import { pageSchema, type Page } from '@/lib/schema/page'
function makePage(): Page {
return pageSchema.parse({
title: 'SEO/Perf',
locale: 'en',
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
sections: [
{ type: 'hero', props: { heading: 'Welcome', subheading: 'Sub', imageUrl: 'https://img.example/hero.png' } },
],
form: { actionUrl: '', fields: [], spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 } },
seo: { title: 'SEO', description: 'Desc' },
})
}
describe('Exporter - small SEO/Perf improvements', () => {
it('includes canonical link and dark mode tokens; hero image lazy/async', () => {
const out = exportPage(makePage())
const html = out.html
const css = out.css
// canonical link present in head
expect(html).toMatch(/<link[^>]+rel="canonical"/)
// hero image has loading/decoding attributes
expect(html).toMatch(/<img[^>]*loading="lazy"/)
expect(html).toMatch(/<img[^>]*decoding="async"/)
// dark mode media query token exists
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test'
type WindowWithExport = Window & {
__exportPreview?: () => { html: string; css: string; js: string }
}
const builderUrl = '/en/builder'
// Verify that focus outline tokens from CSS are applied in a real browser context
// by focusing the submit button in the exported page and inspecting computed styles.
test('Exported page shows 3px solid outline color on focused button', async ({ page, context }) => {
await page.goto(builderUrl)
// Minimal content to export
await page.getByTestId('card-hero').click()
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
expect(exported).not.toBeNull()
const preview = await context.newPage()
await preview.setContent(exported!.html)
await preview.addStyleTag({ content: exported!.css })
const submit = preview.locator('button[type="submit"]')
await expect(submit).toHaveCount(1)
await submit.focus()
// Read computed style in the page context
const style = await preview.evaluate(() => {
const el = document.querySelector('button[type="submit"]') as HTMLElement | null
if (!el) return null
const cs = window.getComputedStyle(el)
return { outlineWidth: cs.outlineWidth, outlineStyle: cs.outlineStyle, outlineColor: cs.outlineColor }
})
expect(style).not.toBeNull()
// Expect 3px solid and brand color (#2563eb => rgb(37, 99, 235))
expect(style!.outlineWidth).toBe('3px')
expect(style!.outlineStyle).toBe('solid')
expect(style!.outlineColor.replace(/\s+/g, '')).toMatch(/rgb\(37,99,235\)|#2563eb/i)
await preview.close()
})
+49
View File
@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test'
type WindowWithExport = Window & {
__exportPreview?: () => { html: string; css: string; js: string }
}
const builderUrl = '/en/builder'
// Verify tab order after activating the skip-link: focus should move into <main>
// Then the first Tab should reach a focusable descendant (e.g., form submit button)
test('Skip-link Enter moves focus into main; next Tab reaches first focusable', async ({ page, context }) => {
await page.goto(builderUrl)
await page.getByTestId('card-hero').click()
await page.waitForFunction(() => Boolean((window as unknown as WindowWithExport).__exportPreview), { timeout: 5000 })
const exported = await page.evaluate(() => (window as unknown as WindowWithExport).__exportPreview?.() ?? null)
expect(exported).not.toBeNull()
const preview = await context.newPage()
await preview.setContent(exported!.html)
await preview.addStyleTag({ content: exported!.css })
const skip = preview.locator('a.skip-link')
await expect(skip).toHaveCount(1)
await skip.focus()
await expect(skip).toBeFocused()
// Activate skip-link
await preview.keyboard.press('Enter')
// Focus should be inside #main or on #main
const focusedInMain = await preview.evaluate(() => {
const main = document.getElementById('main')!
const active = document.activeElement as HTMLElement | null
if (!active) return false
return active === main || main.contains(active)
})
expect(focusedInMain).toBeTruthy()
// Press Tab and expect a focusable inside main (submit button) to receive focus
await preview.keyboard.press('Tab')
const isSubmitFocused = await preview.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return !!active && active.tagName.toLowerCase() === 'button' && (active as HTMLButtonElement).type === 'submit'
})
expect(isSubmitFocused).toBeTruthy()
await preview.close()
})