Skip to content

Commit a811abe

Browse files
committed
fix: accessibility improvements for creative FAL images
- Add alt="" fallback when LLM omits alt attribute (WCAG 1.1.1) - Prevent double-encoding of pre-escaped alt values (double_encode=false) - Add aria-label to image search inputs (WCAG 1.3.1, 3.3.2) - Add aria-pressed to image cards for selection state (screen readers) - Add checkmark overlay on selected images (WCAG 1.4.1 - not color-only) - Add border-2 on selected cards for stronger visual distinction - Add role="group" + aria-label to image card containers - Add aria-expanded to source/preview toggle button - Add role="status" + aria-live="polite" to empty-search alerts - Increase badge font size from 0.65rem to 0.75rem for contrast - Add tests for alt fallback and double-encoding prevention
1 parent e7e1647 commit a811abe

File tree

3 files changed

+107
-11
lines changed

3 files changed

+107
-11
lines changed

Classes/Service/PageCreatorService.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,22 @@ static function (array $matches) use ($publicUrl): string {
315315
$resolved,
316316
) ?? $resolved;
317317

318-
// Escape alt attribute value for XSS safety
318+
// Escape alt attribute value for XSS safety (double_encode=false prevents &)
319319
$resolved = preg_replace_callback(
320320
'/\balt="([^"]*)"/',
321-
static fn(array $m): string => 'alt="' . htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8') . '"',
321+
static fn(array $m): string => 'alt="' . htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8', false) . '"',
322322
$resolved,
323323
) ?? $resolved;
324324

325+
// Ensure alt attribute exists (WCAG 1.1.1)
326+
if (!preg_match('/\balt\s*=/', $resolved)) {
327+
$resolved = preg_replace(
328+
'/<img\b/',
329+
'<img alt=""',
330+
$resolved,
331+
) ?? $resolved;
332+
}
333+
325334
return $resolved;
326335
},
327336
$bodytext,

