Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/app/messages/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useAuth } from '@/contexts/AuthContext';
import { isOAuthUser, getOAuthProvider } from '@/lib/auth/oauth-utils';
import { sendWelcomeMessageOnSetup } from '@/lib/messaging/welcome/send-welcome-message';
import { createLogger } from '@/lib/logger/logger';
import Icon from '@/components/atomic/Icon';

const logger = createLogger('app:messages:setup');

Expand Down Expand Up @@ -264,7 +265,10 @@ export default function MessagingSetupPage() {
className="btn btn-ghost btn-sm absolute top-1/2 right-1 -translate-y-1/2"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '👁️' : '👁️‍🗨️'}
{/* Decorative (#385): the button's aria-label already
says Show/Hide password, and the two emoji were nearly
indistinguishable at button size. */}
<Icon name={showPassword ? 'eye-off' : 'eye'} decorative />
</button>
</div>
</div>
Expand Down
6 changes: 5 additions & 1 deletion src/app/schedule/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import dynamic from 'next/dynamic';
import Icon from '@/components/atomic/Icon';

const CalendarEmbed = dynamic(
() => import('@/components/atomic/CalendarEmbed'),
Expand Down Expand Up @@ -59,7 +60,10 @@ export default function SchedulePage() {

<div className="bg-primary/10 border-primary/20 rounded-lg border p-4">
<h2 className="mb-2 flex items-center text-lg font-semibold">
<span className="mr-2">💡</span>
{/* Decorative (#385): "Prepare for the meeting:" follows. */}
<span className="mr-2">
<Icon name="tip" decorative />
</span>
Prepare for the meeting:
</h2>
<p className="mb-3 text-sm">
Expand Down
7 changes: 7 additions & 0 deletions src/components/GlobalNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,13 @@ export function GlobalNav() {
</li>
)}
<li>
{/* min-h-11: DaisyUI's menu default renders this at 26px,
18px under the 44px standard. It sits inside a closed
dropdown, so the touch-target gate never measured it
until that gate was taught to open the menu (#378). */}
<button
type="button"
className="min-h-11"
onClick={(e) => {
e.preventDefault();
// Close dropdown
Expand Down Expand Up @@ -466,8 +471,10 @@ export function GlobalNav() {
</li>
)}
<li>
{/* Same 26px default as the desktop menu above. */}
<button
type="button"
className="min-h-11"
onClick={(e) => {
e.preventDefault();
// Close dropdown
Expand Down
10 changes: 6 additions & 4 deletions src/components/atomic/Icon/Icon.accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ expect.extend(toHaveNoViolations);
* Accessible names are an API in this repo (#377).
*
* `performSignIn()` and roughly twenty specs locate controls by their
* accessible name, and the emoji this set replaces carry names explicitly —
* `CookieConsent.tsx` renders `role="img" aria-label="Cookie"`. An SVG that
* silently renders unlabelled changes what axe sees and what those locators
* can find, without failing anything at the point of the swap.
* accessible name, and the emoji this set replaced carried names explicitly —
* `CookieConsent` rendered `role="img" aria-label="Cookie"` next to a sentence
* that already said "We use cookies", so that name only repeated the copy and
* the replacement is deliberately decorative. An SVG that silently renders
* unlabelled changes what axe sees and what those locators can find, without
* failing anything at the point of the swap — hence the union in the props.
*/
describe('Icon Accessibility', () => {
it('has no violations when labelled', async () => {
Expand Down
8 changes: 5 additions & 3 deletions src/components/atomic/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import { ICON_PATHS, type IconName } from './icons';
*
* This matters more than usual here. Accessible names are an API in this repo
* (#377): `performSignIn()` and ~20 specs locate controls by name, and the
* emoji this set replaces carry their names explicitly — `CookieConsent.tsx`
* renders `role="img" aria-label="Cookie"`. Swapping in a silently unlabelled
* SVG changes what axe and every name-based locator can see.
* emoji this set replaced carried theirs explicitly — `CookieConsent` rendered
* `role="img" aria-label="Cookie"` beside a sentence that already said "We use
* cookies", so the name simply repeated the copy. Swapping in a silently
* unlabelled SVG changes what axe and every name-based locator can see, so the
* choice has to be made per site rather than defaulted.
*/
export type IconProps = {
/** Which icon to draw. */
Expand Down
14 changes: 14 additions & 0 deletions src/components/atomic/Icon/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ export const ICON_PATHS = {
'M12 2 A7 7 0 0 1 15.5 8 V15 H8.5 V8 A7 7 0 0 1 12 2 Z M8.5 11 L5.5 14 V18 H8.5 M15.5 11 L18.5 14 V18 H15.5 M10.5 8 A1.5 1.5 0 1 1 13.5 8 A1.5 1.5 0 1 1 10.5 8 M10 15 L12 19 L14 15',
layout: 'M3 5 H21 V19 H3 Z M9 5 V19 M9 11 H21',

// ── Chrome (#385) ───────────────────────────────────────────────────────
// Replacing emoji on the surfaces outside the page children. Emoji cannot
// take `currentColor` and render differently on every OS, which is the
// actual problem a monochrome set fixes.
cookie:
'M12 3 A9 9 0 1 0 21 12 A3 3 0 0 1 18 9 A3 3 0 0 1 15 6 A3 3 0 0 1 12 3 Z M9 10 H9.5 M14 9 H14.5 M11 15 H11.5 M16 14 H16.5',
copy: 'M9 3 H19 V17 H9 Z M5 7 V21 H15 V17',
pin: 'M12 21 A18 18 0 0 1 5 9 A7 7 0 0 1 19 9 A18 18 0 0 1 12 21 Z M9.5 9 A2.5 2.5 0 1 1 14.5 9 A2.5 2.5 0 1 1 9.5 9',
ban: 'M4 12 A8 8 0 1 1 20 12 A8 8 0 1 1 4 12 M6.5 6.5 L17.5 17.5',
tip: 'M12 3 A6 6 0 0 1 15 14 V16 H9 V14 A6 6 0 0 1 12 3 Z M9 19 H15 M10 21.5 H14',
eye: 'M2 12 A12 12 0 0 1 22 12 A12 12 0 0 1 2 12 M9 12 A3 3 0 1 1 15 12 A3 3 0 1 1 9 12',
'eye-off':
'M4 4 L20 20 M9.5 9.5 A3 3 0 0 0 14 14 M6 6.5 A12 12 0 0 0 2 12 A12 12 0 0 0 17 18.5 M9.5 4.5 A12 12 0 0 1 22 12 A12 12 0 0 1 19.5 15.5',

// ── Status and actions ──────────────────────────────────────────────────
check: 'M4 12 L9 17 L20 6',
alert: 'M12 3 L21 20 H3 Z M12 10 V14 M12 17 V17.5',
Expand Down
9 changes: 6 additions & 3 deletions src/components/map/LocationButton/LocationButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import React from 'react';
import Icon from '@/components/atomic/Icon';

export interface LocationButtonProps {
onClick: () => void;
Expand Down Expand Up @@ -37,9 +38,11 @@ export const LocationButton: React.FC<LocationButtonProps> = ({
const getIcon = () => {
if (loading)
return <span className="loading loading-spinner loading-sm"></span>;
if (permissionState === 'denied') return '🚫';
if (hasLocation) return '📍';
return '📍';
// Decorative (#385): getButtonText() beside these already says "Location
// blocked" / "Update location" / "Get my location", so the icon carries no
// information the label does not.
if (permissionState === 'denied') return <Icon name="ban" decorative />;
return <Icon name="pin" decorative />;
};

const getVariantClasses = () => {
Expand Down
6 changes: 5 additions & 1 deletion src/components/molecular/BlogContent/BlogContent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ describe('BlogContent', () => {
// Check that copy button is present
const copyButton = screen.getByTitle('Copy code');
expect(copyButton).toBeInTheDocument();
expect(copyButton).toHaveTextContent('📋');
// Was toHaveTextContent('📋'). The button now renders the icon set
// (#385), so its label is real sr-only text rather than an emoji glyph —
// a stronger assertion, since the button previously had no accessible
// name beyond `title`.
expect(copyButton).toHaveTextContent('Copy code');
});

it('processes code blocks with language labels', () => {
Expand Down
26 changes: 24 additions & 2 deletions src/components/molecular/BlogContent/BlogContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,33 @@ import { getProjectConfig } from '@/config/project.config';
// Import Prism theme for styling the pre-highlighted code
import 'prismjs/themes/prism-tomorrow.css';
import '@/styles/prism-override.css';
import { ICON_PATHS, type IconName } from '@/components/atomic/Icon';

interface BlogContentProps {
htmlContent: string;
}

/**
* An icon as an HTML string (#385).
*
* This code block is built as markup in a template literal with an inline
* `onclick`, not as JSX, so `<Icon>` cannot be used. Attributes are
* single-quoted deliberately: the surrounding `onclick` is a double-quoted
* HTML attribute, and double quotes here would terminate it.
*
* The button swaps two pre-rendered spans by `display` rather than rewriting
* `innerHTML`, because injecting SVG markup through an attribute-embedded JS
* string is exactly where quoting breaks.
*/
function iconSvg(name: IconName, style = ''): string {
return (
`<svg width='1em' height='1em' viewBox='0 0 24 24' fill='none' ` +
`stroke='currentColor' stroke-width='2' stroke-linecap='round' ` +
`stroke-linejoin='round' aria-hidden='true' style='${style}'>` +
`<path d='${ICON_PATHS[name]}'/></svg>`
);
}

export default function BlogContent({ htmlContent }: BlogContentProps) {
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -41,10 +63,10 @@ export default function BlogContent({ htmlContent }: BlogContentProps) {
<div class="mockup-code bg-base-300 my-4 relative" data-code-id="${id}">
<div class="absolute top-2 right-12 text-xs text-base-content/80">${lang}</div>
<button
onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre').textContent); this.innerHTML='✓'; setTimeout(() => this.innerHTML='📋', 2000)"
onclick="navigator.clipboard.writeText(this.parentElement.querySelector('pre').textContent); var i=this.children; i[0].style.display='none'; i[1].style.display='inline'; setTimeout(function(){i[0].style.display='inline'; i[1].style.display='none'}, 2000)"
class="btn btn-xs btn-ghost absolute top-2 right-2"
title="Copy code"
>📋</button>
>${iconSvg('copy')}${iconSvg('check', 'display:none')}<span class='sr-only'>Copy code</span></button>
<pre><code class="language-${lang}" id="${id}">${code}</code></pre>
</div>
`;
Expand Down
16 changes: 13 additions & 3 deletions src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useState, useMemo } from 'react';
import type { BlogPost } from '@/types/blog';
import { seoAnalyzer } from '@/lib/blog/seo-analyzer';
import Icon from '@/components/atomic/Icon';

export interface SEOAnalysisPanelProps {
post: BlogPost;
Expand Down Expand Up @@ -64,7 +65,9 @@ export default function SEOAnalysisPanel({
className="btn btn-xs sm:btn-sm btn-ghost"
title="Copy SEO feedback"
>
{copied ? '✓ Copied' : '📋 Copy'}
{/* Decorative (#385): the word beside it changes too. */}
<Icon name={copied ? 'check' : 'copy'} decorative />
{copied ? 'Copied' : 'Copy'}
</button>
<div
className={`radial-progress ${scoreColorClass(score.overall)}`}
Expand Down Expand Up @@ -145,7 +148,11 @@ export default function SEOAnalysisPanel({
key={i}
className="flex items-start gap-2 text-xs sm:text-sm"
>
<span className="text-success mt-0.5 flex-shrink-0">✓</span>
{/* Decorative: the <h4> above reads "Strengths (N)", so
the marker is not the only non-colour signal (#385). */}
<span className="text-success mt-0.5 flex-shrink-0">
<Icon name="check" decorative />
</span>
<span className="break-words">{strength}</span>
</li>
))}
Expand All @@ -165,7 +172,10 @@ export default function SEOAnalysisPanel({
key={i}
className="flex items-start gap-2 text-xs sm:text-sm"
>
<span className="text-error mt-0.5 flex-shrink-0">✗</span>
{/* Decorative: the <h4> above reads "Weaknesses (N)". */}
<span className="text-error mt-0.5 flex-shrink-0">
<Icon name="close" decorative />
</span>
<span className="break-words">{weakness}</span>
</li>
))}
Expand Down
14 changes: 7 additions & 7 deletions src/components/organisms/A11yDevOverlay/A11yDevOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useMemo, useRef, useState } from 'react';
import type { ImpactValue, Result } from 'axe-core';
import { useA11yScan } from './useA11yScan';
import Icon from '@/components/atomic/Icon';

export interface A11yDevOverlayProps {
/**
Expand Down Expand Up @@ -167,9 +168,9 @@ export default function A11yDevOverlay({
aria-label={`Accessibility violations: ${violations.length}. Open panel.`}
className={`badge ${badgeColor} fixed right-4 bottom-20 z-[90] flex min-h-11 min-w-11 cursor-pointer items-center gap-1 rounded-full px-3 shadow-lg ${className}`}
>
<span role="img" aria-hidden="true">
</span>
{/* Decorative (#385): the button's aria-label already reads
"Accessibility violations: N. Open panel." */}
<Icon name="accessibility" decorative />
<span className="font-mono font-bold">{violations.length}</span>
</button>
);
Expand All @@ -184,9 +185,7 @@ export default function A11yDevOverlay({
>
<header className="border-base-300 flex items-center justify-between gap-2 border-b px-3 py-2">
<h2 className="flex items-center gap-2 text-sm font-bold">
<span role="img" aria-hidden="true">
</span>
<Icon name="accessibility" decorative />
A11y
<span className={`badge badge-sm ${badgeColor}`}>
{violations.length}
Expand All @@ -208,7 +207,8 @@ export default function A11yDevOverlay({
aria-label="Collapse accessibility panel"
className="btn btn-ghost btn-xs min-h-11 min-w-11"
>
{/* The button carries aria-label="Collapse accessibility panel". */}
<Icon name="close" decorative />
</button>
</div>
</header>
Expand Down
9 changes: 7 additions & 2 deletions src/components/privacy/CookieConsent/CookieConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from 'react';
import { useConsent } from '../../../contexts/ConsentContext';
import Icon from '../../atomic/Icon';

export interface CookieConsentProps {
position?: 'top' | 'bottom';
Expand Down Expand Up @@ -71,8 +72,12 @@ export function CookieConsent({
<div className="flex items-center justify-between gap-4">
{/* Message Section - Compact */}
<div className="flex flex-1 items-center gap-2">
<span className="text-lg" role="img" aria-label="Cookie">
🍪
{/* Decorative (#385): the sentence beside it already says
"We use cookies". The emoji carried aria-label="Cookie", so
a screen reader announced "Cookie, We use cookies to enhance
your experience" — the name repeated the copy. */}
<span className="text-lg">
<Icon name="cookie" decorative />
</span>
{customContent || (
<p className="text-sm sm:text-base">
Expand Down
46 changes: 46 additions & 0 deletions tests/e2e/tests/mobile-touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,32 @@ test.describe('Touch Target Standards', () => {
await dismissCookieBanner(page);
await waitForLayoutStability(page);

// OPEN THE MOBILE MENU FIRST. Without this the gate is very nearly inert:
// measured on a clean page at 390px, the selector below matched 58
// elements and exactly ONE was visible — the hamburger itself. The other
// 57 sit inside the closed dropdown, `isVisible()` skips them, and the
// test reports green having checked a single 44px button.
//
// The nav is a DaisyUI `dropdown` held open by `:focus-within`, so
// focusing the trigger opens it (#378 will add more of these).
const menuTrigger = page.locator('[aria-label="Navigation menu"]');
if (await menuTrigger.count()) {
// click(), not focus(): a DaisyUI dropdown is held open by
// :focus-within, and a programmatic focus on the <label> does not
// reliably establish it. Measured — focus() surfaced 3 targets, click()
// surfaces the full menu.
await menuTrigger.first().click();
await page.waitForTimeout(250);
}

// Check primary navigation buttons only (not all interactive elements)
// Inline text links, badges, and icons inside buttons are exempt
const primaryButtons = await page
.locator('nav button, nav label, a.btn')
.all();

const failures: string[] = [];
let measured = 0;

for (let i = 0; i < primaryButtons.length; i++) {
const element = primaryButtons[i];
Expand All @@ -68,6 +87,7 @@ test.describe('Touch Target Standards', () => {
const box = await element.boundingBox();

if (box) {
measured++;
const text =
(await element.textContent())?.trim().substring(0, 30) || '';

Expand All @@ -89,6 +109,32 @@ test.describe('Touch Target Standards', () => {
const summary = `${failures.length} primary buttons failed touch target requirements:\n${failures.join('\n')}`;
expect(failures.length, summary).toBe(0);
}

// COVERAGE FLOOR — the assertion that keeps the one above honest.
//
// Every check here is conditional on `isVisible()`, so anything moved
// behind a closed menu stops being measured and the test still passes.
// Zero measured is indistinguishable from zero failures.
//
// That is not hypothetical. Before the click above, this gate measured a
// single element — the hamburger — while its selector matched 60. Opening
// the menu immediately surfaced a real defect that had never been checked:
// Sign Out at 144x26px, 18px under the standard.
//
// 6 is MEASURED, not chosen. Be clear about what it does and does not
// mean: the selector deliberately exempts inline text links, so the menu's
// <a> items are out of scope by design and 6 is full coverage FOR THIS
// SELECTOR — not proof that every nav target is checked.
//
// #378 regroups the nav into `Demos ▾` and `Display ▾`. If this number
// drops, targets were hidden rather than fixed. Raise it deliberately;
// never lower it to make a run pass.
expect(
measured,
`Only ${measured} nav touch targets were measured, down from 6. ` +
`Something is hidden behind a closed menu that this gate must open ` +
`first — see the menuTrigger click above.`
).toBeGreaterThanOrEqual(6);
});

test('Navigation buttons meet touch target standards', async ({ page }) => {
Expand Down
Loading