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
10 changes: 5 additions & 5 deletions a11y_audits/findings-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ Remediation status after the [6 Oct 2026 re-audit](./2026-10-06-cosmomail-ux-aud
| S6 | Serious | To and Cc share the accessible name “Add a recipient” | 1.3.1, 4.1.2 | Fixed | [#19](https://github.com/CodeSignal/bespoke_email-simulator/issues/19) |
| S7 | Serious | Floating composer can cover focusable mail rows | 2.4.11 | Fixed | [#20](https://github.com/CodeSignal/bespoke_email-simulator/issues/20) |
| M1 | Moderate | Button focus rings missing or empty; primary selector broken | 2.4.7 | Fixed | [#21](https://github.com/CodeSignal/bespoke_email-simulator/issues/21) |
| M2 | Moderate | Reduced motion only partly honored | 2.3.3 | Partial | [#22](https://github.com/CodeSignal/bespoke_email-simulator/issues/22) |
| M2 | Moderate | Reduced motion only partly honored | 2.3.3 | Fixed | [#22](https://github.com/CodeSignal/bespoke_email-simulator/issues/22) |
| M3 | Moderate | `--Colors-Text-Body-Secondary` undefined | 1.4.3 | Fixed | — |
| M4 | Moderate | `html[lang]` not synced to i18n | 3.1.1 | Open | [#23](https://github.com/CodeSignal/bespoke_email-simulator/issues/23) |
| M4 | Moderate | `html[lang]` not synced to i18n | 3.1.1 | Fixed | [#23](https://github.com/CodeSignal/bespoke_email-simulator/issues/23) |
| M5 | Moderate | Expanded compose modal initial focus | 2.4.3 | Fixed | — |
| M6 | Moderate | Send/attachment errors not announced | 4.1.3 | Open | [#24](https://github.com/CodeSignal/bespoke_email-simulator/issues/24) |
| M6 | Moderate | Send/attachment errors not announced | 4.1.3 | Fixed | [#24](https://github.com/CodeSignal/bespoke_email-simulator/issues/24) |
| M7 | Moderate | Disabled button opacity 0.24 hard to see | Usability | Open | [#25](https://github.com/CodeSignal/bespoke_email-simulator/issues/25) |
| M8 | Moderate | Dark mode: active recipient role text 3.86:1 | 1.4.3 | Fixed | [#26](https://github.com/CodeSignal/bespoke_email-simulator/issues/26) |
| M9 | Moderate | Recipient remove control hidden until hover or focus | 2.5.8 | Open | [#27](https://github.com/CodeSignal/bespoke_email-simulator/issues/27) |
| M10 | Moderate | Split divider focus line overridden | 2.4.7 | Open | [#28](https://github.com/CodeSignal/bespoke_email-simulator/issues/28) |
| M9 | Moderate | Recipient remove control hidden until hover or focus | 2.5.8 | Fixed | [#27](https://github.com/CodeSignal/bespoke_email-simulator/issues/27) |
| M10 | Moderate | Split divider focus line overridden | 2.4.7 | Fixed | [#28](https://github.com/CodeSignal/bespoke_email-simulator/issues/28) |
| M11 | Moderate | Assistant log rebuilt inside a live region | 4.1.3 | Open | [#29](https://github.com/CodeSignal/bespoke_email-simulator/issues/29) |
| N2 | Minor | Assistant Enter-to-send undisclosed | Best practice | Open | [#30](https://github.com/CodeSignal/bespoke_email-simulator/issues/30) |
| N4 | Minor | `href="#"` mailto shim | Best practice | Open | [#31](https://github.com/CodeSignal/bespoke_email-simulator/issues/31) |
Expand Down
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"Mail": "Mail",
"AI Assistant": "AI Assistant",
"Send": "Send",
"Sending…": "Sending…",
"Message sent": "Message sent",
"Send failed": "Send failed",
"File type not allowed": "File type not allowed",
"To": "To",
"Cc": "Cc",
"Subject": "Subject",
Expand Down
92 changes: 92 additions & 0 deletions lib/document-language.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Browser-safe locale helpers. No Node imports: public/app.js bundles this file.

// Normalizes a language name for case-insensitive matching (e.g. "Spanish" -> "spanish").
export function normalizeLanguageName(name) {
return String(name ?? '').trim().toLowerCase();
}

// Well-formed BCP 47 langtag (RFC 5646), not a registered-subtag lookup.
// The primary language stays 2 or 3 letters, so a full name such as "english" is not a tag.
// An extension singleton such as "u" is one character and needs 2 to 8 character subtags.
// Private-use "x" subtags may be 1 to 8 characters.
const PRIMARY_LANGUAGE = /^[A-Za-z]{2,3}$/;
const EXTLANG = /^[A-Za-z]{3}$/;
const SCRIPT = /^[A-Za-z]{4}$/;
const REGION = /^([A-Za-z]{2}|\d{3})$/;
const VARIANT = /^([A-Za-z0-9]{5,8}|\d[A-Za-z0-9]{3})$/;
const EXTENSION_SINGLETON = /^[0-9A-WY-Za-wy-z]$/;
const EXTENSION_PART = /^[A-Za-z0-9]{2,8}$/;
const PRIVATE_USE = /^[Xx]$/;
const PRIVATE_PART = /^[A-Za-z0-9]{1,8}$/;

export function isDocumentLanguageTag(value) {
const tag = String(value ?? '').trim();
if (!tag) return false;
const subtags = tag.split('-');
if (subtags.some((part) => part.length === 0)) return false;
if (!PRIMARY_LANGUAGE.test(subtags[0])) return false;

let index = 1;
let extlangs = 0;
while (index < subtags.length && extlangs < 3 && EXTLANG.test(subtags[index])) {
extlangs += 1;
index += 1;
}
if (index < subtags.length && SCRIPT.test(subtags[index])) index += 1;
if (index < subtags.length && REGION.test(subtags[index])) index += 1;
while (index < subtags.length && VARIANT.test(subtags[index])) index += 1;

while (index < subtags.length && EXTENSION_SINGLETON.test(subtags[index])) {
index += 1;
const start = index;
while (index < subtags.length && EXTENSION_PART.test(subtags[index])) index += 1;
if (index === start) return false;
}

if (index < subtags.length && PRIVATE_USE.test(subtags[index])) {
index += 1;
const start = index;
while (index < subtags.length && PRIVATE_PART.test(subtags[index])) index += 1;
if (index === start) return false;
}

return index === subtags.length;
}

// The catalog identifies the page language with a BCP 47 tag in `languageNames`.
// Returns '' when none of the names is a tag.
export function documentLanguageTag(languageNames) {
const names = Array.isArray(languageNames) ? languageNames : [];
for (const name of names) {
const tag = String(name ?? '').trim();
if (isDocumentLanguageTag(tag)) return tag;
}
return '';
}

// The locale whose `languageNames` includes `language` (case-insensitively), or null.
export function matchLocale(language, locales = []) {
const target = normalizeLanguageName(language);
if (!target) return null;
for (const locale of locales) {
const names = Array.isArray(locale?.languageNames) ? locale.languageNames : [];
if (names.some((n) => normalizeLanguageName(n) === target)) return locale;
}
return null;
}

// Finds the first locale whose `languageNames` includes `language`
// (case-insensitively) and returns its `strings` map, or `{}` when none match.
export function matchLocaleStrings(language, locales = []) {
const locale = matchLocale(language, locales);
if (!locale?.strings || typeof locale.strings !== 'object') return {};
return locale.strings;
}

// html[lang] for a scenario language. '' when no catalog matches or the
// catalog does not identify a language tag — callers must keep the existing lang.
export function resolveDocumentLanguage(language, locales = []) {
const locale = matchLocale(language, locales);
if (!locale) return '';
return documentLanguageTag(locale.languageNames);
}
28 changes: 9 additions & 19 deletions lib/helpers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import fs from 'fs/promises';

export {
normalizeLanguageName,
isDocumentLanguageTag,
documentLanguageTag,
matchLocale,
matchLocaleStrings,
resolveDocumentLanguage,
} from './document-language.js';

// ── JSON file IO ──────────────────────────────────────────────
export async function readJsonFile(filePath, fallback = {}) {
try {
Expand Down Expand Up @@ -35,25 +44,6 @@ export function filterModels(rawText, allowedModels, allowedModelFamilies) {
}

// ── i18n ──────────────────────────────────────────────────────
// Normalizes a language name for case-insensitive matching (e.g. "Spanish" -> "spanish").
export function normalizeLanguageName(name) {
return String(name ?? '').trim().toLowerCase();
}

// Finds the first locale whose `languageNames` includes `language`
// (case-insensitively) and returns its `strings` map, or `{}` when none match.
export function matchLocaleStrings(language, locales = []) {
const target = normalizeLanguageName(language);
if (!target) return {};
for (const locale of locales) {
const names = Array.isArray(locale?.languageNames) ? locale.languageNames : [];
if (names.some((n) => normalizeLanguageName(n) === target)) {
return locale.strings && typeof locale.strings === 'object' ? locale.strings : {};
}
}
return {};
}

// Merges i18n base strings with per-key config overrides (config wins).
export function mergeStrings(base = {}, overrides = {}) {
return { ...base, ...(overrides && typeof overrides === 'object' ? overrides : {}) };
Expand Down
17 changes: 13 additions & 4 deletions lib/i18n.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs/promises';
import path from 'path';
import { readJsonFile, matchLocaleStrings, mergeStrings } from './helpers.js';
import { readJsonFile, matchLocaleStrings, mergeStrings, resolveDocumentLanguage } from './helpers.js';

/**
* Loads every i18n/*.json locale catalog. Each file is
Expand All @@ -27,8 +27,17 @@ export async function readLocales(i18nDir) {
* Resolves the effective UI strings for a language: the matching i18n catalog is
* the base, and any per-key `overrides` (from the scenario's ui.strings) win.
*/
export async function resolveStrings(language, overrides, i18nDir) {
// Resolved UI strings plus the catalog's html[lang] tag ('' when the catalog
// does not identify one).
export async function resolveClientLocale(language, overrides, i18nDir) {
const locales = await readLocales(i18nDir);
const base = matchLocaleStrings(language, locales);
return mergeStrings(base, overrides);
return {
strings: mergeStrings(matchLocaleStrings(language, locales), overrides),
documentLanguage: resolveDocumentLanguage(language, locales),
};
}

export async function resolveStrings(language, overrides, i18nDir) {
const { strings } = await resolveClientLocale(language, overrides, i18nDir);
return strings;
}
30 changes: 18 additions & 12 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ body {
transition: opacity 0.15s ease;
}

#mailSplit .split-panel-divider:hover::after {
#mailSplit .split-panel-divider:hover::after,
#mailSplit .split-panel-divider:focus-visible::after {
background: var(--Colors-Border-Subtle);
}

Expand All @@ -165,6 +166,12 @@ body {
opacity: 1;
}

/* Focus uses the same line as hover. Icon-Strong clears 3:1 on the dark seam;
the design-system handle is black and does not. */
#mailSplit .split-panel-divider:focus-visible .split-panel-divider-handle {
background: var(--Colors-Icon-Strong);
}

/* ── Icon button (Figma "Button Icon": 32px, radius 6) ── */
.icon-button {
display: inline-flex;
Expand Down Expand Up @@ -1227,28 +1234,21 @@ body {
color: var(--Colors-Text-Body-Strong);
}

/* Remove control: hidden at rest (matches Figma), shown on hover / focus. */
/* Always visible at 24×24 so a pointer that cannot hover can still remove a chip. */
.recipient-picker__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0;
height: 18px;
flex: 0 0 24px;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: none;
border-radius: var(--UI-Radius-radius-xxs);
background: transparent;
color: var(--Colors-Icon-Neutral-Default);
cursor: pointer;
overflow: hidden;
opacity: 0;
transition: opacity 0.15s ease;
}

.recipient-picker__chip:hover .recipient-picker__remove,
.recipient-picker__remove:focus-visible {
width: 18px;
opacity: 1;
}

Expand Down Expand Up @@ -2082,6 +2082,12 @@ body {
}
}

@media (prefers-reduced-motion: reduce) {
.email--arrive {
animation: none;
}
}

/* New-mail toasts: inside the main column, 8px below the 64px toolbar and
8px from its right edge; slide in from the right (co-design-al motion). */
.mail-toasts {
Expand Down
Loading
Loading