Resources/Public/JavaScript/wizard.js

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,8 @@ class LandingPageWizard {
725725

726726
const imageList = document.createElement('div');
727727
imageList.className = 'd-flex gap-2 flex-wrap mb-2';
728+
imageList.setAttribute('role', 'group');
729+
imageList.setAttribute('aria-label', this.label('wizard.content.imageSuggestions'));
728730

729731
const sectionImages = (images[index] && images[index].length > 0) ? images[index] : [];
730732
this.renderImageCards(imageList, sectionImages, index);
@@ -735,6 +737,8 @@ class LandingPageWizard {
735737
const emptyInfo = document.createElement('div');
736738
emptyInfo.className = 'alert alert-info py-2 px-3 mb-2';
737739
emptyInfo.style.fontSize = '0.85em';
740+
emptyInfo.setAttribute('role', 'status');
741+
emptyInfo.setAttribute('aria-live', 'polite');
738742
emptyInfo.textContent = this.label('wizard.content.imageAutoSearchEmpty', keywords.join(', '));
739743
imageSection.appendChild(emptyInfo);
740744
}
@@ -749,6 +753,7 @@ class LandingPageWizard {
749753
searchInput.type = 'text';
750754
searchInput.className = 'form-control form-control-sm';
751755
searchInput.placeholder = this.label('wizard.content.imageSearchPlaceholder');
756+
searchInput.setAttribute('aria-label', this.label('wizard.content.imageSearchPlaceholder'));
752757
searchInput.style.maxWidth = '250px';
753758
if (keywords.length > 0) {
754759
searchInput.value = keywords.join(' ');
@@ -893,9 +898,11 @@ class LandingPageWizard {
893898
const showingSource = source.style.display !== 'none';
894899
preview.style.display = showingSource ? 'block' : 'none';
895900
source.style.display = showingSource ? 'none' : 'block';
901+
toggleBtn.setAttribute('aria-expanded', showingSource ? 'false' : 'true');
896902
}
897903
},
898904
);
905+
toggleBtn.setAttribute('aria-expanded', 'false');
899906

900907
const regenerateBtn = this.createButton(
901908
this.label('wizard.button.regenerate'),
@@ -959,6 +966,8 @@ class LandingPageWizard {
959966

960967
const imageList = document.createElement('div');
961968
imageList.className = 'd-flex gap-2 flex-wrap mb-2';
969+
imageList.setAttribute('role', 'group');
970+
imageList.setAttribute('aria-label', this.label('wizard.content.imageSuggestions'));
962971

963972
if (hasKeywords) {
964973
const images = WizardState.getImages();
@@ -970,6 +979,8 @@ class LandingPageWizard {
970979
const emptyInfo = document.createElement('div');
971980
emptyInfo.className = 'alert alert-info py-2 px-3 mb-2';
972981
emptyInfo.style.fontSize = '0.85em';
982+
emptyInfo.setAttribute('role', 'status');
983+
emptyInfo.setAttribute('aria-live', 'polite');
973984
emptyInfo.textContent = this.label('wizard.content.imageAutoSearchEmpty', keywords.join(', '));
974985
imageSection.appendChild(emptyInfo);
975986
}
@@ -985,6 +996,7 @@ class LandingPageWizard {
985996
searchInput.type = 'text';
986997
searchInput.className = 'form-control form-control-sm';
987998
searchInput.placeholder = this.label('wizard.content.imageSearchPlaceholder');
999+
searchInput.setAttribute('aria-label', this.label('wizard.content.imageSearchPlaceholder'));
9881000
searchInput.style.maxWidth = '250px';
9891001
if (hasKeywords) {
9901002
searchInput.value = keywords.join(' ');
@@ -1116,6 +1128,7 @@ class LandingPageWizard {
11161128
imgCard.setAttribute('tabindex', '0');
11171129
imgCard.setAttribute('aria-label', img.title || img.name || 'Image');
11181130
imgCard.dataset.imageUid = String(img.uid);
1131+
imgCard.style.position = 'relative';
11191132

11201133
// Auto-select recommended image when no image is selected yet
11211134
const isRecommended = img.recommended === true;
@@ -1124,8 +1137,11 @@ class LandingPageWizard {
11241137
sections[sectionIndex].imageUid = img.uid;
11251138
}
11261139

1127-
if (sections[sectionIndex].imageUid === img.uid) {
1128-
imgCard.classList.add('border-primary', 'shadow-sm');
1140+
const isSelected = sections[sectionIndex].imageUid === img.uid;
1141+
imgCard.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
1142+
if (isSelected) {
1143+
imgCard.classList.add('border-primary', 'border-2', 'shadow-sm');
1144+
this._addCheckOverlay(imgCard);
11291145
}
11301146

11311147
// Thumbnail or placeholder
@@ -1153,7 +1169,7 @@ class LandingPageWizard {
11531169
badge.className = img.generated
11541170
? 'badge bg-warning text-dark mb-1'
11551171
: 'badge bg-success text-white mb-1';
1156-
badge.style.fontSize = '0.65rem';
1172+
badge.style.fontSize = '0.75rem';
11571173
badge.textContent = img.generated ? 'AI' : '\u2605 Best';
11581174
imgBody.appendChild(badge);
11591175
}
@@ -1167,16 +1183,21 @@ class LandingPageWizard {
11671183

11681184
const selectImage = () => {
11691185
const secs = WizardState.getContentSections();
1170-
const isSelected = secs[sectionIndex].imageUid === img.uid;
1186+
const wasSelected = secs[sectionIndex].imageUid === img.uid;
11711187

1172-
secs[sectionIndex].imageUid = isSelected ? 0 : img.uid;
1188+
secs[sectionIndex].imageUid = wasSelected ? 0 : img.uid;
11731189

1174-
// Update visual state for all cards in this section's image list
1190+
// Update visual + ARIA state for all cards in this section's image list
11751191
imageList.querySelectorAll('.card').forEach((c) => {
1176-
c.classList.remove('border-primary', 'shadow-sm');
1192+
c.classList.remove('border-primary', 'border-2', 'shadow-sm');
1193+
c.setAttribute('aria-pressed', 'false');
1194+
const check = c.querySelector('.image-check-overlay');
1195+
if (check) check.remove();
11771196
});
1178-
if (!isSelected) {
1179-
imgCard.classList.add('border-primary', 'shadow-sm');
1197+
if (!wasSelected) {
1198+
imgCard.classList.add('border-primary', 'border-2', 'shadow-sm');
1199+
imgCard.setAttribute('aria-pressed', 'true');
1200+
this._addCheckOverlay(imgCard);
11801201
}
11811202
};
11821203

@@ -1192,6 +1213,23 @@ class LandingPageWizard {
11921213
});
11931214
}
11941215

1216+
/**
1217+
* Add a checkmark overlay to an image card to visually indicate selection.
1218+
* Provides a non-color visual indicator (WCAG 1.4.1).
1219+
*
1220+
* @param {HTMLElement} card
1221+
*/
1222+
_addCheckOverlay(card) {
1223+
const check = document.createElement('span');
1224+
check.className = 'image-check-overlay';
1225+
check.setAttribute('aria-hidden', 'true');
1226+
check.textContent = '\u2713';
1227+
check.style.cssText = 'position:absolute;top:4px;right:4px;background:#0d6efd;color:#fff;'
1228+
+ 'border-radius:50%;width:20px;height:20px;display:flex;align-items:center;'
1229+
+ 'justify-content:center;font-size:12px;font-weight:bold;line-height:1;';
1230+
card.appendChild(check);
1231+
}
1232+
11951233
/**
11961234
* Re-render content sections using the appropriate renderer for the current generation mode.
11971235
*

Tests/Unit/Service/PageCreatorServiceTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,55 @@ public function resolveImagePlaceholdersEscapesAltAttribute(): void
921921
self::assertStringContainsString('src="/fileadmin/hero.jpg"', $result);
922922
}
923923

924+
#[Test]
925+
public function resolveImagePlaceholdersAddsAltWhenMissing(): void
926+
{
927+
$file = $this->createMock(File::class);
928+
$file->method('getPublicUrl')->willReturn('/fileadmin/hero.jpg');
929+
930+
$resourceFactory = $this->createMock(ResourceFactory::class);
931+
$resourceFactory->method('getFileObject')->with(1)->willReturn($file);
932+
933+
$dh = $this->createMockDataHandler(['NEW_page' => 1]);
934+
$subject = $this->createService($dh, resourceFactory: $resourceFactory);
935+
936+
$method = new ReflectionMethod(PageCreatorService::class, 'resolveImagePlaceholders');
937+
$result = $method->invoke(
938+
$subject,
939+
'<img data-image-slot="0">',
940+
1,
941+
);
942+
943+
// Must have alt attribute for WCAG 1.1.1 compliance
944+
self::assertStringContainsString('alt=""', $result);
945+
self::assertStringContainsString('src="/fileadmin/hero.jpg"', $result);
946+
}
947+
948+
#[Test]
949+
public function resolveImagePlaceholdersDoesNotDoubleEncodeAlt(): void
950+
{
951+
$file = $this->createMock(File::class);
952+
$file->method('getPublicUrl')->willReturn('/fileadmin/hero.jpg');
953+
954+
$resourceFactory = $this->createMock(ResourceFactory::class);
955+
$resourceFactory->method('getFileObject')->with(1)->willReturn($file);
956+
957+
$dh = $this->createMockDataHandler(['NEW_page' => 1]);
958+
$subject = $this->createService($dh, resourceFactory: $resourceFactory);
959+
960+
$method = new ReflectionMethod(PageCreatorService::class, 'resolveImagePlaceholders');
961+
// LLM already produced escaped entity
962+
$result = $method->invoke(
963+
$subject,
964+
'<img data-image-slot="0" alt="Tom &amp; Jerry">',
965+
1,
966+
);
967+
968+
// Should not double-encode to &amp;amp;
969+
self::assertStringContainsString('alt="Tom &amp; Jerry"', $result);
970+
self::assertStringNotContainsString('&amp;amp;', $result);
971+
}
972+
924973
#[Test]
925974
public function htmlCtypeWithImageButNoPlaceholderSkipsSysFileReference(): void
926975
{

0 commit comments

Comments
 (0)