fix(studio): each pane's transcript has an id of its own

id="flowScroll" was written into Transcript, which renders once per pane, so
two open panes answered to one id and the rail's aria-controls pointed at
whichever came first — not necessarily the one it scrolls. The sweep only ever
reported it with two panes open, which is a state its 23-step walk does not
reach; it turned up on a session opened by hand.

useId per pane. The stylesheet and the perf scripts move to [data-pane="flow"],
an attribute the element already carried, so nothing depended on the id being
the same string twice. Specificity drops from (1,0,0) to (0,1,0) and the four
rules still land: .scroll is written earlier in the file, and the [hidden] rule
gains a second attribute.

#trajScroll in that same [hidden] rule has no element anywhere and is left
alone.
This commit is contained in:
esengine committed 2026-09-14 08:08:57 -07:00
1 parent e7bb095e67
commit 60b0288566
9 files changed
+23 -21

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ async function stream(page, k) {
long,
longMs,
nodes: document.querySelectorAll("*").length,
items: document.querySelectorAll("#flowScroll .call, #flowScroll .find").length,
items: document.querySelectorAll("[data-pane="flow"] .call, [data-pane="flow"] .find").length,
trajRows: document.querySelectorAll("table.traj tbody tr").length,
};
}, k);
+1 -1
View File
@@ -42,7 +42,7 @@ for (const { width, height } of [{ width: 1440, height: 900 }, { width: 640, hei
const layout = await page.evaluate(() => {
const box = (selector) => document.querySelector(selector)?.getBoundingClientRect();
const prose = document.querySelector('.call[data-k="say"] .out .txt');
const flow = document.querySelector("#flowScroll");
const flow = document.querySelector("[data-pane="flow"]");
const ask = document.querySelector(".ask");
const tabs = document.querySelector(".ask-tabs");
const foot = document.querySelector(".ask-foot");
+1 -1
View File
@@ -51,7 +51,7 @@ const clickIn = (group, label) =>
const readPx = () =>
page.evaluate(() => {
const el = document.querySelector("#flowScroll .out .txt");
const el = document.querySelector("[data-pane="flow"] .out .txt");
return el ? parseFloat(getComputedStyle(el).fontSize) : 0;
});
const rootVar = (name) => page.evaluate((n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(), name);
+1 -1
View File
@@ -107,7 +107,7 @@ const thumb = page.locator(".srail-view");
await thumb.focus();
await page.keyboard.press("End");
await page.waitForTimeout(80);
check("消息定位轨可用键盘滚动", await page.evaluate(() => document.querySelector("#flowScroll").scrollTop > 0));
check("消息定位轨可用键盘滚动", await page.evaluate(() => document.querySelector("[data-pane="flow"]").scrollTop > 0));
const reduced = await browser.newPage({
viewport: { width: 1440, height: 900 },
+1 -1
View File
@@ -60,7 +60,7 @@ for (const n of SCALES) {
const mem = await cdp.send("Runtime.evaluate", { expression: "performance.memory ? performance.memory.usedJSHeapSize : 0", returnByValue: true });
rows.push({
轮次: n,
卡片: await page.locator("#flowScroll .call").count(),
卡片: await page.locator("[data-pane="flow"] .call").count(),
DOM节点: Math.round(after.Nodes ?? 0),
"JS堆/MB": (mem.result.value / 1048576).toFixed(0),
"开到这么长/s": (grow / 1000).toFixed(1),
+5 -5
View File
@@ -43,12 +43,12 @@ await page.evaluate(async (n) => {
}, 60);
await page.waitForTimeout(500);
const flow = page.locator("#flowScroll").first();
const flow = page.locator("[data-pane="flow"]").first();
const geom = () => flow.evaluate((el) => ({ top: el.scrollTop, h: el.scrollHeight, c: el.clientHeight }));
// 1) 挂载的块真的画出来了,而不是留下一片占位空白。
const cards = await page.locator("#flowScroll .call").count();
const blocks = await page.locator("#flowScroll .chunk").count();
const cards = await page.locator("[data-pane="flow"] .call").count();
const blocks = await page.locator("[data-pane="flow"] .chunk").count();
check("底部卡片已挂载", cards > 0, `${cards} 张 / ${blocks} 块`);
check("远处的块已卸载", cards < blocks * 48, `挂载 ${cards},全挂会是 ${blocks * 48}`);
const visibleText = await flow.evaluate((el) => (el.innerText || "").trim().length);
@@ -94,7 +94,7 @@ const tallBefore = (await geom()).h;
await flow.evaluate((el) => el.scrollTo({ top: el.scrollHeight * 0.08 }));
await page.waitForTimeout(700);
const upTop = await page.evaluate(() => {
const el = document.querySelector("#flowScroll");
const el = document.querySelector("[data-pane="flow"]");
const cards = [...el.querySelectorAll(".call")];
const r = el.getBoundingClientRect();
return cards.filter((c) => { const b = c.getBoundingClientRect(); return b.bottom > r.top && b.top < r.bottom; }).length;
@@ -123,7 +123,7 @@ check("轨迹页有内容", trajRows > 50, `${trajRows} 行`);
await page.screenshot({ path: `${SHOTS}/3-轨迹页.png` });
await page.locator('[role="tab"]').nth(0).click();
await page.waitForTimeout(400);
const after = await page.locator("#flowScroll .call").count();
const after = await page.locator("[data-pane="flow"] .call").count();
check("切回后卡片仍在", after > 0, `${after} 张`);
await page.screenshot({ path: `${SHOTS}/4-切回活动页.png` });
+6 -6
View File
@@ -1067,10 +1067,10 @@
/* 空流时压到底部。用 margin-top:auto 而不是 justify-content:flex-end ——
后者在滚动容器上会把溢出的内容锁在视口外,滚不到。 */
#flowScroll { display: flex; flex-direction: column }
[data-pane="flow"] { display: flex; flex-direction: column }
/* 空态是邀请,不是一片留白 */
#flowScroll:has(.hero) { justify-content: center }
#flowScroll:has(.hero) .flow { margin-top: 0 }
[data-pane="flow"]:has(.hero) { justify-content: center }
[data-pane="flow"]:has(.hero) .flow { margin-top: 0 }
.hero { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 20px 24px 34px }
.hero .rmark { width: 78px; height: 78px; color: var(--faint); opacity: .92 }
.hero .rmark path { stroke-width: .42; animation: drawin 1.4s var(--ease-out) both }
@@ -1097,7 +1097,7 @@
}
.stay:hover { color: var(--muted) }
.pick:focus-visible, .stay:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
#flowScroll[hidden], #trajScroll[hidden] { display: none }
[data-pane="flow"][hidden], #trajScroll[hidden] { display: none }
/* Content is cut off under the tab strip with nothing saying "more above". The
fade rides a scroll timeline, not a scroll listener: it stays off the main
@@ -1133,8 +1133,8 @@
.railhost { position: sticky; top: 0; height: 0; z-index: 4; pointer-events: none }
/* 转录不画原生滚动条:视窗框和滚动条的滑块说的是同一件事(你在哪),
并排出现就是两条一模一样的东西。留下能被标记的那一条。 */
#flowScroll { scrollbar-width: none }
#flowScroll::-webkit-scrollbar { display: none }
[data-pane="flow"] { scrollbar-width: none }
[data-pane="flow"]::-webkit-scrollbar { display: none }
/* 宿主高度为零(它不能占布局),所以百分比高度在这里等于零 —— 轨道的高度
由测量时写进来,它本来就在读滚动容器的几何。 */
.srail {
+3 -2
View File
@@ -32,6 +32,7 @@ interface Props {
// Only the transcript on screen answers the keys; a pane on another tab keeps
// its rail but must not fight for them.
bound: boolean;
controls: string;
}
// The rail is a fisheye, not a map. Marks sit together in the middle at a fixed
@@ -65,7 +66,7 @@ function pull(i: number, focus: number): number {
return d > REACH ? 0 : (Math.cos((Math.PI * d) / REACH) + 1) / 2;
}
export function Rail({ marks, total, scroll, flow, onJump, onGrab, bound }: Props) {
export function Rail({ marks, total, scroll, flow, onJump, onGrab, bound, controls }: Props) {
const host = useRef<HTMLDivElement>(null);
const [tops, setTops] = useState<number[]>([]);
const [view, setView] = useState({ top: 0, height: 0 });
@@ -254,7 +255,7 @@ export function Rail({ marks, total, scroll, flow, onJump, onGrab, bound }: Prop
style={{ top: view.top, height: view.height }}
role="scrollbar"
aria-label={t("你说过的话")}
aria-controls="flowScroll"
aria-controls={controls}
aria-orientation="vertical"
aria-valuemin={0}
aria-valuemax={100}
+4 -3
View File
@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react";
import { decimals } from "../i18n/format";
import { t } from "../i18n";
import type { Item, Waiting } from "../state/session";
@@ -120,6 +120,7 @@ export function Transcript({ items, entering, onEntered, revision, waiting, scro
const [pinned, setPinned] = useState(true);
const end = useRef<HTMLDivElement>(null);
const flow = useRef<HTMLDivElement>(null);
const scrollId = useId();
const sizing = useRef({ px: SEED_PER_ENTRY, n: 0 });
// Read from observer callbacks that must not be torn down and rebuilt every
// time the reader crosses the bottom.
@@ -446,12 +447,12 @@ export function Transcript({ items, entering, onEntered, revision, waiting, scro
return (
<div
className="scroll"
id="flowScroll"
id={scrollId}
data-pane="flow"
ref={scroll}
hidden={hidden}
>
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} onGrab={release} bound={!hidden} />
<Rail marks={marks} total={items.length} scroll={scroll} flow={flow} onJump={jumpTo} onGrab={release} bound={!hidden} controls={scrollId} />
<div className="flow-edge" aria-hidden="true" />
{/* 空转录是「窗口的空白」,壁纸该在那儿;一有内容它就是内容了。 */}
<div className="flow" ref={flow} data-empty={items.length === 0 ? "" : undefined}>