Skip to content

Settings graph: a clamped fit scale still pushes the left card out of reach on a narrow canvas #1769

Closed

Description

@HugoXK

本仓库回归,由 #1762(Settings 图形 fit,merged_at 2026-10-30T03:18:40Z,merge commit a6ba3280)引入:fitScale 给缩放加了 MIN_FIT = 0.5 下限,而画布是「中心线缩放 + overflow: auto」,于是当最小缩放仍让图形宽于画布时,溢出对称落在两侧——左溢出不可滚,左侧的卡和外框被裁掉且滚不回去。

What happens

core/src/client/workflows.js:

  • :129 const MIN_FIT = 0.5
  • :142-147 fitScale(nodes, width) = Math.min(1, Math.max(MIN_FIT, (width / 2) / reach)),reach = max(|node.x| + FRAME_SIDE)
  • :213-214 stage 用 transform: scale(...) 缩放,画布 overflow: auto
  • :139-141 的注释却声称「A canvas narrower than the graph shrinks the whole plane, so the frame, the cards and the wire into them stay inside it.」

当 reach * scale > width / 2 时,图形左右同时溢出;scrollLeft 最小为 0,所以右侧可以滚、左侧永远滚不到。设计面宽 960,编辑器允许把卡拖到中心线左侧(x = -400 合法),窄窗口下就必然触发。

Reproduction

探针(esbuild + vm 载入真实模块,非 jsdom),在 main 8252bce9 上:

fitScale([{ x: -400 }], 380) -> 0.500   # 被 MIN_FIT 钳住
卡片屏幕区间 [-52.0, 32.0]
外框屏幕区间 [-64.0, 44.0]
width=300 时更差

380px 正是 #1752 报告里自己写的画布宽度;MIN_FIT 之下的部分全部落在画布左侧之外且不可滚。

Expected

A(本单采用):给 stage 加一段左留白,宽度等于 (width / 2) - reach * scale(不小于 0),让所有溢出都落到右侧、始终可滚;缩放本身与 #1762 的 MIN_FIT 行为不变。

B(备选,不在本单):让下限只在「不裁切」时生效——会改变 #1762 已落地的缩放行为,需要单独决定。

