Skip to content

Commit 4b39519

Browse files
authored
Merge pull request #431 from raifdmueller/fix/lighthouse-a11y-perf
fix: Lighthouse a11y + CLS issues on homepage
2 parents ddcf64e + f999826 commit 4b39519

6 files changed

Lines changed: 36 additions & 12 deletions

File tree

website/src/components/card-grid.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,21 @@ function renderCategorySection(category, allAnchors) {
9090
</h2>
9191
9292
<div class="anchor-cards-grid">
93-
${categoryAnchors.map((anchor) => renderAnchorCard(anchor, color)).join('')}
93+
${categoryAnchors.map((anchor) => renderAnchorCard(anchor, color, category.id)).join('')}
9494
</div>
9595
</section>
9696
`
9797
}
9898

9999
/**
100-
* Render a single anchor card
100+
* Render a single anchor card.
101+
*
102+
* The optional `categoryId` argument is used to namespace the heading id
103+
* used by `aria-labelledby`, since the same anchor may appear in multiple
104+
* category sections (anchors can belong to more than one category) and
105+
* the DOM must not contain duplicate ids.
101106
*/
102-
function renderAnchorCard(anchor, categoryColor) {
107+
function renderAnchorCard(anchor, categoryColor, categoryId) {
103108
const isUmbrella = anchor.subAnchors && anchor.subAnchors.length > 0
104109
const umbrellaClass = isUmbrella ? ' anchor-card-umbrella' : ''
105110
const rolesCount = anchor.roles ? anchor.roles.length : 0
@@ -110,19 +115,21 @@ function renderAnchorCard(anchor, categoryColor) {
110115
const editTitle = i18n.t('card.edit')
111116
const copyLinkTitle = i18n.t('card.copyLink')
112117
const safeId = escapeHtml(anchor.id)
118+
const safeCategoryId = escapeHtml(categoryId || 'uncat')
119+
const cardTitleId = `anchor-card-title-${safeCategoryId}-${safeId}`
113120

114121
return `
115-
<article
122+
<div
116123
class="anchor-card${umbrellaClass}"
117124
data-anchor="${safeId}"
118125
data-roles="${escapeHtml(anchor.roles ? anchor.roles.join(',') : '')}"
119126
data-tags="${escapeHtml(anchor.tags ? anchor.tags.join(',') : '')}"
120127
tabindex="0"
121128
role="button"
122-
aria-label="${escapeHtml(i18n.t('card.openDetails').replace('{title}', anchor.title))}"
129+
aria-labelledby="${cardTitleId}"
123130
>
124131
<div class="anchor-card-header">
125-
<h3 class="anchor-card-title">${escapeHtml(anchor.title)}</h3>
132+
<h3 id="${cardTitleId}" class="anchor-card-title">${escapeHtml(anchor.title)}</h3>
126133
<div class="flex gap-1">
127134
<button
128135
class="anchor-copy-link-btn"
@@ -215,7 +222,7 @@ function renderAnchorCard(anchor, categoryColor) {
215222
</div>
216223
217224
<div class="anchor-card-indicator" style="background-color: ${categoryColor}"></div>
218-
</article>
225+
</div>
219226
`
220227
}
221228

website/src/components/header.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function renderHeader() {
1111
<!-- Logo left, spanning both rows -->
1212
<div class="flex items-center">
1313
<a href="#/" class="no-underline flex flex-col items-center">
14-
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" class="max-h-24" />
14+
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" width="218" height="96" class="max-h-24 w-auto" />
1515
<span class="text-xs text-[var(--color-text-secondary)] leading-tight" data-i18n="header.slogan">${i18n.t('header.slogan')}</span>
1616
</a>
1717
<button
@@ -54,7 +54,8 @@ export function renderHeader() {
5454
<button
5555
id="lang-toggle"
5656
class="rounded-md px-2 py-1 text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] transition-colors"
57-
aria-label="Toggle language"
57+
aria-label="${i18n.t('header.langToggleAria')}"
58+
data-i18n-aria="header.langToggleAria"
5859
>${langLabel}</button>
5960
<button
6061
id="theme-toggle"
@@ -82,6 +83,8 @@ export function renderHeader() {
8283
/>
8384
<select
8485
id="header-role-filter"
86+
aria-label="${i18n.t('filter.allRoles')}"
87+
data-i18n-aria="filter.allRoles"
8588
class="rounded-lg border-2 border-[var(--color-border)] bg-[var(--color-bg)] px-4 py-2 text-base text-[var(--color-text)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] transition-colors duration-300"
8689
>
8790
<option value="" data-i18n="filter.allRoles">${i18n.t('filter.allRoles')}</option>
@@ -97,7 +100,7 @@ export function renderHeader() {
97100
<div class="sm:hidden">
98101
<div class="flex flex-col items-center">
99102
<a href="#/" class="no-underline flex flex-col items-center">
100-
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" class="max-h-16" />
103+
<img src="${import.meta.env.BASE_URL}logo.png" alt="Semantic Anchors" width="145" height="64" class="max-h-16 w-auto" />
101104
<span class="text-xs text-[var(--color-text-secondary)] leading-tight text-center" data-i18n="header.slogan">${i18n.t('header.slogan')}</span>
102105
</a>
103106
<div class="flex items-center gap-3 mt-2">
@@ -117,7 +120,8 @@ export function renderHeader() {
117120
<button
118121
id="lang-toggle-mobile"
119122
class="rounded-md px-2 py-1 text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] transition-colors"
120-
aria-label="Toggle language"
123+
aria-label="${i18n.t('header.langToggleAria')}"
124+
data-i18n-aria="header.langToggleAria"
121125
>${langLabel}</button>
122126
<button
123127
id="theme-toggle-mobile"

website/src/components/onboarding-modal.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function buildModalContent() {
101101
</div>
102102
103103
<div class="text-center px-6 pb-4">
104-
<img src="${baseUrl}logo.png" alt="Semantic Anchors" class="h-24 mx-auto mb-3" />
104+
<img src="${baseUrl}logo.png" alt="Semantic Anchors" width="218" height="96" class="h-24 w-auto mx-auto mb-3" />
105105
<h2 id="onboarding-title" class="text-lg font-medium text-[var(--color-text-secondary)]">
106106
${slogan2}
107107
</h2>

website/src/styles/main.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ body {
6565
font-family: Inter, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
6666
}
6767

68+
/* Reserve viewport height so the browser doesn't shift layout when the
69+
card grid / doc content loads asynchronously. This is the single biggest
70+
lever for Cumulative Layout Shift on the homepage (Lighthouse weight 25). */
71+
#page-content {
72+
min-height: calc(100vh - 12rem);
73+
}
74+
6875
/* Force sans-serif on all card descendants to override AsciiDoc serif fonts */
6976
.anchor-card,
7077
.anchor-card *,
@@ -164,6 +171,10 @@ body {
164171
@apply flex items-center gap-1;
165172
@apply px-2 py-1 rounded;
166173
@apply bg-gray-100 dark:bg-gray-700;
174+
/* Explicit text color for WCAG AA contrast on the badge background.
175+
Default .anchor-card-meta text (gray-500/400) only reaches ~3.9:1 / ~3.1:1,
176+
below the 4.5:1 threshold for normal text. */
177+
@apply text-gray-700 dark:text-gray-200;
167178
}
168179

169180
.feedback-badge {

website/src/translations/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"app.title": "Semantic Anchors",
33
"app.description": "Gemeinsames Vokabular für LLM-Kommunikation",
44
"header.langToggle": "EN",
5+
"header.langToggleAria": "EN — zu Englisch wechseln",
56
"header.slogan": "Ein Wort, und die KI versteht den Rest.",
67
"header.themeToggle.dark": "Zum Dunkelmodus wechseln",
78
"header.themeToggle.light": "Zum Hellmodus wechseln",

website/src/translations/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"app.title": "Semantic Anchors",
33
"app.description": "Shared vocabulary for LLM communication",
44
"header.langToggle": "DE",
5+
"header.langToggleAria": "DE — switch to German",
56
"header.slogan": "One word, and the AI gets the rest.",
67
"header.themeToggle.dark": "Switch to dark mode",
78
"header.themeToggle.light": "Switch to light mode",

0 commit comments

Comments
 (0)