Files
Jyotisha/frontend/src/components/chat-composer.tsx
T
Jesse_ChenandClaude Opus 5 66d9f59d84
Independent Staging Quality Gate / validate (push) Failing after 9m26s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 标题不再落宋体,强调色分阶换 coral,删输入框常驻底栏(BUG-737/738)
BUG-737:--font-display 以两个从未加载的 Anthropic 授权字体开头
(Tiempos Headline / StyreneB:无 @font-face、public/ 无字体、layout.tsx
只 vendor 了 Inter),于是每个中文标题都掉到 Songti SC / SimSun——20 条
规则宽,含助手回答正文的 h2/h3。display 与 body 合并为同一条无衬线栈,
层级改由字重承担(33 条 display 规则 400→500)。

拉丁衬线方案实测否决:Newsreader 拉丁子集 132 KB(Inter 的 2.7 倍),
只为给一个品牌字上衬线,且会把「D10 事业盘怎么读」劈成两种字形。

BUG-738:亮色 #85432f 与暗色 #d78064 不同源;--color-ring 在
@theme inline 里硬编码不跟随 :root。产品拍板换 Claude coral #cc785c,
但实测它作文字色只有 3.14:1,而 62 个调用点里 53 个是 color:。
按角色拆两阶:--color-action #a9583e(文字 4.85:1)、
--color-action-strong #cc785c(填充/导轨/焦点环,3:1 非文字阈值)。
--report-accent 刻意保持 #85432f(报告是纸面,不跟应用强调色)。

T1.3/T1.4:模型选择器进 ChatComposer 的 toolbar 插槽,删掉常驻 44px 的
.composer-footer;顶栏 68→46px,「分析对象:」改为标题旁静默 chip。

防复发:新增 font-stack-loadable-contract,断言字体栈里每个 family 要么
vendored 要么是系统字体,且 --font-display 不得触达任何 CJK 衬线。

测试 3346→3350,fail 仍 31 且清单与基线逐条一致;/ 仍 Static;
CSS gzip 41,095→41,096(+0.002%);快速门 pytest 段 798 passed / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 04:53:14 +00:00

139 lines
4.5 KiB
TypeScript

"use client";
import { useSyncExternalStore } from "react";
import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode, RefObject } from "react";
import { ArrowUp, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { CharacterRemaining } from "@/components/character-remaining";
import { characterRemainingVisible } from "@/lib/character-remaining";
import {
composerDraftSnapshot,
serverComposerDraftSnapshot,
subscribeComposerDraft,
} from "@/lib/composer-draft";
import { COMPOSER_QUEUE_LABEL, COMPOSER_QUEUE_RECALL_LABEL } from "@/lib/queued-draft";
export const composerRemainingId = "composer-character-remaining";
export function useComposerDraft(): string {
return useSyncExternalStore(
subscribeComposerDraft,
composerDraftSnapshot,
serverComposerDraftSnapshot,
);
}
type ChatComposerProps = {
readonly inputRef: RefObject<HTMLTextAreaElement | null>;
/**
* A surface that owns its own draft (the rectification session) passes it here;
* the main chat leaves it undefined and the composer reads the draft store.
*/
readonly value?: string;
readonly remainingId?: string;
/** Extra description (a question stem shown above the field) merged with the count id. */
readonly describedBy?: string;
readonly inputLabel: string;
readonly placeholder: string;
readonly maxLength: number;
readonly inputDisabled: boolean;
readonly submitLabel: string;
readonly submitBlocked: boolean;
readonly stopVisible: boolean;
readonly stopLabel: string;
readonly stopTitle: string;
readonly queued?: { text: string; onRecall: () => void };
/**
* Controls that belong to the composer itself (model picker, chart subject).
* They render on the composer's own bottom row; there is no longer a strip
* below the composer holding a permanent 44px of chrome.
*/
readonly toolbar?: ReactNode;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
readonly onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
readonly onStop: () => void;
};
export function ChatComposer({
inputRef,
value,
remainingId = composerRemainingId,
describedBy,
inputLabel,
placeholder,
maxLength,
inputDisabled,
submitLabel,
submitBlocked,
stopVisible,
stopLabel,
stopTitle,
queued,
toolbar,
onSubmit,
onChange,
onKeyDown,
onStop,
}: ChatComposerProps) {
const storeDraft = useComposerDraft();
const draft = value ?? storeDraft;
const showRemaining = characterRemainingVisible(draft.length, maxLength);
const describedByIds = [describedBy, showRemaining ? remainingId : undefined].filter(Boolean).join(" ") || undefined;
return (
<>
{queued ? (
<div className="composer-queue" role="status">
<div className="composer-queue__body">
<p className="composer-queue__label">{COMPOSER_QUEUE_LABEL}</p>
<p className="composer-queue__text">{queued.text}</p>
</div>
<button className="composer-queue__recall" type="button" onClick={queued.onRecall}>
{COMPOSER_QUEUE_RECALL_LABEL}
</button>
</div>
) : null}
<form className="composer" onSubmit={onSubmit}>
<Textarea
ref={inputRef}
aria-label={inputLabel}
aria-describedby={describedByIds}
placeholder={placeholder}
rows={1}
maxLength={maxLength}
disabled={inputDisabled}
value={draft}
onChange={onChange}
onKeyDown={onKeyDown}
/>
<div className="composer-row">
{toolbar ? <div className="composer-tools">{toolbar}</div> : null}
<div className="composer-row-end">
{showRemaining ? (
<CharacterRemaining id={remainingId} length={draft.length} maxLength={maxLength} />
) : null}
{stopVisible ? (
<Button
className="composer-stop"
aria-label={stopLabel}
title={stopTitle}
size="icon"
type="button"
onClick={onStop}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={submitLabel} disabled={!draft.trim() || submitBlocked} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</div>
</div>
</form>
</>
);
}