diff --git a/app/globals.css b/app/globals.css
index d1f74a0..c49a048 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -4,6 +4,12 @@
@import "./theme/spacing.css";
@import "./theme/containers.css";
+/* Hash focus from the skip link. Beats the preflight :focus-visible outline. */
+#conteudo:focus,
+#conteudo:focus-visible {
+ outline: none;
+}
+
@custom-variant dark (&:where(.dark, .dark *));
@theme {
diff --git a/components/layout/site-shell.tsx b/components/layout/site-shell.tsx
index 3d99d47..14a2bab 100644
--- a/components/layout/site-shell.tsx
+++ b/components/layout/site-shell.tsx
@@ -7,7 +7,9 @@ import { Footer } from '@/components/layout/footer'
import { Header } from '@/components/layout/header'
import { LoadingBar } from '@/components/layout/loading-indicator'
import { NavigationProgress } from '@/components/layout/navigation-progress'
+import { SkipLink } from '@/components/layout/skip-link'
import { ThemeProvider } from '@/components/theme-provider'
+import { getDictionary } from '@/lib/i18n/dictionary'
import { htmlLang, type InterfaceLanguage } from '@/lib/i18n/types'
import '@/app/globals.css'
@@ -31,6 +33,8 @@ export function SiteShell({
language: InterfaceLanguage
children: ReactNode
}) {
+ const skipLabel = getDictionary(language).skipLink.label
+
return (
+
- {children}
+
+ {children}
+
diff --git a/components/layout/skip-link.test.tsx b/components/layout/skip-link.test.tsx
new file mode 100644
index 0000000..71f6bdb
--- /dev/null
+++ b/components/layout/skip-link.test.tsx
@@ -0,0 +1,22 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, test } from 'vitest'
+import { SkipLink } from './skip-link'
+
+afterEach(() => {
+ cleanup()
+})
+
+test('points at #conteudo in Portuguese and English', () => {
+ const { unmount } = render()
+
+ const link = screen.getByRole('link', { name: 'Pular para o conteúdo' })
+ expect(link.getAttribute('href')).toBe('#conteudo')
+ expect(link.closest('nav')).toBeNull()
+
+ unmount()
+ render()
+
+ expect(
+ screen.getByRole('link', { name: 'Skip to content' }).getAttribute('href'),
+ ).toBe('#conteudo')
+})
diff --git a/components/layout/skip-link.tsx b/components/layout/skip-link.tsx
new file mode 100644
index 0000000..5e563ae
--- /dev/null
+++ b/components/layout/skip-link.tsx
@@ -0,0 +1,19 @@
+// not-sr-only sets position: static and fights with fixed. Reset the clip on focus.
+const skipLinkClassName = [
+ 'sr-only fixed z-50 bg-background text-sm font-medium text-foreground',
+ 'outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35',
+ 'focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ 'focus-visible:fixed focus-visible:top-4 focus-visible:left-4',
+ 'focus-visible:m-0 focus-visible:h-auto focus-visible:w-auto',
+ 'focus-visible:overflow-visible focus-visible:px-4 focus-visible:py-2',
+ 'focus-visible:whitespace-normal focus-visible:rounded-md',
+ 'focus-visible:[clip-path:none]',
+].join(' ')
+
+export function SkipLink({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ )
+}
diff --git a/components/views/blog-view.test.tsx b/components/views/blog-view.test.tsx
index 4e7a1b3..6bbe1c9 100644
--- a/components/views/blog-view.test.tsx
+++ b/components/views/blog-view.test.tsx
@@ -89,14 +89,23 @@ test('list view formats month and year in English', () => {
expect(within(articleLink).getByRole('time').textContent).toBe('Sep 2026')
})
-test('grid view hides descriptions and shows cover images', () => {
- renderBlog('pt', { view: 'grid' })
+test('grid view hides descriptions and treats covers as decorative', () => {
+ const { container } = renderBlog('pt', { view: 'grid' })
expect(
screen.queryByText('Um passeio pelo pipeline de ponta a ponta.'),
).toBeNull()
- expect(screen.getAllByRole('img')).toHaveLength(3)
- expect(screen.getByRole('img', { name: 'Hello World' })).toBeTruthy()
+ const covers = container.querySelectorAll('img')
+ expect(covers).toHaveLength(3)
+ for (const cover of covers) {
+ expect(cover.getAttribute('alt')).toBe('')
+ }
+ expect(
+ screen.getByRole('link', {
+ name: (accessibleName) =>
+ accessibleName.split('Hello World').length - 1 === 1,
+ }),
+ ).toBeTruthy()
})
test('view switch links preserve the current page', () => {
diff --git a/components/views/blog-view.tsx b/components/views/blog-view.tsx
index b250876..7993e31 100644
--- a/components/views/blog-view.tsx
+++ b/components/views/blog-view.tsx
@@ -116,7 +116,7 @@ function GridCard({
{coverSrc ? (
({
+ default: function MockImage({ alt, src }: { alt: string; src: string }) {
+ return
+ },
+}))
+
+vi.mock('@/lib/content/mdx', () => ({
+ MdxContent: ({ source }: { source: string }) => {source}
,
+}))
+
+vi.mock('next/link', () => ({
+ default: function MockLink({
+ children,
+ href,
+ className,
+ }: {
+ children: ReactNode
+ href: string
+ className?: string
+ }) {
+ return (
+
+ {children}
+
+ )
+ },
+}))
+
+const post: Post = {
+ slug: 'hello-world',
+ content: 'Body',
+ frontmatter: {
+ title: 'Hello World',
+ description: 'A post',
+ date: new Date('2026-09-21T00:00:00.000Z'),
+ tags: [],
+ cover: 'hello-world.png',
+ language: 'pt',
+ },
+}
+
+afterEach(() => {
+ cleanup()
+})
+
+test('wraps the article frame in main and keeps the top button outside the frame', () => {
+ const { container } = render()
+
+ const main = screen.getByRole('main')
+ const frame = container.querySelector('.article-frame')
+ const article = container.querySelector('article')
+ expect(frame).toBeTruthy()
+ expect(main.contains(frame)).toBe(true)
+ expect(frame?.contains(article)).toBe(true)
+ expect(article?.getAttribute('data-analytics-source')).toBe('post')
+
+ const backToTop = screen.getByRole('button', { name: 'Voltar ao topo' })
+ expect(main.contains(backToTop)).toBe(true)
+ expect(frame?.contains(backToTop)).toBe(false)
+ expect(container.querySelector('img')?.getAttribute('alt')).toBe('')
+})
diff --git a/components/views/post-article.tsx b/components/views/post-article.tsx
index 96c4f35..fa454d2 100644
--- a/components/views/post-article.tsx
+++ b/components/views/post-article.tsx
@@ -69,7 +69,7 @@ export function PostArticle({
: undefined
return (
- <>
+
- >
+
)
}
diff --git a/components/views/view-mode-switch.test.tsx b/components/views/view-mode-switch.test.tsx
new file mode 100644
index 0000000..48502e8
--- /dev/null
+++ b/components/views/view-mode-switch.test.tsx
@@ -0,0 +1,52 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, expect, test } from 'vitest'
+import { ViewModeSwitch } from './view-mode-switch'
+
+const options = [
+ { id: 'list' as const, label: 'Lista', icon: list },
+ { id: 'grid' as const, label: 'Grade', icon: grid },
+]
+
+afterEach(() => {
+ cleanup()
+})
+
+test('link mode marks the committed view with aria-current', () => {
+ render(
+ (id === 'list' ? '/blog' : '/blog?view=grid')}
+ options={options}
+ />,
+ )
+
+ const list = screen.getByRole('link', { name: 'Lista' })
+ const grid = screen.getByRole('link', { name: 'Grade' })
+
+ expect(list.getAttribute('aria-current')).toBe('page')
+ expect(grid.hasAttribute('aria-current')).toBe(false)
+ expect(list.hasAttribute('aria-pressed')).toBe(false)
+ expect(grid.hasAttribute('aria-pressed')).toBe(false)
+ expect(list.className.split(/\s+/)).toContain('text-foreground')
+})
+
+test('button mode keeps aria-pressed and omits aria-current', () => {
+ render(
+ ,
+ )
+
+ const list = screen.getByRole('button', { name: 'Lista' })
+ const grid = screen.getByRole('button', { name: 'Grade' })
+
+ expect(grid.getAttribute('aria-pressed')).toBe('true')
+ expect(list.getAttribute('aria-pressed')).toBe('false')
+ expect(list.hasAttribute('aria-current')).toBe(false)
+ expect(grid.hasAttribute('aria-current')).toBe(false)
+})
diff --git a/components/views/view-mode-switch.tsx b/components/views/view-mode-switch.tsx
index 3ee1df1..6cf5d32 100644
--- a/components/views/view-mode-switch.tsx
+++ b/components/views/view-mode-switch.tsx
@@ -90,7 +90,7 @@ export function ViewModeSwitch({
{
+ await skipToContent(page, '/')
+})
+
+test('first Tab skips to content on a post', async ({ page }) => {
+ await skipToContent(page, '/blog/meu-blog-profissional')
+})
+
+test('open mobile menu makes the skip link inert', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.goto('/')
+ await page.getByRole('button', { name: 'Menu' }).click()
+
+ const skip = page.locator('a[href="#conteudo"]')
+ await expect(skip).toHaveAttribute('inert')
+
+ for (let step = 0; step < 8; step += 1) {
+ await page.keyboard.press('Tab')
+ await expect(skip).not.toBeFocused()
+ }
+})
+
+test.describe('without JavaScript', () => {
+ test.use({ javaScriptEnabled: false })
+
+ test('Enter then Tab reaches the first content link on the home page', async ({
+ page,
+ }) => {
+ await skipWithoutJavaScript(page, '/')
+ })
+
+ test('Enter then Tab reaches the first content link on a post', async ({
+ page,
+ }) => {
+ await skipWithoutJavaScript(page, '/blog/meu-blog-profissional')
+ })
+})
+
+async function skipWithoutJavaScript(page: Page, path: string) {
+ await page.goto(path)
+ await page.keyboard.press('Tab')
+ await expect(
+ page.getByRole('link', { name: 'Pular para o conteúdo' }),
+ ).toBeFocused()
+
+ await page.keyboard.press('Enter')
+ await expect(page.locator('#conteudo')).toBeFocused()
+
+ await page.keyboard.press('Tab')
+ await expect(page.locator('#conteudo a').first()).toBeFocused()
+}
diff --git a/lib/i18n/dictionary.test.ts b/lib/i18n/dictionary.test.ts
index 8648a9b..42fe83d 100644
--- a/lib/i18n/dictionary.test.ts
+++ b/lib/i18n/dictionary.test.ts
@@ -23,6 +23,8 @@ test('pt and en dictionaries share the same key shape', () => {
expect(Object.keys(en.language)).toEqual(Object.keys(pt.language))
expect(Object.keys(pt.mobileMenu)).toEqual(['trigger', 'navAriaLabel'])
expect(Object.keys(en.mobileMenu)).toEqual(Object.keys(pt.mobileMenu))
+ expect(Object.keys(pt.skipLink)).toEqual(['label'])
+ expect(Object.keys(en.skipLink)).toEqual(Object.keys(pt.skipLink))
expect(Object.keys(pt.navigation)).toEqual(['loading'])
expect(Object.keys(en.navigation)).toEqual(Object.keys(pt.navigation))
})
@@ -71,6 +73,8 @@ test('includes theme, language switch, and mobile menu copy in both languages',
expect(en.language.switchToPt).toBe('Switch to Portuguese')
expect(pt.mobileMenu.trigger).toBe('Menu')
expect(en.mobileMenu.navAriaLabel).toBe('Main navigation')
+ expect(pt.skipLink.label).toBe('Pular para o conteúdo')
+ expect(en.skipLink.label).toBe('Skip to content')
expect(pt.navigation.loading).toBe('O conteúdo está sendo carregado')
expect(en.navigation.loading).toBe('The content is loading')
})
diff --git a/lib/i18n/dictionary.ts b/lib/i18n/dictionary.ts
index d7d4069..0742c90 100644
--- a/lib/i18n/dictionary.ts
+++ b/lib/i18n/dictionary.ts
@@ -21,6 +21,9 @@ export type UiDictionary = {
trigger: string
navAriaLabel: string
}
+ skipLink: {
+ label: string
+ }
navigation: {
loading: string
}
@@ -42,6 +45,9 @@ const uiCopy: Record> = {
trigger: 'Menu',
navAriaLabel: 'Navegação principal',
},
+ skipLink: {
+ label: 'Pular para o conteúdo',
+ },
navigation: {
loading: 'O conteúdo está sendo carregado',
},
@@ -61,6 +67,9 @@ const uiCopy: Record> = {
trigger: 'Menu',
navAriaLabel: 'Main navigation',
},
+ skipLink: {
+ label: 'Skip to content',
+ },
navigation: {
loading: 'The content is loading',
},