fix(frontend): raise dark contrast, expand hit areas, and surface input limits
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 9m54s

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:
Jesse_Chen
2026-08-30 11:19:00 +08:00
parent b43808b016
commit e8fa1ce4ea
20 changed files with 555 additions and 40 deletions
+2 -2
View File
@@ -76,7 +76,7 @@ export default function RootError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
}
@@ -86,7 +86,7 @@ export default function RootError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
.root-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
+2 -2
View File
@@ -38,7 +38,7 @@ export default function Forbidden() {
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-action: #d4785a;
--bp-action: #d78064;
}
}
:root[data-theme="dark"] {
@@ -46,7 +46,7 @@ export default function Forbidden() {
--bp-canvas: #262624;
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-action: #d4785a;
--bp-action: #d78064;
}
`}</style>
<section style={{ maxWidth: "32rem", textAlign: "center" }}>
+2 -2
View File
@@ -60,7 +60,7 @@ export default function GlobalError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
}
@@ -70,7 +70,7 @@ export default function GlobalError({
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
.global-error-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
+55 -12
View File
@@ -158,16 +158,16 @@
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
a dark ground. Hover now lightens instead of darkening. */
--color-action: #d4785a;
--color-action: #d78064;
--color-action-soft: #3a2620;
--color-action-hover: #e59273;
--color-focus: #d4785a;
--color-focus: #d78064;
--color-action-on-dark: #8f4a33;
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
--color-ink-secondary: #b3afa4;
--color-ink-tertiary: #928e84;
--color-ink-tertiary: #9c988e;
--color-sidebar: rgba(31, 30, 29, .86);
--color-sidebar-solid: #1f1e1d;
@@ -206,7 +206,7 @@
a lit sheet in a dark room. */
--report-paper: #262624;
--report-rule: #3c3b37;
--report-accent: #d4785a;
--report-accent: #d78064;
/* Hairlines that were dark-on-light have to become light-on-dark. */
--ring-hairline: oklch(1 0 0 / .14);
@@ -229,16 +229,16 @@
/* The clay hue is kept and lifted: #85432f is too dark to read as an action on
a dark ground. Hover now lightens instead of darkening. */
--color-action: #d4785a;
--color-action: #d78064;
--color-action-soft: #3a2620;
--color-action-hover: #e59273;
--color-focus: #d4785a;
--color-focus: #d78064;
--color-action-on-dark: #8f4a33;
--color-ink: #f2f0ea;
--color-ink-strong: #e2dfd6;
--color-ink-secondary: #b3afa4;
--color-ink-tertiary: #928e84;
--color-ink-tertiary: #9c988e;
--color-sidebar: rgba(31, 30, 29, .86);
--color-sidebar-solid: #1f1e1d;
@@ -277,7 +277,7 @@
a lit sheet in a dark room. */
--report-paper: #262624;
--report-rule: #3c3b37;
--report-accent: #d4785a;
--report-accent: #d78064;
/* Hairlines that were dark-on-light have to become light-on-dark. */
--ring-hairline: oklch(1 0 0 / .14);
@@ -730,6 +730,7 @@ button:disabled { cursor: default; opacity: .45; }
.sidebar-empty { margin: 0; padding: 0 0 var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.35; }
.chart-nav-list { display: flex; flex-wrap: wrap; gap: var(--space-2); padding: 0 0 var(--space-2); }
.chart-nav-chip {
position: relative;
min-height: 32px;
max-width: 100%;
padding: 0 var(--space-3);
@@ -742,6 +743,12 @@ button:disabled { cursor: default; opacity: .45; }
font-weight: 400;
line-height: 1.35;
}
/* Visual chip stays 32px; wrap gap is 8px, so a 44px overlay would overlap. */
.chart-nav-chip::after {
position: absolute;
inset: -4px;
content: "";
}
.chart-nav-chip:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.sidebar-label:focus-visible { outline: 3px solid color-mix(in srgb, var(--sidebar-ring) 56%, transparent); outline-offset: 2px; border-radius: var(--radius-sm); }
.session-nav-toggle { min-height: 44px; min-width: 44px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-tertiary); cursor: pointer; font-size: var(--type-caption); transition: background-color 120ms ease-out, color 120ms ease-out; }
@@ -1251,6 +1258,7 @@ button:disabled { cursor: default; opacity: .45; }
.conversation-follow-up:hover { background: var(--color-canvas-muted); }
.conversation-follow-up:disabled { cursor: not-allowed; opacity: .56; }
.message-actions button {
position: relative;
display: inline-flex;
width: 26px;
height: 26px;
@@ -1266,6 +1274,18 @@ button:disabled { cursor: default; opacity: .45; }
opacity: .7;
transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out;
}
/* Visual icon stays 26px. Neighbours sit 27px centre-to-centre (26px + 1px gap)
and follow-ups start after an 8px bottom margin, so a 44×44 overlay would
steal clicks. This is the largest non-overlapping hit: 27×34. */
.message-actions button::after {
position: absolute;
top: 50%;
left: 50%;
width: 27px;
height: 34px;
content: "";
transform: translate(-50%, -50%);
}
.message-actions button:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent);
color: var(--color-ink-secondary);
@@ -1502,6 +1522,23 @@ button:disabled { cursor: default; opacity: .45; }
.composer .composer-stop { background: var(--color-action); }
.composer .composer-stop:not(:disabled):hover { background: var(--color-action-hover); }
.composer-footer { width: min(760px, 100%); min-height: 44px; display: flex; align-items: center; margin: var(--space-1) auto 0; }
.character-remaining {
margin-left: auto;
flex-shrink: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.4;
white-space: nowrap;
}
.character-remaining[data-limit] { color: var(--color-danger); }
.character-remaining-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-2);
}
.model-selector-trigger { width: fit-content; min-width: 0; max-width: min(100%, 360px); min-height: 44px; display: inline-flex; grid-column: 1; align-items: center; justify-self: start; gap: var(--space-2); border: 0; padding: 0 var(--space-2); border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out; }
.model-selector-trigger > b { min-width: 0; overflow: visible; overflow-wrap: anywhere; text-align: left; white-space: normal; font-size: var(--type-caption); font-weight: 500; line-height: 1.35; }
.model-selector-trigger svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); transition: transform 120ms ease-out; }
@@ -1654,7 +1691,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.auth-mode-tabs button, .auth-links button { min-width: 0; min-height: 40px; border: 0; background: transparent; cursor: pointer; color: var(--color-ink-secondary); font: inherit; font-size: 13px; }
.auth-mode-tabs button[aria-pressed="true"] { border-radius: calc(var(--radius-md) - 2px); background: var(--color-canvas); color: var(--color-ink); box-shadow: var(--shadow-soft); }
.auth-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); }
.auth-links button { min-height: 32px; padding: 0; color: var(--color-action); }
.auth-links button { position: relative; min-height: 32px; padding: 0; color: var(--color-action); }
/* Visual link stays 32px; wrap gap is 8px, so a 44px overlay would overlap. */
.auth-links button::after {
position: absolute;
inset: -4px;
content: "";
}
.admin-page { background: var(--color-canvas-soft); }
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
@@ -2447,7 +2490,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
font-size: var(--type-caption);
line-height: 1.5;
}
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 44px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
.birth-time-window-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-grid > label { gap: 7px; }
.birth-time-detail-grid > label > span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 700; }
@@ -2504,7 +2547,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.select-content { width: var(--anchor-width); min-width: 190px; max-width: min(360px, calc(100vw - 32px)); height: auto !important; max-height: min(360px, calc(100vh - 32px)); overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); padding: 5px; box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); animation: select-content-in 140ms var(--ease-out) both; }
.select-list { display: grid; gap: 2px; max-height: min(320px, 50vh) !important; overflow-y: auto !important; outline: none; scrollbar-width: none; -ms-overflow-style: none; }
.select-list::-webkit-scrollbar { width: 0; height: 0; display: none; }
.select-item { min-height: 40px; position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 9px 10px; border-radius: var(--radius-sm); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.35; cursor: pointer; outline: none; user-select: none; }
.select-item { min-height: 44px; position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 9px 10px; border-radius: var(--radius-sm); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.35; cursor: pointer; outline: none; user-select: none; }
.select-item:hover, .select-item[data-highlighted] { background: var(--color-action-soft); color: var(--color-action-hover); }
.select-item[data-selected] { color: var(--color-action-hover); font-weight: 650; }
.select-item[data-disabled] { cursor: not-allowed; opacity: .45; }
@@ -3052,7 +3095,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.report-center-section { max-width: 1080px; margin: 0 auto; }
.report-center-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 14px; }
.report-center-section-heading h2 { margin: 0; font-family: var(--font-body); font-size: 20px; font-weight: 650; letter-spacing: -.015em; }
.report-center-section-heading button { min-height: 40px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.report-center-section-heading button { min-height: 44px; display: inline-flex; align-items: center; gap: 7px; padding: 0 8px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
.report-center-section-heading button:hover { color: var(--color-action); }
.report-center-list { border-top: 1px solid var(--color-border-strong); }
.report-center-card { min-height: 126px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; padding: 22px 0; border-bottom: 1px solid var(--color-border); }
+2 -2
View File
@@ -76,7 +76,7 @@ export default function RootNotFound() {
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
}
@@ -86,7 +86,7 @@ export default function RootNotFound() {
--bp-ink: #f2f0ea;
--bp-ink-soft: #b3afa4;
--bp-border: #3c3b37;
--bp-action: #d4785a;
--bp-action: #d78064;
--bp-danger: #e08573;
}
.root-not-found-action:focus-visible { outline: 3px solid ${focus}; outline-offset: 2px; }
+19 -2
View File
@@ -37,6 +37,9 @@ import { ModelSelector } from "@/components/model-selector";
import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall";
import { BirthPlacePicker } from "@/components/birth-place-picker";
import { ChatComposer } from "@/components/chat-composer";
import { ComposerCharacterRemaining } from "@/components/composer-character-remaining";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
import { clearStaleClientReload } from "@/lib/stale-client-recovery";
@@ -902,11 +905,24 @@ function invalidateCandidateAfterLocationChange(current: Profile, next: Profile)
}
function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
const nameRemainingId = `${nameInputId ?? "profile-name"}-remaining`;
return (
<>
<label>
<span></span>
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
<span className="character-remaining-row">
<CharacterRemaining id={nameRemainingId} length={value.name.length} maxLength={80} />
</span>
<input
id={nameInputId}
required
autoComplete="name"
maxLength={80}
placeholder="例如:林遥"
value={value.name}
aria-describedby={characterRemainingVisible(value.name.length, 80) ? nameRemainingId : undefined}
onChange={(event) => onChange({ ...value, name: event.target.value })}
/>
</label>
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange(applyBirthTimeDraftPatch(value, patch))} />
<BirthLocationFields
@@ -4301,6 +4317,7 @@ export default function Home() {
disabled={!activeSession || isLoading || cancellationPending || creatingSession}
onSelect={(modelId) => void selectSessionModel(modelId)}
/>
<ComposerCharacterRemaining maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500} />
</div>
</div>}
</SidebarInset>
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
choiceQuestionGroups,
choiceSelectionIntent,
@@ -94,6 +96,8 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
);
}
const unmatchedRemainingId = "birth-time-unmatched-remaining";
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
const [note, setNote] = useState("");
const reframeRef = useRef<HTMLButtonElement | null>(null);
@@ -108,9 +112,13 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
<fieldset className="birth-time-choice-question" disabled={props.pending}>
<legend></legend>
<label className="birth-time-unmatched-note">
<span></span>
<span className="character-remaining-row">
<CharacterRemaining id={unmatchedRemainingId} length={note.length} maxLength={240} />
</span>
<textarea
maxLength={240}
aria-describedby={characterRemainingVisible(note.length, 240) ? unmatchedRemainingId : undefined}
onChange={(event) => setNote(event.target.value)}
placeholder="例如:这段变化发生得更早,或情况不太一样"
value={note}
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import type { JourneyProgress } from "@/lib/birth-time-journey-turn";
import { protectOnboardingPhrases } from "@/lib/onboarding-copy";
@@ -13,11 +15,17 @@ type BirthTimeGuideTurnProps = {
readonly onPause: () => void;
};
const guideRemainingId = "birth-time-guide-remaining";
export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
const [message, setMessage] = useState("");
const phase = props.progress.adaptiveRound > 0
? `自适应第 ${props.progress.adaptiveRound} / ${props.progress.maxAdaptiveRounds}`
: "基础证据";
const showRemaining = characterRemainingVisible(message.length, 500);
const describedBy = showRemaining
? `birth-time-guide-hint ${guideRemainingId}`
: "birth-time-guide-hint";
return (
<div className="birth-time-guide-turn" aria-live="polite">
@@ -27,23 +35,27 @@ export function BirthTimeGuideTurn(props: BirthTimeGuideTurnProps) {
</div>
<p className="birth-time-guide-question">{protectOnboardingPhrases(props.question)}</p>
<label className="birth-time-guide-composer">
<span></span>
<span className="character-remaining-row">
<CharacterRemaining id={guideRemainingId} length={message.length} maxLength={500} />
</span>
<textarea
aria-describedby="birth-time-guide-hint"
aria-describedby={describedBy}
disabled={props.pending}
maxLength={500}
rows={3}
value={message}
onChange={(event) => setMessage(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && message.trim()) {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter" && !event.shiftKey && message.trim()) {
event.preventDefault();
props.onSubmit(message.trim());
}
}}
/>
</label>
<p id="birth-time-guide-hint" className="birth-time-guide-hint"></p>
<p id="birth-time-guide-hint" className="birth-time-guide-hint">Enter Shift+Enter </p>
<div className="birth-time-guided-actions">
<button className="button-text birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onPause}></button>
<button className="button-secondary birth-time-guided-action" disabled={props.pending} type="button" onClick={props.onSkip}></button>
@@ -0,0 +1,31 @@
import {
characterRemainingAnnouncement,
characterRemainingLabel,
characterRemainingVisible,
} from "@/lib/character-remaining";
export function CharacterRemaining({
id,
length,
maxLength,
}: {
id: string;
length: number;
maxLength: number;
}) {
if (!characterRemainingVisible(length, maxLength)) return null;
const remaining = maxLength - length;
const atLimit = remaining <= 0;
return (
<span
id={id}
className="character-remaining"
data-limit={atLimit ? "true" : undefined}
aria-live="polite"
aria-atomic="true"
>
<span aria-hidden="true">{characterRemainingLabel(remaining, maxLength)}</span>
<span className="sr-only">{characterRemainingAnnouncement(remaining, maxLength)}</span>
</span>
);
}
@@ -5,12 +5,15 @@ import type { ChangeEvent, FormEvent, KeyboardEvent, RefObject } from "react";
import { ArrowUp, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
composerDraftSnapshot,
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
export const composerRemainingId = "composer-character-remaining";
export function useComposerDraft(): string {
return useSyncExternalStore(
subscribeComposerDraft,
@@ -53,12 +56,14 @@ export function ChatComposer({
onStop,
}: ChatComposerProps) {
const draft = useComposerDraft();
const showRemaining = characterRemainingVisible(draft.length, maxLength);
return (
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={showRemaining ? composerRemainingId : undefined}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
@@ -0,0 +1,9 @@
"use client";
import { CharacterRemaining } from "@/components/character-remaining";
import { composerRemainingId, useComposerDraft } from "@/components/chat-composer";
export function ComposerCharacterRemaining({ maxLength }: { maxLength: number }) {
const draft = useComposerDraft();
return <CharacterRemaining id={composerRemainingId} length={draft.length} maxLength={maxLength} />;
}
+17
View File
@@ -0,0 +1,17 @@
/** Show the remaining-count hint when this many characters (or fewer) are left. */
export function remainingThreshold(maxLength: number): number {
return Math.max(8, Math.min(50, Math.round(maxLength / 10)));
}
export function characterRemainingVisible(length: number, maxLength: number): boolean {
return maxLength - length <= remainingThreshold(maxLength);
}
export function characterRemainingLabel(remaining: number, maxLength: number): string {
return remaining <= 0 ? `已达 ${maxLength} 字上限` : `还剩 ${remaining}`;
}
/** Stable live text: one announcement on entering the threshold, one at the cap. */
export function characterRemainingAnnouncement(remaining: number, maxLength: number): string {
return remaining <= 0 ? `已达 ${maxLength} 字上限` : "接近字数上限";
}