Acceptance: a rendered-value assertion, not a scale( substring

现有 ok 13 - the Settings graph fits its plane into the canvas width 抓不住这个缺陷:把 fitScale 改成 return 1(等于停掉 #1762 的缩放)它依然绿——它只断言 transform 字符串里有 scale(,而 jsdom 里 canvasWidth = 0,fitScale 恒返回 1,于是 scale(1) 也满足。只有 helper 测试 14 会转红。

所以本单必须补一条对渲染值断言的用例:给定 width(如 380)与非零 node.x(如 -400),断言实际 scale 值与左留白/可滚边界(例如 offset ≥ 0,或断言 scale >= (2 * reach) / width 的取值),并在测试名里写明「a clamped scale still keeps the left card reachable」。改坏成 return 1 时这条必须转红。

Scope

  • core/src/client/workflows.js:左留白(修法 A)+ 注释与实现对齐
  • core/src/client/styles.js:仅当左留白需要一条样式
  • core/test/workflows-page.test.js:上面那条渲染值红先(另加一条 fitScale 边界用例,width=380、x=-400 → 断言留白能把卡推回可滚区)
  • 生成面:core/client.js 与 patches/ds-bot/0008-client-bundle.patch 由整链 generate 产出(0001/0014 若只有纯 hunk 头漂移,还原成 base 字节并在正文写明,应用字节不变)
  • 不改变 #1762 的 MIN_FIT 语义,不动 960 设计面与拖拽坐标

Risk surface (not verified)

overflow: auto 的滚动容器里加左留白后,overflowAnchor 的默认行为可能在窗口尺寸变化时改变 scrollLeft;本单不含该交互测试,若复现请在 PR 里补一条。

Duplicate check

在 live main 上按 settings graph clipped card、workflow graph keyboard、settings workflows accessibility 搜 issue,各 0 条;该面由 #1762 于 2026-10-30 引入,close 的 #1752 是它自己的来源单(其正文只报「卡片被压」、不含窄画布裁切)。

Where fix A applies (and where it must not)

Left padding is only for the case that actually overflows: reach * scale > width / 2. When the graph already fits, the stage must render exactly as #1762 does today — no padding, no coordinate shift. Otherwise every ordinary graph moves one slot and we would be changing shipped behaviour to fix an edge case.

Regression case that must exist alongside the fix: a normal width with node.x in the middle of the design plane must produce the same scale and the same offset as main 03c8c630 (the merge commit of #1745) — record the expected numbers from that tree in the test, so a future change to the padding cannot silently shift the normal case.

Acceptance, strengthened

The assertion must be able to catch the degradation someone actually made: turning fitScale into return 1. So it cannot look for the substring scale(; it must assert numbers and the scrollable boundary:

  • the concrete scale value for a given width and node.x (e.g. width=380, x=-400 → the value the fix produces, not just "> 0");
  • the left padding / offset floor: offset >= 0 in the overflowing case, and 0 in the normal case;
  • a scrollable boundary, e.g. scrollWidth - clientWidth (left overflow unreachable today means the leftmost card is at a negative screen x while scrollLeft cannot go below 0);
  • the width must be injected explicitly (a prop or a getBoundingClientRect stub). jsdom reports 0, so any assertion that relies on the measured width is vacuous — that is exactly how #1762's ok 13 passes with scale(1).

A test that still passes with fitScale = () => 1 does not count as coverage for this issue.

Attribution

该退化由今天合入的 #1762(fitScale + overflow:auto,merged_at 2026-10-30T03:18:40Z,merge commit a6ba3280)带来:其合并时新增的用例没有对渲染值断言(详情见 B-10)。我们自己发现、自己承接,不回头改 #1762 的评论或正文——向前修。

Side audit (bounded, not a survey)

既然已经确认「jsdom 里宽/高为 0 会让渲染断言空转」,同批排查 core/test/ 里依赖 clientWidth / canvasWidth / getBoundingClientRect 的用例:只要数量和不超过 10 条的清单(文件:行 + 一句“在零尺寸下它断言什么”),并入本单正文或 B-10,不逐条修。这条由 FlyBot Product 做,不改任何测试。

Side audit result (FlyBot Product, 2026-10-30)

Zero-size assertions in core/test/ — the count is 1, and it is the one this issue already owns:

  • core/test/workflows-page.test.js:378-383 (ok 13) — jsdom getBoundingClientRect().width is 0, so :164 measure() never sets a value, canvasWidth stays 0, workflows.js:144 returns 1 on !(width > 0), the rendered transform is always scale(1), and the assertion is only the substring /scale\(/. Deleting the whole measurement effect :161-172 still leaves it green.

Registered, different mechanism — do not fix here:

  • core/test/new-chat.test.js:90 — asserts the source string contains style: { left: paneLeft }; paneLeft comes from overlay-hooks.js:28 column.getBoundingClientRect().right, which is 0 under jsdom, so replacing the measurement with a constant 0 still passes.

Checked and explicitly not vacuous (do not list them as the same class): read-page.test.js:56-58 (stubs the prototype with width:100), bot-browsers.test.js:69,99 (BOX = {left:600,top:90,width:500,height:700}), exchange-read.test.js:110 (10×10 fake rect), thinking-row.test.js:182-186 (real JSDOM + factory CSS via getComputedStyle), workflows-page.test.js:385-399 (calls the pure fitScale with an explicit width=380 — this is the one leg that turns red under fitScale => 1), :356-376 (asserts authored coordinates, not measurements).

Side note worth keeping: nothing in core/test/ asserts clientWidth / clientHeight / offsetWidth / scrollWidth directly (the only hit is a comment at thinking-row.test.js:183), and the layout-heavy modules — browser-pane.js (11 reads), plugins/workflow/client.js (13), motion.js (5) — have no test under core/test/ that imports them. One vacuous assertion, but the layout surface is mostly unwatched; this issue does not widen to cover it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions