f542c02490
The rectification session rendered its own textarea (no length ceiling, no count), its own rAF scroll follow with a sticky-scroll helper, and its own jump chip styled differently from the main chat's inline Tailwind button, while the main chat re-ran scrollTo on every streamed token and added a smooth scroll on settle. ChatComposer now accepts a controlled value so the rectification surface reuses it without touching the main draft store; useConversationScrollAnchor owns the follow through a ResizeObserver, one frame per content change, for both surfaces; a single JumpToLatestButton replaces both chips; the 720px transcript override is gone. DESIGN.md records the shared composer, the jump control, the reading width and the two shadow tokens. BUG-477 BUG-478 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
59 lines
3.3 KiB
TypeScript
59 lines
3.3 KiB
TypeScript
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";
|
|
import { homeSurface as page } from "./home-surface.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 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", () => {
|
|
// Former lock: `aria-describedby={showRemaining ? composerRemainingId : undefined}`. The composer
|
|
// is now shared with the rectification surface, which passes its own count id (BUG-477).
|
|
assert.match(composer, /remainingId = composerRemainingId/);
|
|
assert.match(composer, /showRemaining \? remainingId : undefined\]\.filter\(Boolean\)\.join\(" "\)/);
|
|
assert.match(composer, /aria-describedby=\{describedByIds\}/);
|
|
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"/);
|
|
});
|