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:
@@ -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; }
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} 字上限` : "接近字数上限";
|
||||
}
|
||||
Reference in New Issue
Block a user