fix(frontend): raise dark contrast, expand hit areas, and surface input limits
Dark muted surfaces missed WCAG AA; action and tertiary tokens plus a 32-pair contract close that. Hit targets, remaining-count, and Enter-to-send follow the interaction audit without changing visual sizes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
characterRemainingAnnouncement,
|
||||
characterRemainingLabel,
|
||||
characterRemainingVisible,
|
||||
remainingThreshold,
|
||||
} from "../src/lib/character-remaining.ts";
|
||||
|
||||
const composer = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
||||
const remainingSource = readFileSync(new URL("../src/components/character-remaining.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
|
||||
const choice = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("thresholds stay quiet until the last 10 percent, floored at 8 and capped at 50", () => {
|
||||
assert.equal(remainingThreshold(500), 50);
|
||||
assert.equal(remainingThreshold(240), 24);
|
||||
assert.equal(remainingThreshold(80), 8);
|
||||
assert.equal(characterRemainingVisible(449, 500), false);
|
||||
assert.equal(characterRemainingVisible(450, 500), true);
|
||||
assert.equal(characterRemainingVisible(71, 80), false);
|
||||
assert.equal(characterRemainingVisible(72, 80), true);
|
||||
});
|
||||
|
||||
test("the visible label updates while the live announcement stays at two phrases", () => {
|
||||
assert.equal(characterRemainingLabel(12, 500), "还剩 12 字");
|
||||
assert.equal(characterRemainingLabel(0, 500), "已达 500 字上限");
|
||||
assert.equal(characterRemainingAnnouncement(12, 500), "接近字数上限");
|
||||
assert.equal(characterRemainingAnnouncement(0, 80), "已达 80 字上限");
|
||||
});
|
||||
|
||||
test("the count region is polite, atomic, and only mounted inside the threshold", () => {
|
||||
assert.match(remainingSource, /if \(!characterRemainingVisible\(length, maxLength\)\) return null;/);
|
||||
assert.match(remainingSource, /aria-live="polite"/);
|
||||
assert.match(remainingSource, /aria-atomic="true"/);
|
||||
assert.match(remainingSource, /aria-hidden="true"/);
|
||||
assert.match(remainingSource, /className="sr-only"/);
|
||||
assert.match(css, /\.character-remaining\[data-limit\] \{ color: var\(--color-danger\); \}/);
|
||||
});
|
||||
|
||||
test("the four capped fields wire describedby to a count that lives in existing chrome", () => {
|
||||
assert.match(composer, /aria-describedby=\{showRemaining \? composerRemainingId : undefined\}/);
|
||||
assert.match(page, /<ComposerCharacterRemaining maxLength=\{!profileComplete && onboardingStep === "name" \? 80 : 500\} \/>/);
|
||||
assert.match(page, /className="composer-footer"/);
|
||||
assert.match(page, /aria-describedby=\{characterRemainingVisible\(value\.name\.length, 80\) \? nameRemainingId : undefined\}/);
|
||||
assert.match(guide, /aria-describedby=\{describedBy\}/);
|
||||
assert.match(choice, /aria-describedby=\{characterRemainingVisible\(note\.length, 240\) \? unmatchedRemainingId : undefined\}/);
|
||||
assert.match(css, /\.composer-footer > p \{ display: none; \}/);
|
||||
assert.doesNotMatch(page, /<p className="character-remaining"/);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(
|
||||
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const guide = readFileSync(new URL("../src/components/birth-time-guide-turn.tsx", import.meta.url), "utf8");
|
||||
|
||||
function onKeyDownBlock(source: string, marker: string) {
|
||||
const start = source.indexOf(marker);
|
||||
assert.notEqual(start, -1, `missing onKeyDown near ${marker}`);
|
||||
const slice = source.slice(start, start + 600);
|
||||
const handler = slice.match(/onKeyDown=\{\(event\) => \{[\s\S]*?\n\s*\}\}/)
|
||||
?? slice.match(/function handleComposerKeyDown[\s\S]*?\n \}/);
|
||||
assert.ok(handler, `could not isolate onKeyDown from ${marker}`);
|
||||
return handler[0];
|
||||
}
|
||||
|
||||
test("all three composers ignore Enter while an IME is composing", () => {
|
||||
const main = page.slice(
|
||||
page.indexOf("function handleComposerKeyDown"),
|
||||
page.indexOf("\n\n if (!hydrated"),
|
||||
);
|
||||
const rectificationHandler = onKeyDownBlock(rectification, "onKeyDown={(event) => {");
|
||||
const guideHandler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
|
||||
|
||||
for (const [name, handler] of [
|
||||
["main composer", main],
|
||||
["rectification composer", rectificationHandler],
|
||||
["birth-time guide", guideHandler],
|
||||
] as const) {
|
||||
assert.match(handler, /event\.nativeEvent\.isComposing/, `${name} must ignore composing Enter`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the guide turn sends with Enter and wraps with Shift+Enter, matching the other two", () => {
|
||||
const handler = onKeyDownBlock(guide, "onKeyDown={(event) => {");
|
||||
assert.match(handler, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.doesNotMatch(handler, /metaKey \|\| event\.ctrlKey/);
|
||||
assert.match(guide, /Enter 发送,Shift\+Enter 换行/);
|
||||
});
|
||||
@@ -64,15 +64,43 @@ test("dark ink reads lighter than dark canvas, and the action stays legible", ()
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const canvas = pinned.get("color-canvas")!;
|
||||
for (const token of ["color-ink", "color-action", "color-danger", "color-success", "color-warning"]) {
|
||||
const value = pinned.get(token)!;
|
||||
assert.match(value, /^#[0-9a-f]{6}$/i, `--${token} must be a hex so contrast is checkable`);
|
||||
assert.ok(
|
||||
contrast(value, canvas) >= 4.5,
|
||||
`--${token} (${value}) on --color-canvas (${canvas}) is ${contrast(value, canvas).toFixed(2)}:1, below AA`,
|
||||
);
|
||||
// Checking only the reading surface missed ink-tertiary and action on the
|
||||
// raised card (--color-canvas-muted). New text or surface tokens must join
|
||||
// this matrix; do not drop a pair or lower 4.5 to make it pass.
|
||||
const inks = [
|
||||
"color-ink",
|
||||
"color-ink-strong",
|
||||
"color-ink-secondary",
|
||||
"color-ink-tertiary",
|
||||
"color-action",
|
||||
"color-danger",
|
||||
"color-success",
|
||||
"color-warning",
|
||||
];
|
||||
const surfaces = [
|
||||
"color-canvas",
|
||||
"color-canvas-soft",
|
||||
"color-canvas-muted",
|
||||
"color-sidebar-solid",
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const ink of inks) {
|
||||
const inkValue = pinned.get(ink)!;
|
||||
assert.match(inkValue, /^#[0-9a-f]{6}$/i, `--${ink} must be a hex so contrast is checkable`);
|
||||
for (const surface of surfaces) {
|
||||
const surfaceValue = pinned.get(surface)!;
|
||||
assert.match(
|
||||
surfaceValue,
|
||||
/^#[0-9a-f]{6}$/i,
|
||||
`--${surface} must be a hex so contrast is checkable`,
|
||||
);
|
||||
const ratio = contrast(inkValue, surfaceValue);
|
||||
if (ratio < 4.5) {
|
||||
failures.push(`--${ink} (${inkValue}) on --${surface} (${surfaceValue}) is ${ratio.toFixed(2)}:1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(failures.length, 0, failures.join("; "));
|
||||
// Elevation reads through lightness on dark: floor is darkest, raised steps up.
|
||||
const steps = ["color-canvas-soft", "color-canvas", "color-canvas-muted", "color-canvas-strong"]
|
||||
.map((token) => luminance(pinned.get(token)!));
|
||||
@@ -80,3 +108,22 @@ test("dark ink reads lighter than dark canvas, and the action stays legible", ()
|
||||
assert.ok(steps[i] > steps[i - 1], "dark surfaces must get lighter as they rise");
|
||||
}
|
||||
});
|
||||
|
||||
test("the previous dark action and tertiary hexes are gone from product sources", () => {
|
||||
const sources = [
|
||||
globalStyles,
|
||||
readFileSync(new URL("../src/app/error.tsx", import.meta.url), "utf8"),
|
||||
readFileSync(new URL("../src/app/not-found.tsx", import.meta.url), "utf8"),
|
||||
readFileSync(new URL("../src/app/forbidden.tsx", import.meta.url), "utf8"),
|
||||
readFileSync(new URL("../src/app/global-error.tsx", import.meta.url), "utf8"),
|
||||
readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8"),
|
||||
];
|
||||
for (const source of sources) {
|
||||
assert.doesNotMatch(source, /#d4785a/i);
|
||||
}
|
||||
assert.equal(pinned.get("color-action"), "#d78064");
|
||||
assert.equal(pinned.get("color-focus"), "#d78064");
|
||||
assert.equal(pinned.get("color-ink-tertiary"), "#9c988e");
|
||||
assert.equal(preferred.get("color-action"), "#d78064");
|
||||
assert.equal(preferred.get("color-ink-tertiary"), "#9c988e");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
function minHeightPx(selector: string) {
|
||||
const matches = [...cssDeclarations(selector, globalStyles).matchAll(/min-height:\s*(\d+)px/g)]
|
||||
.map((hit) => Number(hit[1]));
|
||||
assert.ok(matches.length > 0, `${selector} must declare min-height`);
|
||||
return Math.max(...matches);
|
||||
}
|
||||
|
||||
function hasHitExpandPseudo(selector: string) {
|
||||
const after = cssDeclarations(`${selector}::after`, globalStyles);
|
||||
assert.match(after, /position:\s*absolute/);
|
||||
assert.match(after, /content:\s*""/);
|
||||
return after;
|
||||
}
|
||||
|
||||
test("40px controls grow to a 44px box when that does not pack a scrolling list badly", () => {
|
||||
assert.ok(minHeightPx(".select-item") >= 44);
|
||||
assert.ok(minHeightPx(".birth-time-window-details .birth-time-skip-button") >= 44);
|
||||
assert.ok(minHeightPx(".report-center-section-heading button") >= 44);
|
||||
});
|
||||
|
||||
test("icon and chip controls keep their visual size and expand the hit with a pseudo-element", () => {
|
||||
const actions = cssDeclarations(".message-actions button", globalStyles);
|
||||
assert.match(actions, /width:\s*26px/);
|
||||
assert.match(actions, /height:\s*26px/);
|
||||
assert.match(cssDeclarations(".message-actions", globalStyles), /gap:\s*1px/);
|
||||
const actionHit = hasHitExpandPseudo(".message-actions button");
|
||||
assert.match(actionHit, /width:\s*27px/);
|
||||
assert.match(actionHit, /height:\s*34px/);
|
||||
|
||||
assert.equal(minHeightPx(".chart-nav-chip"), 32);
|
||||
assert.match(hasHitExpandPseudo(".chart-nav-chip"), /inset:\s*-4px/);
|
||||
|
||||
const auth = cssDeclarations(".auth-links button", globalStyles);
|
||||
assert.match(auth, /min-height:\s*32px/);
|
||||
assert.match(hasHitExpandPseudo(".auth-links button"), /inset:\s*-4px/);
|
||||
});
|
||||
Reference in New Issue
Block a user