perf(chat): isolate the composer, split heavy chunks, fix dead tokens
Independent Staging Quality Gate / validate (push) Successful in 13m15s
Independent Staging Quality Gate / publish (push) Successful in 10m9s

Second batch from the staging UX audit (BUG-248..253).

- css: expose the 32 palette tokens through @theme. Seven utilities
  including text-ink, text-danger and text-warning compiled to no CSS
  at all, so 30 call sites had been silently inert (BUG-248)
- chat: move the composer into its own component behind a draft store,
  so a keystroke no longer re-renders a 2723-line component, and
  persist the draft across reloads (BUG-249)
- chat: load gsap, react-markdown and thinking-orbs on demand. First
  Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250)
- chat: route the five in-app destinations through router.push, and
  keep the five auth redirects and the bootstrap retry as hard loads
  on purpose (BUG-251)
- a11y: announce reply completion, and move the live region out of the
  aria-busy subtree that was likely suppressing even the start
  announcement (BUG-252)
- docs: give the 27 collided bug ids unique numbers and repair their
  inbound references; require search rather than a full read of a
  3690-line file (BUG-253)

Verified: tsc, eslint, next build, and 1592 assertions across the 199
non-database test files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 14:16:05 +08:00
co-authored by Cursor
parent 9c296f1e3f
commit e8d201dd9a
19 changed files with 1229 additions and 169 deletions
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { plainParagraphs } from "../src/components/chat-message-paragraphs.ts";
const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const contentSource = readProjectFile("src/components/chat-message-content.tsx");
const markdownViewSource = readProjectFile("src/components/chat-markdown-view.tsx");
const messageRowSource = readProjectFile("src/components/chat-message-row.tsx");
const activitySource = readProjectFile("src/components/agent-activity-status.tsx");
const prefetchSource = readProjectFile("src/components/chat-chunk-prefetch.ts");
test("the markdown pipeline is split out of the initial chat bundle", () => {
// Given: the chat surface statically imports the message content component.
assert.doesNotMatch(contentSource, /^import .*"react-markdown"/m);
assert.doesNotMatch(contentSource, /^import .*"remark-gfm"/m);
// Then: the renderer only arrives through a dynamic import.
assert.match(contentSource, /import\("@\/components\/chat-markdown-view"\)/);
assert.match(contentSource, /markdownRenderer = module\.renderChatMarkdown/);
assert.match(markdownViewSource, /^import ReactMarkdown, \{ type Components \} from "react-markdown";$/m);
assert.match(markdownViewSource, /^import remarkGfm from "remark-gfm";$/m);
assert.match(markdownViewSource, /remarkPlugins=\{\[remarkGfm\]\}/);
assert.match(markdownViewSource, /skipHtml/);
});
test("the pre-markdown fallback renders prose but never raw markdown", () => {
// Given: a reply that carries markdown syntax.
assert.equal(plainParagraphs("**重点**在这里"), null);
assert.equal(plainParagraphs("## 阶段判断"), null);
assert.equal(plainParagraphs("- 第一点"), null);
assert.equal(plainParagraphs("1. 第一点"), null);
assert.equal(plainParagraphs("| 宫位 | 行星 |"), null);
assert.equal(plainParagraphs("`Vimshottari`"), null);
assert.equal(plainParagraphs("参见 https://example.com"), null);
assert.equal(plainParagraphs("> 引用"), null);
assert.equal(plainParagraphs("[链接](/x)"), null);
assert.equal(plainParagraphs("行尾软换行 \n下一行"), null);
// Then: only plain prose takes the fallback path, split exactly like CommonMark.
assert.deepEqual(plainParagraphs("第一段。\n\n第二段。"), ["第一段。", "第二段。"]);
assert.deepEqual(plainParagraphs("一行\n下一行"), ["一行\n下一行"]);
assert.deepEqual(plainParagraphs("只有一段。"), ["只有一段。"]);
// And: one stable wrapper serves both paths, so no styling or caret is lost on upgrade.
assert.equal(contentSource.match(/className="message-markdown"/g)?.length, 1);
assert.match(contentSource, /<p key=\{index\}>\{paragraph\}<\/p>/);
assert.match(contentSource, /plainParagraphs\(text\) \?\? \[\]/);
});
test("gsap loads on demand while keeping the reduced-motion gate", () => {
// Given: the animation engine is no longer part of the static import graph.
assert.doesNotMatch(messageRowSource, /^import .*from "gsap"/m);
assert.doesNotMatch(messageRowSource, /@gsap\/react/);
assert.match(messageRowSource, /import\("gsap"\)/);
// Then: reduced-motion users never request it, and the tween contract is unchanged.
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
assert.match(messageRowSource, /duration: 0\.18/);
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
// And: a row that mounts before the chunk lands renders unanimated instead of flashing.
assert.match(messageRowSource, /if \(!gsapCore\) \{/);
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
});
test("the thinking orb loads on demand behind a same-size placeholder", () => {
// Given: only the state type is imported at build time.
assert.doesNotMatch(activitySource, /^import \{ ThinkingOrb/m);
assert.match(activitySource, /^import type \{ OrbState \} from "thinking-orbs";$/m);
assert.match(activitySource, /dynamic\(async \(\) => \(await importThinkingOrb\(\)\)\.ThinkingOrb/);
assert.match(activitySource, /ssr: false/);
// Then: the placeholder reserves the exact 20px orb box so the row cannot shift.
assert.match(activitySource, /loading: \(\) => \(/);
assert.match(activitySource, /height: 20, width: 20/);
assert.match(activitySource, /flex: "0 0 auto"/);
// And: the live region and the decorative marking survive.
assert.match(activitySource, /role="status"/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
});
test("deferred chat chunks are requested during idle time after first paint", () => {
assert.match(prefetchSource, /if \(typeof window === "undefined"\) return;/);
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
assert.match(activitySource, /prefetchOnIdle\(importThinkingOrb\)/);
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
});
@@ -0,0 +1,223 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
chatReplyAnnouncement,
chatReplyAnnouncer,
type ChatReplyPhase,
} from "../src/lib/chat-reply-announcement.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const membershipSource = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8");
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
const announcementSource = readFileSync(new URL("../src/lib/chat-reply-announcement.ts", import.meta.url), "utf8");
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
return source.slice(start, end);
}
test("in-app destinations navigate client-side so the chat survives the round trip", () => {
// Given: the chat page now owns a router.
assert.match(pageSource, /import \{ useRouter \} from "next\/navigation"/);
assert.match(pageSource, /const router = useRouter\(\)/);
// Then: every same-app destination the user comes back from is a soft push.
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
assert.match(pageSource, /onOpenRedeem=\{\(\) => router\.push\(membershipHref\("account-menu"\)\)\}/);
assert.match(pageSource, /onClick=\{\(\) => router\.push\(membershipHref\("credits"\)\)\}/);
assert.match(pageSource, /router\.push\(membershipHref\("insufficient-credits"\)\)/);
assert.match(pageSource, /if \(response\.status === 402\) router\.push\(membershipHref\("insufficient-credits"\)\)/);
// And: none of them fall back to a document load.
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
assert.doesNotMatch(pageSource, /window\.location\.assign\(membershipHref\(/);
});
test("both insufficient-credit paths are soft so the typed question is not thrown away", () => {
const creditGuard = sourceBetween(
pageSource,
"if (account.credits <= 0 && !account.activeSubscription) {",
"const [year, month, day]",
);
const sendSource = pageSource.slice(pageSource.indexOf(" async function send("));
// Given: the pre-flight guard runs before send() clears the composer draft.
assert.match(creditGuard, /router\.push\(membershipHref\("insufficient-credits"\)\)/);
assert.doesNotMatch(creditGuard, /window\.location/);
assert.ok(sendSource.indexOf('router.push(membershipHref("insufficient-credits"))')
< sendSource.indexOf(' setDraft("");'));
assert.equal(pageSource.match(/router\.push\(membershipHref\("insufficient-credits"\)\)/g)?.length, 2);
});
test("auth redirects stay hard document loads so stale session state cannot survive", () => {
// Given: a 401 means the client is holding a session the server has rejected.
// Then: every login redirect is a deliberate full page load, not router.push.
assert.match(pageSource, /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/);
assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4);
assert.equal(pageSource.match(/window\.location\.replace\("\/login"\)/g)?.length, 1);
assert.doesNotMatch(pageSource, /router\.push\("\/login"\)/);
assert.doesNotMatch(pageSource, /router\.replace\("\/login"\)/);
// And: the login redirect after sign-out drops the whole React tree with the session.
const signOut = sourceBetween(pageSource, "async function signOut()", "function chooseSuggestedQuestion");
assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/);
// And: the sibling membership surface keeps the same precedent.
assert.match(membershipSource, /import \{ useSearchParams \} from "next\/navigation"/);
assert.equal(membershipSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 3);
assert.doesNotMatch(membershipSource, /router\.(push|replace)\("\/login"\)/);
});
test("bootstrap failure retries with a hard reload because refresh cannot rerun client bootstrap", () => {
// Given: the account, catalog and sessions are fetched from a mount-once client effect,
// which router.refresh() would not re-run because it never remounts the client tree.
assert.match(pageSource, /onClick=\{\(\) => window\.location\.reload\(\)\}>重试<\/button>/);
assert.doesNotMatch(pageSource, /router\.refresh\(\)/);
assert.match(pageSource, /void loadCloudData\(\);\n\s*return \(\) => \{[\s\S]*?\n \}, \[\]\);/);
});
test("the streaming reply announces state transitions, never streamed deltas", () => {
// Given: one page-level status region outside every aria-busy subtree.
assert.match(
pageSource,
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
);
// Then: the region is fed a phase-derived string, not the streaming text.
assert.match(pageSource, /const replyAnnouncement = chatReplyAnnouncement\(replyPhase, activeReplyOutcome\?\.replyOrdinal \?\? 0\)/);
assert.doesNotMatch(announcementSource, /activeStreamingText|streamingReply|answer\.delta/);
assert.doesNotMatch(pageSource, /aria-live=[^>]*\{activeStreamingText/);
assert.doesNotMatch(pageSource, /\{activeStreamingText\}<\/span>/);
// And: the old start-only region that lived inside the busy message list is gone.
assert.doesNotMatch(pageSource, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*\{chatMessageViews\(/);
});
test("the reply phase covers start, completion and every terminal state", () => {
const phaseDerivation = sourceBetween(pageSource, "const replyPhase: ChatReplyPhase = isLoading", "const accountId =");
assert.match(phaseDerivation, /consultationPhase === "recovering" \? "recovering" : "generating"/);
assert.match(phaseDerivation, /activeReplyOutcome\?\.phase \?\? "idle"/);
assert.match(pageSource, /const activeReplyOutcome = replyOutcome && replyOutcome\.sessionId === activeSession\?\.id \? replyOutcome : null/);
// Given: each terminal transition records its outcome from an event handler, not an effect.
assert.match(pageSource, /setReplyOutcome\(\{\n\s*sessionId,\n\s*phase: "completed",\n\s*replyOrdinal: completedSession\.messages\.filter\(\(message\) => message\.role === "assistant"\)\.length,\n\s*\}\)/);
assert.match(pageSource, /setReplyOutcome\(\{ sessionId, phase: "failed", replyOrdinal: 0 \}\)/);
assert.match(pageSource, /setReplyOutcome\(\{ sessionId: pending\.sessionId, phase: "stopped", replyOrdinal: 0 \}\)/);
// And: a new question clears the previous outcome so a stale reply is never re-announced.
assert.match(pageSource, /setRequestError\(null\);\n\s*setReplyOutcome\(null\);\n\s*setComposerNotice\(""\)/);
});
test("completion is announced with where to find the reply, and start says one is coming", () => {
assert.equal(chatReplyAnnouncement("generating", 0), "Jyotisha 正在回答,完成后会提示你阅读。");
assert.equal(
chatReplyAnnouncement("completed", 3),
"Jyotisha 已回答完毕,第 3 条回答已显示在对话区末尾,可以开始阅读。",
);
// Given: two consecutive replies would otherwise repeat identical text and stay unspoken.
assert.notEqual(chatReplyAnnouncement("completed", 3), chatReplyAnnouncement("completed", 4));
// And: every announcement is simplified Chinese in the existing product voice.
for (const phase of ["generating", "completed"] as const) {
const text = chatReplyAnnouncement(phase, 1);
assert.match(text, /^Jyotisha /);
assert.doesNotMatch(text.replace("Jyotisha", ""), /[A-Za-z]/);
}
});
test("toast-owned and alert-owned events are deliberately silent in the status region", () => {
// Given: sonner mounts its own polite live region for every chat notice.
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
// Then: stop, recovery and disconnect stay with the toast instead of double-announcing.
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
assert.equal(chatReplyAnnouncer("recovering"), "chat_notice_toast");
assert.equal(chatReplyAnnouncement("stopped", 0), "");
assert.equal(chatReplyAnnouncement("recovering", 0), "");
// And: a failed reply belongs to the assertive conversation alert, not the polite region.
assert.equal(chatReplyAnnouncer("failed"), "conversation_alert");
assert.equal(chatReplyAnnouncement("failed", 0), "");
assert.match(pageSource, /<p className="error-message" role="alert">\{activeError\}<\/p>/);
// And: only the two live-region phases ever produce spoken text.
const phases: readonly ChatReplyPhase[] = ["idle", "generating", "recovering", "completed", "stopped", "failed"];
const spoken = phases.filter((phase) => chatReplyAnnouncement(phase, 1) !== "");
assert.deepEqual(spoken, ["generating", "completed"]);
});
test("the recovered background reply tells the reader where it landed", () => {
assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2);
assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/);
});
test("assertive announcement is reserved for genuine errors", () => {
// Given: the bootstrap failure screen is the only assertive surface on the page.
assert.match(pageSource, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
assert.doesNotMatch(pageSource, /aria-live="assertive"/);
// And: the still-loading screen stays polite and marks itself busy.
assert.match(pageSource, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
});
test("the onboarding typewriter announces the finished line once instead of each character", () => {
const onboardingMessage = sourceBetween(pageSource, "function OnboardingChatMessage(", "function isProfileComplete(");
// Given: the visibly typed text is hidden from assistive technology.
assert.match(onboardingMessage, /className=\{`onboarding-stream[^`]*`\} aria-hidden="true"/);
// Then: only the completed line reaches the polite region, as one atomic unit.
assert.match(
onboardingMessage,
/<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{length >= text\.length \? text : ""\}<\/span>/,
);
assert.doesNotMatch(onboardingMessage, /aria-live="polite">\{visibleText\}|aria-live="polite">\{protectedVisibleText\}/);
assert.doesNotMatch(onboardingMessage, /aria-busy/);
});
test("aria-busy marks the updating message list without swallowing the status region", () => {
// Given: the activity status inside a streamed row is itself a role="status" region.
const activitySource = readFileSync(
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
"utf8",
);
assert.match(activitySource, /role="status"/);
// Then: aria-busy on the message list suppresses that per-phase chatter while streaming,
// and the reply status region sits outside it so completion is still spoken.
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>/);
const messageList = sourceBetween(pageSource, '<div className="message-list" aria-busy={isLoading}>', "</div>");
assert.doesNotMatch(messageList, /aria-live|role="status"/);
});
test("the jump-to-latest control stays keyboard reachable and labelled", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</div>\n )}");
const buttonTag = sourceBetween(jumpControl, "<button", ">");
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpControl, /<button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
assert.doesNotMatch(buttonTag, /disabled/);
// And: a visible label, a matching accessible name, a focus ring and a 44px target.
assert.match(jumpControl, /aria-label="跳到最新"/);
assert.match(jumpControl, /\n\s*跳到最新\n/);
assert.match(jumpControl, /focus-visible:ring-3/);
assert.match(jumpControl, /min-h-11/);
assert.match(jumpControl, /min-w-11/);
// And: the icon is decorative, and pointer-events never gate keyboard activation.
assert.match(jumpControl, /<ArrowDown aria-hidden="true"/);
assert.match(jumpControl, /pointer-events-auto/);
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
});
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.ts", import.meta.url), "utf8");
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
return source.slice(start, end);
}
test("keystrokes never re-render the chat page root", () => {
// Given: the composer text used to live in page-level React state.
assert.doesNotMatch(pageSource, /const \[draft, setDraft\] = useState/);
assert.doesNotMatch(pageSource, /const \[draftTheme, setDraftTheme\] = useState/);
assert.doesNotMatch(pageSource, /const \[draftEntrypoint, setDraftEntrypoint\] = useState/);
// When: the page keeps only the hidden routing values it reads at submit time.
assert.match(pageSource, /const draftTheme = useRef<Theme \| null>\(null\)/);
assert.match(pageSource, /const draftEntrypoint = useRef<ConsultationEntrypoint \| null>\(null\)/);
// Then: every writer the page still exposes updates a ref or the external store.
assert.match(pageSource, /function setDraft\(value: string\) \{\n setComposerDraft\(value\);\n \}/);
assert.match(pageSource, /function setDraftTheme\(theme: Theme \| null\) \{\n draftTheme\.current = theme;\n \}/);
assert.match(pageSource, /function setDraftEntrypoint\(entrypoint: ConsultationEntrypoint \| null\) \{\n draftEntrypoint\.current = entrypoint;\n \}/);
assert.match(pageSource, /import \{ composerDraftSnapshot, setComposerDraft \} from "@\/lib\/composer-draft"/);
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
});
test("the textarea lives in an isolated composer that owns the draft subscription", () => {
// Given: the page renders the composer without handing it the draft text.
assert.match(pageSource, /import \{ ChatComposer \} from "@\/components\/chat-composer"/);
const composerElement = sourceBetween(pageSource, "<ChatComposer", "/>");
assert.doesNotMatch(composerElement, /\bdraft=/);
assert.doesNotMatch(pageSource, /<Textarea/);
assert.doesNotMatch(pageSource, /value=\{draft\}/);
// When: the composer resolves the draft itself.
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
assert.match(composerSource, /const draft = useComposerDraft\(\)/);
// Then: only the composer subtree reads the value that changes on every keystroke.
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
});
test("the composer keeps its Chinese input, focus and accessibility contract", () => {
// Given: Enter submits only outside an IME composition.
const keyHandler = sourceBetween(pageSource, "function handleComposerKeyDown", "\n\n if (!hydrated");
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
assert.match(pageSource, /onKeyDown=\{handleComposerKeyDown\}/);
// When: the page still focuses the same textarea element through its own ref.
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
assert.match(pageSource, /inputRef=\{composerInput\}/);
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
assert.match(pageSource, /composerInput\.current\?\.focus\(\)/);
// Then: the form shell, labels, hit targets and the stop control are unchanged.
assert.match(composerSource, /<form className="composer" onSubmit=\{onSubmit\}>/);
assert.match(composerSource, /aria-label=\{inputLabel\}/);
assert.match(composerSource, /rows=\{1\}/);
assert.match(composerSource, /maxLength=\{maxLength\}/);
assert.match(composerSource, /className="composer-stop"/);
assert.match(composerSource, /aria-label=\{stopLabel\}/);
assert.match(composerSource, /title=\{stopTitle\}/);
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
assert.match(pageSource, /inputLabel=\{!profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
assert.match(pageSource, /submitLabel=\{!profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
});
test("every external draft writer keeps working through the page-owned setters", () => {
// Given: editing clears the hidden routing that a suggested question attached.
assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/);
// When: each existing write path is inspected.
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "function chooseConversationSuggestion");
const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession(");
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (process.env.NODE_ENV === \"development\" && uiPreview.current)");
const sendRestore = sourceBetween(pageSource, "if (activeSessionIdRef.current === sessionId) {", "setRequestError({");
// Then: suggestions fill, session switches clear, stop restores and send clears.
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
assert.match(startNewChat, /setDraft\(""\)/);
assert.match(selectSession, /setDraft\(""\)/);
assert.match(saveOnboardingName, /composerDraftSnapshot\(\)\.replace\(/);
assert.match(saveOnboardingName, /setDraft\(""\)/);
assert.match(stopRestore, /setDraft\(pending\.question\);\n\s*setDraftTheme\(pending\.theme\);\n\s*setDraftEntrypoint\(pending\.entrypoint\);/);
assert.match(sendClear, /setDraft\(""\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);/);
assert.match(sendRestore, /setDraft\(originalQuestion\);\n\s*setDraftTheme\(theme\);\n\s*setDraftEntrypoint\(entrypoint\);/);
assert.match(pageSource, /void send\(composerDraftSnapshot\(\), draftTheme\.current \?\? undefined, draftEntrypoint\.current\)/);
// And: opening a rectification case still empties the composer it replaces.
const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromHomepage");
assert.match(openRectification, /setDraft\(""\)/);
});
test("the draft survives a reload without resurrecting a sent question", () => {
// Given: the draft key follows the existing session-scoped storage convention.
assert.match(pageSource, /const pendingConsultationStorageKey = "jyotisha\.pending-consultation"/);
assert.match(draftStoreSource, /export const composerDraftStorageKey = "jyotisha\.composer-draft"/);
assert.match(draftStoreSource, /return window\.sessionStorage;/);
// When: the store restores exactly once and every write mirrors the new value.
assert.match(draftStoreSource, /function restoreComposerDraft\(\) \{\n if \(restored\) return;\n restored = true;\n draft = readStoredComposerDraft\(\);\n\}/);
assert.match(draftStoreSource, /export function composerDraftSnapshot\(\): string \{\n restoreComposerDraft\(\);\n return draft;\n\}/);
assert.match(draftStoreSource, /export function setComposerDraft\(value: string\) \{\n restoreComposerDraft\(\);/);
assert.match(draftStoreSource, /writeStoredComposerDraft\(value\);\n for \(const listener of \[\.\.\.listeners\]\) listener\(\);/);
// Then: clearing after a send removes the key instead of persisting an empty draft.
assert.match(draftStoreSource, /function writeStoredComposerDraft\(value: string\) \{\n if \(!value\) \{\n clearStoredDraft\(\);\n return;\n \}/);
// And: a corrupt, foreign, oversized or stale value is discarded, never surfaced.
const readStored = sourceBetween(draftStoreSource, "export function readStoredComposerDraft()", "function writeStoredComposerDraft");
assert.match(readStored, /typeof text !== "string"/);
assert.match(readStored, /typeof savedAt !== "number"/);
assert.match(readStored, /!Number\.isFinite\(savedAt\)/);
assert.match(readStored, /savedAt > Date\.now\(\)/);
assert.match(readStored, /Date\.now\(\) - savedAt > composerDraftMaxAgeMs/);
assert.match(readStored, /clearStoredDraft\(\);\n return "";\n \}/);
assert.match(readStored, /catch \{\n clearStoredDraft\(\);\n return "";\n \}/);
assert.match(readStored, /text\.slice\(0, composerDraftLimit\)/);
// And: an unavailable storage or a server render degrades to an empty composer.
assert.match(draftStoreSource, /if \(typeof window === "undefined"\) return null;/);
assert.match(draftStoreSource, /export function serverComposerDraftSnapshot\(\): string \{\n return "";\n\}/);
});
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { extname, join } from "node:path";
import test from "node:test";
const root = new URL("../", import.meta.url);
const css = readFileSync(new URL("src/app/globals.css", root), "utf8");
/**
* Tailwind v4 builds its utility namespace only from `@theme`. A `:root` custom
* property is invisible to the compiler, so `text-ink` compiles to nothing at
* all while still looking correct in the JSX. Text assertions on class names
* cannot catch that, so this file cross-references the two sides instead.
*/
function themeDeclarations() {
const block = css.match(/@theme[^{]*\{([\s\S]*?)\n\}/);
assert.ok(block, "globals.css must declare a @theme block");
return new Map(
[...block[1].matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map(([, name, value]) => [name, value.trim()]),
);
}
function rootDeclarations() {
const entries = new Map<string, string>();
for (const [, body] of css.matchAll(/^:root\s*\{([\s\S]*?)\n\}/gm)) {
for (const [, name, value] of body.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
if (!entries.has(name)) entries.set(name, value.trim());
}
}
return entries;
}
function sourceFiles(path: string): string[] {
const directory = new URL(path, root);
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const child = join(directory.pathname, entry.name);
if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`);
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
});
}
// Utility prefixes that resolve their argument through the `--color-*` namespace.
const colorPrefixes = [
"accent", "bg", "border", "border-b", "border-e", "border-l", "border-r", "border-s",
"border-t", "border-x", "border-y", "caret", "decoration", "divide", "fill", "from",
"inset-shadow", "outline", "placeholder", "ring", "ring-offset", "shadow", "stroke",
"text", "text-shadow", "to", "via",
];
/** Every `--color-*` token that `src/` uses as a Tailwind utility, with its call sites. */
function colorTokensUsedAsUtilities(palette: Map<string, string>) {
const used = new Map<string, Set<string>>();
for (const file of sourceFiles("src")) {
const label = file.slice(file.indexOf("/src/") + 1);
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
for (const [candidate] of line.matchAll(/[a-z][A-Za-z0-9:\/.-]*/g)) {
// Strip variants (`hover:`), the important marker and any opacity modifier.
const utility = candidate.split(":").pop()!.replace(/^!/, "").split("/")[0];
for (const prefix of colorPrefixes) {
if (!utility.startsWith(`${prefix}-`)) continue;
const token = `--color-${utility.slice(prefix.length + 1)}`;
if (!palette.has(token)) continue;
if (!used.has(token)) used.set(token, new Set());
used.get(token)!.add(`${label}:${index + 1} (${utility})`);
}
}
});
}
return used;
}
test("every color token used as a Tailwind utility is exposed through @theme", () => {
const theme = themeDeclarations();
const palette = rootDeclarations();
const dead: string[] = [];
for (const [token, callSites] of colorTokensUsedAsUtilities(palette)) {
if (theme.has(token)) continue;
dead.push(`${token} -> ${[...callSites].sort().join(", ")}`);
}
assert.deepEqual(
dead,
[],
`these utilities compile to no CSS because the token is missing from @theme:\n${dead.join("\n")}`,
);
});
test("the whole :root colour palette is reachable from the utility namespace", () => {
const theme = themeDeclarations();
const missing = [...rootDeclarations().keys()]
.filter((token) => token.startsWith("--color-") && !theme.has(token))
.sort();
assert.deepEqual(missing, [], `add these to @theme so utilities generate: ${missing.join(", ")}`);
});
test("@theme keeps the shadcn aliases pointing at the same palette tokens", () => {
const theme = themeDeclarations();
const aliases: Record<string, string> = {
"--color-background": "var(--color-canvas)",
"--color-foreground": "var(--color-ink)",
"--color-primary": "var(--color-action)",
"--color-primary-foreground": "var(--color-on-dark)",
"--color-secondary": "var(--color-canvas-muted)",
"--color-secondary-foreground": "var(--color-ink)",
"--color-muted": "var(--color-canvas-muted)",
"--color-muted-foreground": "var(--color-ink-secondary)",
"--color-destructive": "var(--color-danger)",
};
for (const [name, value] of Object.entries(aliases)) {
assert.equal(theme.get(name), value, `@theme must keep ${name} as ${value}`);
}
});
test("@theme resolves palette aliases inline so the :root values stay authoritative", () => {
// `inline` makes a utility emit the declared value rather than a reference to
// the theme variable, which is what lets `--color-background` forward to
// `--color-canvas` instead of shadowing it.
assert.match(css, /@theme inline \{/);
// Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat
// layered ones, so the literal values below must stay outside any layer.
assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette");
assert.match(css, /^:root \{\n color-scheme: light;\n --color-action: #85432f;/m);
});
test("@theme literals do not drift from the :root token they duplicate", () => {
const palette = rootDeclarations();
for (const [name, value] of themeDeclarations()) {
if (!name.startsWith("--color-") || value.startsWith("var(")) continue;
const token = palette.get(name);
if (token === undefined) continue;
assert.equal(value, token, `@theme ${name} must match the :root token ${token}`);
}
});
+2 -1
View File
@@ -192,7 +192,8 @@ test("global report copy uses the canonical profile, preserves accepted/confirme
test("entry is global in the sidebar and absent from the active session header", () => {
assert.match(sidebarSource, /我的报告/);
assert.match(sidebarSource, /onOpenReports/);
assert.match(pageSource, /onOpenReports=\{\(\) => window\.location\.assign\("\/reports"\)\}/);
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/);
assert.match(
globalStyles,