fix(ui): 回答不再折叠,次级页侧栏改真链接并补回头像
真机走查第二批: 1. 普通对话的回答,首个 H2 之后的全部内容(带推理的那一半)被 <details class="answer-detail">「完整分析」默认收起——用户「输出根本 看不到」。折叠控件与组件文件一并删除,报告层直接渲染进正文。 技法审计表保留自己的折叠:那是给人核对的证据,不是回复正文。 product-voice.ts 同步改掉「UI 会折叠」那句,否则模型继续按折叠写。 2. 次级页侧栏点不了头像、头像样式与首页不一致。根因是 R4 的取舍: SecondaryShell 共用,但里面挂的是新写的 AppNavRail 而不是 AppSidebar。 现在 use-nav-rail 也取 avatar,渲染同一个 UserAvatar;页脚改成真链接, 指向 / —— 账户菜单连着设置弹窗栈,留在那儿,不在这里复制第二份。 3. 「新建对话」整页刷新。原来走 location.assign,现在是 next/link。 /login 保留硬跳转:它跨鉴权边界,要先存返回目标。 注意:第一版我改成了 useRouter(),它在 app-router 上下文外会抛 「invariant expected app router to be mounted」,打红 12 条渲染测试。 改用 <Link>,静态渲染也安全;折叠态 tooltip 换成原生 title。 测试 3369,fail 仍 31 且与基线逐条一致;四个路由标记不变; CSS gzip 40,002。 未修:截图里 `## 适合推进 / 需要避开` 被当字面量渲染,是模型输出里 那两个 H2 前面没有换行,属生成层,另记。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Opus 5
parent
49bc4a8ba1
commit
524015cfc1
@@ -363,6 +363,9 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-assistant .message-content { width: auto; max-width: none; flex: 1; }
|
||||
.message p { max-width: none; margin: 0; color: var(--color-ink); font-size: var(--type-body-md); line-height: 1.65; white-space: pre-wrap; }
|
||||
.message-markdown { color: var(--color-ink); font-size: var(--type-body-md); line-height: 1.65; }
|
||||
/* The report body, inline where the collapsed 完整分析 control used to be. */
|
||||
.answer-report-body { margin-top: var(--space-4); }
|
||||
.answer-report-body > .message-markdown > *:first-child { margin-top: 0; }
|
||||
.message-markdown > *:first-child { margin-top: 0; }
|
||||
.message-markdown > *:last-child { margin-bottom: 0; }
|
||||
.claim-boundary-badge { width: fit-content; margin: 0 0 var(--space-2); padding: var(--space-1) var(--space-2); border: 1px solid color-mix(in srgb, var(--color-action) 24%, transparent); border-radius: 999px; background: var(--color-action-soft); color: var(--color-action-hover); font-size: var(--type-overline); line-height: 1.45; }
|
||||
@@ -1445,50 +1448,6 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
font-size: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.answer-detail {
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
}
|
||||
.answer-detail > summary {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 32px;
|
||||
margin-inline: calc(var(--space-1) * -1);
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-ink-tertiary);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
.answer-detail > summary::-webkit-details-marker { display: none; }
|
||||
.answer-detail > summary::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-inline-end: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
content: "";
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 120ms ease-out;
|
||||
}
|
||||
.answer-detail[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); }
|
||||
.answer-detail > summary:hover { background: var(--color-canvas-muted); color: var(--color-ink-secondary); }
|
||||
.answer-detail > summary:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.answer-detail > summary small {
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.answer-detail-panel {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.technique-audit-panel {
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
@@ -4801,7 +4760,6 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
.technique-audit > summary::before { transition: none; }
|
||||
.answer-detail > summary::before { transition: none; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CalendarDays, FileText, Orbit, SquarePen, Star } from "lucide-react";
|
||||
import {
|
||||
@@ -10,13 +11,13 @@ import {
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { persistLoginSessionReturn, sessionHref } from "@/lib/chat-session-url";
|
||||
import { groupSessionsByRecency } from "@/lib/session-groups";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { useNavRail, type NavRailSession } from "@/hooks/use-nav-rail";
|
||||
|
||||
/**
|
||||
@@ -47,24 +48,26 @@ export function AppNavRail() {
|
||||
const history = sessions.filter((session) => !session.pinned);
|
||||
const groups = groupSessionsByRecency(history);
|
||||
|
||||
/* Same navigation the chat sidebar performs when it leaves for one of these
|
||||
pages: a real document load, with the return target stored first. */
|
||||
function go(href: string) {
|
||||
/* Real links, not buttons with a router call. `useRouter()` throws outside an
|
||||
app-router context (it took out twelve render tests), and an <a href> gives
|
||||
client-side navigation for free — clicking 新建对话 from a chart used to
|
||||
reload the whole document. `/login` keeps the hard exit: it crosses an auth
|
||||
boundary and wants the return target persisted first. */
|
||||
function leaveForLogin() {
|
||||
persistLoginSessionReturn();
|
||||
if (isMobile) setOpenMobile(false);
|
||||
window.location.assign(href);
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}
|
||||
|
||||
function renderRow(session: NavRailSession) {
|
||||
return (
|
||||
<SidebarMenuItem key={session.id}>
|
||||
<button
|
||||
className="session-row nav-rail-row"
|
||||
type="button"
|
||||
onClick={() => go(sessionHref("", session.id))}
|
||||
>
|
||||
<Link className="session-row nav-rail-row" href={sessionHref("", session.id)} onClick={closeDrawer}>
|
||||
<span className="session-title">{session.title}</span>
|
||||
</button>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -78,23 +81,24 @@ export function AppNavRail() {
|
||||
</div>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="new-chat" type="button" tooltip="新对话" onClick={() => go("/")}>
|
||||
<Link className="new-chat" data-sidebar="menu-button" href="/" title="新对话" onClick={closeDrawer}>
|
||||
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>新建对话</span> : null}
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
{NAV_PAGES.map(({ href, label, icon: Icon }) => (
|
||||
<SidebarMenuItem key={href}>
|
||||
<SidebarMenuButton
|
||||
<Link
|
||||
className="report-nav-button"
|
||||
type="button"
|
||||
tooltip={label}
|
||||
isActive={pathname === href || pathname.startsWith(`${href}/`)}
|
||||
onClick={() => go(href)}
|
||||
data-sidebar="menu-button"
|
||||
data-active={pathname === href || pathname.startsWith(`${href}/`)}
|
||||
href={href}
|
||||
title={label}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{label}</span> : null}
|
||||
</SidebarMenuButton>
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -136,22 +140,32 @@ export function AppNavRail() {
|
||||
|
||||
<SidebarFooter className="sidebar-footer">
|
||||
{signedOut ? (
|
||||
<button className="nav-rail-identity" type="button" onClick={() => go("/login")}>
|
||||
<a className="nav-rail-identity" href="/login" onClick={leaveForLogin}>
|
||||
<span className="profile-initial" aria-hidden="true">·</span>
|
||||
{showExpandedContent ? <span><b>去登录</b></span> : null}
|
||||
</button>
|
||||
</a>
|
||||
) : (
|
||||
/* Identity only. Account actions live in the chat page's own menu; a
|
||||
second copy here would need the settings dialog stack to come with it. */
|
||||
<div className="nav-rail-identity" aria-label={account ? `${account.name},余额 ${account.credits} 点` : undefined}>
|
||||
<span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>
|
||||
/* Draws the same beam avatar the chat sidebar does — the rail used to
|
||||
show only the initial, so the same account wore two different faces
|
||||
depending on which page you were on. It is a real control now: the
|
||||
account menu itself lives on `/` with the settings dialogs behind
|
||||
it, so this goes there rather than growing a second copy. */
|
||||
<Link
|
||||
className="nav-rail-identity"
|
||||
href="/"
|
||||
aria-label={account ? `${account.name},余额 ${account.credits} 点,打开账户` : "打开账户"}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
{account?.avatar
|
||||
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
|
||||
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
|
||||
{showExpandedContent && account ? (
|
||||
<>
|
||||
<span><b>{account.name}</b></span>
|
||||
<small>{account.credits} 点</small>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { answerDetailCaption } from "@/lib/chat-answer-split";
|
||||
|
||||
export function AnswerDetailDisclosure({
|
||||
headings,
|
||||
streaming = false,
|
||||
children,
|
||||
}: {
|
||||
readonly headings: readonly string[];
|
||||
readonly streaming?: boolean;
|
||||
readonly children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<details className="answer-detail">
|
||||
<summary>
|
||||
完整分析
|
||||
<small>{streaming ? "正在写…" : answerDetailCaption(headings)}</small>
|
||||
</summary>
|
||||
{streaming ? null : <div className="answer-detail-panel">{children}</div>}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { memo, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { AnswerDetailDisclosure } from "@/components/chat-answer-detail";
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { plainParagraphs } from "@/components/chat-message-paragraphs";
|
||||
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
|
||||
@@ -139,13 +138,12 @@ export function ChatMessageContent({
|
||||
: <StableMarkdownPrefix text={spoken} renderMarkdown={renderMarkdown} />}
|
||||
</div>
|
||||
) : null}
|
||||
{report
|
||||
? (
|
||||
<AnswerDetailDisclosure headings={layers.headings} streaming={streaming}>
|
||||
{reportBody}
|
||||
</AnswerDetailDisclosure>
|
||||
)
|
||||
: foldedAudit}
|
||||
{/* The body used to sit inside a collapsed 完整分析 control: everything
|
||||
from the first H2 down was one click away, so the part of the answer
|
||||
that carries the reasoning was invisible by default. The answer is the
|
||||
product — it renders inline. The technique audit keeps its own
|
||||
disclosure, because that one is evidence for checking, not the reply. */}
|
||||
{report ? <div className="answer-report-body">{reportBody}</div> : foldedAudit}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { BeamAvatar } from "@/lib/beam-avatar";
|
||||
|
||||
/**
|
||||
* Read-only nav data for the secondary pages (/chart, /ephemeris, /reports).
|
||||
*
|
||||
@@ -23,6 +25,8 @@ export type NavRailAccount = {
|
||||
readonly name: string;
|
||||
readonly initial: string;
|
||||
readonly credits: number;
|
||||
/** The same beam avatar the chat sidebar draws, so the two footers match. */
|
||||
readonly avatar: BeamAvatar | null;
|
||||
};
|
||||
|
||||
export type NavRailState = {
|
||||
@@ -86,6 +90,7 @@ export function useNavRail(): NavRailState {
|
||||
credits?: unknown;
|
||||
user?: { email?: unknown };
|
||||
profile?: { name?: unknown };
|
||||
avatar?: BeamAvatar | null;
|
||||
} | null;
|
||||
const name = typeof body?.profile?.name === "string" ? body.profile.name.trim() : "";
|
||||
const email = typeof body?.user?.email === "string" ? body.user.email : "";
|
||||
@@ -94,6 +99,7 @@ export function useNavRail(): NavRailState {
|
||||
name: name || email || "账户",
|
||||
initial: name.slice(0, 1) || email.slice(0, 1).toUpperCase() || "你",
|
||||
credits: typeof body?.credits === "number" ? body.credits : 0,
|
||||
avatar: body?.avatar ?? null,
|
||||
});
|
||||
}
|
||||
} else if (accountResult.status === "fulfilled" && accountResult.value.status === 401) {
|
||||
|
||||
@@ -13,7 +13,7 @@ Never open with 根据系统计算, 根据当前计算结果, 综合来看, 需
|
||||
|
||||
export const natalSpokenReportContract = `NATAL REPORT CONTRACT (spoken reply first, then skill Level 2 skeleton)
|
||||
The skill governs METHOD, TECHNIQUE INVOCATION, TRUTH BOUNDARIES, and the Level 2 skeleton — not the opening paragraphs.
|
||||
For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI folds everything from the first H2 into a collapsed 完整分析 control; you must still write the full skeleton in the body — the fold is visibility, not omission. Do not hide tables in a collapsed control as the only copy.
|
||||
For career / wealth / marriage / family (and any natal domain reading): open with 3–6 heading-free sentences that answer the user's actual question (directional reading + one clause of context), before the first H2. Shape: one conclusion sentence, then 2–3 short point-sentences (complete sentences, not noun-phrase lists; each ≤ 30 Chinese characters), then one next-step sentence. Spoken-layer total ≤ 400 Chinese characters. Then write the skill Level 2 template. 骨架不可省略,但必须以直接回应开场. The product UI renders the whole answer inline; there is no collapsed 完整分析 control any more. Write the full skeleton in the body and assume every line of it is read. Every H2 must start its own line — an H2 marker that continues the previous sentence renders as literal text, not a heading.
|
||||
|
||||
Required body order after that opener, using these exact H2 headings: ## 统一参数与原始结构, then ## {Chinese domain label} for each executed domain, then ## 技法审计表, then ## 现代生活. Do not write a thinking-process checklist; Activity is server-owned.
|
||||
1. Unified parameters and raw structure first (degrees, houses, vargas, Dasha boundaries, Shadbala/AV, functional benefic/malefic, Western layers the server delivered).
|
||||
|
||||
@@ -2,43 +2,51 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
/**
|
||||
* 原值:这份文件断言回答的第二层是一个默认收起的 `<details class="answer-detail">`
|
||||
* 「完整分析」控件(`chat-answer-detail.tsx`),并锁住它的 caption、流式期
|
||||
* 不渲染子节点、以及它与 technique-audit 是两套样式。
|
||||
* 新值:控件与组件文件一并删除,报告层直接渲染进正文 `.answer-report-body`。
|
||||
* 原因:产品在真机走查后拍板——首个 H2 之后的全部内容(也就是带推理的那一半)
|
||||
* 默认不可见,用户「输出根本看不到」。回答本身就是产品,不该藏在一次点击后面。
|
||||
* 技法审计表保留它自己的折叠:那是给人核对的证据,不是回复正文。
|
||||
*
|
||||
* 下面保留的断言是原文件里与折叠无关、仍然成立的那些(记忆化、复制取原文、
|
||||
* onboarding 走同一渲染链),并新增两条锁住「不得再把回答折起来」。
|
||||
*/
|
||||
|
||||
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
|
||||
const disclosureSource = readFileSync(new URL("../src/components/chat-answer-detail.tsx", import.meta.url), "utf8");
|
||||
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
|
||||
const onboardingSource = readFileSync(new URL("../src/components/onboarding-chat-message.tsx", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("the answer fold is a closed details element with a chapter caption", () => {
|
||||
assert.match(disclosureSource, /<details className="answer-detail">/);
|
||||
assert.doesNotMatch(disclosureSource, /<details[^>]*\sopen/);
|
||||
assert.match(disclosureSource, /完整分析/);
|
||||
assert.match(disclosureSource, /answerDetailCaption\(headings\)/);
|
||||
assert.doesNotMatch(disclosureSource, />展开</);
|
||||
test("the report layer renders inline; the answer is never folded away", () => {
|
||||
assert.match(contentSource, /<div className="answer-report-body">\{reportBody\}<\/div>/);
|
||||
assert.doesNotMatch(contentSource, /AnswerDetailDisclosure/);
|
||||
// 只查真正的折叠元素——解释这段历史的注释本身会提到「完整分析」四个字。
|
||||
assert.doesNotMatch(contentSource, /<details/);
|
||||
assert.match(globalStyles, /\.answer-report-body \{/);
|
||||
assert.doesNotMatch(globalStyles, /\.answer-detail\b/);
|
||||
});
|
||||
|
||||
test("streaming keeps the fold closed and does not render report children", () => {
|
||||
assert.match(disclosureSource, /streaming \? "正在写…" : answerDetailCaption\(headings\)/);
|
||||
assert.match(disclosureSource, /\{streaming \? null : <div className="answer-detail-panel">\{children\}<\/div>\}/);
|
||||
assert.doesNotMatch(disclosureSource, /spinner|骨架|正在加载|InlineSpinner|aria-busy/i);
|
||||
assert.match(contentSource, /reportBody = !streaming && report/);
|
||||
test("the model is told the answer renders inline, so it stops writing for a fold", () => {
|
||||
const voice = readFileSync(new URL("../src/mastra/product-voice.ts", import.meta.url), "utf8");
|
||||
assert.match(voice, /renders the whole answer inline/);
|
||||
assert.doesNotMatch(voice, /folds everything from the first H2/);
|
||||
// 截图里 `## 适合推进 / 需要避开` 接在上一句后面,被当成字面量渲染。
|
||||
assert.match(voice, /Every H2 must start its own line/);
|
||||
});
|
||||
|
||||
test("ChatMessageContent splits spoken from report and only folds when a report exists", () => {
|
||||
test("ChatMessageContent still splits spoken from report and keeps the audit fold", () => {
|
||||
assert.match(contentSource, /splitSpokenAndReport\(split\.spoken\)/);
|
||||
assert.match(contentSource, /report\s*\n\s*\? \(\s*\n\s*<AnswerDetailDisclosure/);
|
||||
assert.match(contentSource, /: foldedAudit/);
|
||||
assert.match(contentSource, /showFoldedAudit = rows\.length > 0 && split\.rows\.length === 0/);
|
||||
});
|
||||
|
||||
test("the folded technique audit moves inside the report fold when a report exists", () => {
|
||||
// 原值: `{renderProse(report, renderMarkdown)}` 每次重渲全量 parse。
|
||||
// 新值: 报告层走 `StableMarkdownPrefix`。
|
||||
// 原因: BUG-725 结算态 Markdown 按文本记忆化。
|
||||
test("the settled report layer stays memoised by text", () => {
|
||||
// 原值/新值不变,原因仍是 BUG-725:结算态 Markdown 按文本记忆化,不每次重 parse。
|
||||
assert.match(contentSource, /<div className="message-markdown">\s*<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
|
||||
assert.match(contentSource, /\{foldedAudit\}/);
|
||||
assert.match(contentSource, /const reportBody = !streaming && report/);
|
||||
assert.match(contentSource, /AnswerDetailDisclosure headings=\{layers\.headings\} streaming=\{streaming\}/);
|
||||
assert.match(contentSource, /\{reportBody\}/);
|
||||
});
|
||||
|
||||
test("spoken streaming still uses StreamingMarkdown so the memoised prefix stays intact", () => {
|
||||
@@ -52,22 +60,3 @@ test("copy still sends the original message text, not the spoken layer", () => {
|
||||
test("onboarding typewriter messages still go through ChatMessageContent", () => {
|
||||
assert.match(onboardingSource, /<ChatMessageContent text=\{protectedVisibleText\} \/>/);
|
||||
});
|
||||
|
||||
test("answer-detail styles are a sibling of technique-audit, not the same class", () => {
|
||||
assert.match(globalStyles, /\.answer-detail \{/);
|
||||
assert.match(globalStyles, /\.answer-detail > summary \{/);
|
||||
assert.match(globalStyles, /\.answer-detail-panel \{/);
|
||||
assert.match(disclosureSource, /className="answer-detail"/);
|
||||
assert.doesNotMatch(disclosureSource, /className="technique-audit"/);
|
||||
});
|
||||
|
||||
test("the fold does not introduce a spinner or skeleton class", () => {
|
||||
assert.doesNotMatch(disclosureSource, /spinner|skeleton|starter-loading|InlineSpinner/i);
|
||||
assert.doesNotMatch(contentSource, /正在加载/);
|
||||
assert.doesNotMatch(globalStyles, /\.answer-detail[^{]*spinner/i);
|
||||
});
|
||||
|
||||
test("streaming copy is a quiet line, not a loading phrase", () => {
|
||||
assert.match(disclosureSource, /正在写…/);
|
||||
assert.doesNotMatch(disclosureSource, /正在加载/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user