From e26ea7a39fa1281276614e71bc6bcdbbb8ac31ca Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Tue, 28 Jul 2026 06:54:12 +0000 Subject: [PATCH 1/2] feat(#385): replace the chrome emoji with the icon set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 of 20 emoji across the seven surfaces outside the page children. Seven new icons drawn to the grid in icons.ts: cookie, copy, pin, ban, tip, eye, eye-off. The grid test validated all seven unprompted; I rendered them anyway, because it cannot see whether a drawing reads as the thing it names -- which is how `settings` and `theme` were caught in #377. Every replacement is DECORATIVE, and that is a per-site judgement rather than a default. In each case the accessible name already existed beside the glyph: CookieConsent aria-label="Cookie" next to "We use cookies to enhance your experience" -- the name repeated the copy A11yDevOverlay button already labelled "Accessibility violations: N" and "Collapse accessibility panel" LocationButton text reads "Location blocked" / "Update location" SEOAnalysisPanel

Strengths (N)

/

Weaknesses (N)

above the lists, so the marker was never the only non-colour signal schedule "Prepare for the meeting:" follows messages/setup button already labelled "Show/Hide password" -- and the two eye emoji were near-identical at button size TWO ARE DELIBERATELY LEFT, because they are text and not chrome: A11yDevOverlay:252 🎉 inside the sentence "No accessibility violations" SEOAnalysisPanel:42 ✗ in CLIPBOARD EXPORT text, where an SVG cannot go BlogContent needed different handling. Its copy button is built as an HTML string with an inline onclick, so cannot be used; an `iconSvg()` helper emits markup with single-quoted attributes, because the surrounding onclick is a double-quoted HTML attribute and double quotes would terminate it. The button also swaps two pre-rendered spans by `display` rather than rewriting innerHTML -- injecting SVG through an attribute-embedded JS string is exactly where quoting breaks. That change surfaced a real defect: with the emoji gone the button had NO text content, leaving `title` as its only accessible name. It now carries sr-only "Copy code". The test asserted toHaveTextContent('📋'); it now asserts the real label, which is a stronger check than the glyph was. Also refreshed two doc comments in Icon.tsx and its a11y spec that cited CookieConsent's aria-label as a live example -- it no longer is. 35 E2E green; 239 unit green Refs #385 Part of epic #376 Co-Authored-By: Claude Opus 5 (1M context) --- src/app/messages/setup/page.tsx | 6 ++++- src/app/schedule/page.tsx | 6 ++++- .../atomic/Icon/Icon.accessibility.test.tsx | 10 ++++--- src/components/atomic/Icon/Icon.tsx | 8 +++--- src/components/atomic/Icon/icons.ts | 14 ++++++++++ .../map/LocationButton/LocationButton.tsx | 9 ++++--- .../BlogContent/BlogContent.test.tsx | 6 ++++- .../molecular/BlogContent/BlogContent.tsx | 26 +++++++++++++++++-- .../SEOAnalysisPanel/SEOAnalysisPanel.tsx | 16 +++++++++--- .../A11yDevOverlay/A11yDevOverlay.tsx | 14 +++++----- .../privacy/CookieConsent/CookieConsent.tsx | 9 +++++-- 11 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/app/messages/setup/page.tsx b/src/app/messages/setup/page.tsx index 3c4e167f..487d2e62 100644 --- a/src/app/messages/setup/page.tsx +++ b/src/app/messages/setup/page.tsx @@ -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'); @@ -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. */} + diff --git a/src/app/schedule/page.tsx b/src/app/schedule/page.tsx index c1607d6f..8ea5e256 100644 --- a/src/app/schedule/page.tsx +++ b/src/app/schedule/page.tsx @@ -1,6 +1,7 @@ 'use client'; import dynamic from 'next/dynamic'; +import Icon from '@/components/atomic/Icon'; const CalendarEmbed = dynamic( () => import('@/components/atomic/CalendarEmbed'), @@ -59,7 +60,10 @@ export default function SchedulePage() {

- 💡 + {/* Decorative (#385): "Prepare for the meeting:" follows. */} + + + Prepare for the meeting:

diff --git a/src/components/atomic/Icon/Icon.accessibility.test.tsx b/src/components/atomic/Icon/Icon.accessibility.test.tsx index 47c8a61a..fa485eb7 100644 --- a/src/components/atomic/Icon/Icon.accessibility.test.tsx +++ b/src/components/atomic/Icon/Icon.accessibility.test.tsx @@ -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 () => { diff --git a/src/components/atomic/Icon/Icon.tsx b/src/components/atomic/Icon/Icon.tsx index c1f35c52..9f1d653a 100644 --- a/src/components/atomic/Icon/Icon.tsx +++ b/src/components/atomic/Icon/Icon.tsx @@ -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. */ diff --git a/src/components/atomic/Icon/icons.ts b/src/components/atomic/Icon/icons.ts index bdbdb176..a08a2c63 100644 --- a/src/components/atomic/Icon/icons.ts +++ b/src/components/atomic/Icon/icons.ts @@ -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', diff --git a/src/components/map/LocationButton/LocationButton.tsx b/src/components/map/LocationButton/LocationButton.tsx index 8790182b..f9b6bda8 100644 --- a/src/components/map/LocationButton/LocationButton.tsx +++ b/src/components/map/LocationButton/LocationButton.tsx @@ -1,6 +1,7 @@ 'use client'; import React from 'react'; +import Icon from '@/components/atomic/Icon'; export interface LocationButtonProps { onClick: () => void; @@ -37,9 +38,11 @@ export const LocationButton: React.FC = ({ const getIcon = () => { if (loading) return ; - 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 ; + return ; }; const getVariantClasses = () => { diff --git a/src/components/molecular/BlogContent/BlogContent.test.tsx b/src/components/molecular/BlogContent/BlogContent.test.tsx index 9bc1051f..7a0a05f1 100644 --- a/src/components/molecular/BlogContent/BlogContent.test.tsx +++ b/src/components/molecular/BlogContent/BlogContent.test.tsx @@ -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', () => { diff --git a/src/components/molecular/BlogContent/BlogContent.tsx b/src/components/molecular/BlogContent/BlogContent.tsx index c6724b7e..14d6544a 100644 --- a/src/components/molecular/BlogContent/BlogContent.tsx +++ b/src/components/molecular/BlogContent/BlogContent.tsx @@ -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 `` 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 ( + `` + ); +} + export default function BlogContent({ htmlContent }: BlogContentProps) { const [copiedCode, setCopiedCode] = React.useState(null); const contentRef = React.useRef(null); @@ -41,10 +63,10 @@ export default function BlogContent({ htmlContent }: BlogContentProps) {

${lang}
+ >${iconSvg('copy')}${iconSvg('check', 'display:none')}Copy code
${code}
`; diff --git a/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx b/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx index b949e4d3..d8e11d0c 100644 --- a/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx +++ b/src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx @@ -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; @@ -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. */} + + {copied ? 'Copied' : 'Copy'}
- + {/* Decorative: the

above reads "Strengths (N)", so + the marker is not the only non-colour signal (#385). */} + + + {strength} ))} @@ -165,7 +172,10 @@ export default function SEOAnalysisPanel({ key={i} className="flex items-start gap-2 text-xs sm:text-sm" > - + {/* Decorative: the

above reads "Weaknesses (N)". */} + + + {weakness} ))} diff --git a/src/components/organisms/A11yDevOverlay/A11yDevOverlay.tsx b/src/components/organisms/A11yDevOverlay/A11yDevOverlay.tsx index 271eb700..9a74160f 100644 --- a/src/components/organisms/A11yDevOverlay/A11yDevOverlay.tsx +++ b/src/components/organisms/A11yDevOverlay/A11yDevOverlay.tsx @@ -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 { /** @@ -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}`} > - + {/* Decorative (#385): the button's aria-label already reads + "Accessibility violations: N. Open panel." */} + {violations.length} ); @@ -184,9 +185,7 @@ export default function A11yDevOverlay({ >

- + A11y {violations.length} @@ -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". */} +

diff --git a/src/components/privacy/CookieConsent/CookieConsent.tsx b/src/components/privacy/CookieConsent/CookieConsent.tsx index 305f1b73..97686902 100644 --- a/src/components/privacy/CookieConsent/CookieConsent.tsx +++ b/src/components/privacy/CookieConsent/CookieConsent.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useConsent } from '../../../contexts/ConsentContext'; +import Icon from '../../atomic/Icon'; export interface CookieConsentProps { position?: 'top' | 'bottom'; @@ -71,8 +72,12 @@ export function CookieConsent({
{/* Message Section - Compact */}
- - 🍪 + {/* 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. */} + + {customContent || (

From 22c5f7350b8fc07b570c2be1d42edb7247d27b01 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Tue, 28 Jul 2026 07:11:44 +0000 Subject: [PATCH 2/2] fix(#378): make the touch-target gate measure the nav it claims to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate was very nearly inert. Its selector matched 60 elements at 390px and exactly ONE was visible -- the hamburger itself. Everything else sat inside a closed dropdown, isVisible() skipped it, and the test reported green having checked a single 44px button. It had been that way for as long as the mobile nav has been a dropdown. #378 flagged this as something its own nav regrouping WOULD cause. It had already happened. Opening the menu found a real defect on the first run: **Sign Out at 144x26px**, 18px under the standard, in both the desktop and mobile menus. Neither had a className at all, so both inherited DaisyUI's 26px menu default. Never measured, never reported. Fixed with min-h-11. Two changes to the gate: click(), not focus() -- a DaisyUI dropdown is held open by :focus-within and a programmatic focus on the

  • + {/* 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). */}
  • )}
  • + {/* Same 26px default as the desktop menu above. */}