Skip to content

[T-192] Surface 可結案 and add 強制結案 to the task card - #493

Merged
pkyosx merged 13 commits into
mainfrom
t-192/force-done-ui
Sep 13, 2026
Merged

[T-192] Surface 可結案 and add 強制結案 to the task card#493
pkyosx merged 13 commits into
mainfrom
t-192/force-done-ui

Conversation

@pkyosx

@pkyosx pkyosx commented Sep 13, 2026

Copy link
Copy Markdown
Owner

[T-192]

The cockpit gets one force-close button, and a task parked in ready_for_done now shows on the card that it is waiting. Force-done's reason is optional (owner ruling rc-a92a6252c3bd); the scope was narrowed from two buttons to one on rc-a074eba6a4f2, which withdrew the AC that read 「owner 可以直接按下結案」.

There is deliberately no ordinary-close button. An earlier cut put one in the banner wired to mark_task_done. It was out of scope and unpressable: callerMayMarkTaskDone is literally t.ExecutorID != "" && currentActor(r) == t.ExecutorID — no admin exemption, the owner included — and this cockpit authenticates as exactly one principal, the owner, who is never the executor. A test now pins that the cockpit never calls mark_task_done.

api.markTaskDone stays on the api port: the reverse guard above needs it there to spy on.


Round-3 changes (head 0767dc8d)

Three findings from the second independent review, each verified against the server before anything was touched.

N1 — the 可結案 banner named the one principal the route floor refuses

assigneeLabel and filterExecutorNoun are both 「負責人」, so in this UI that word is the executor, and the card prints 「負責人:某某」 two rows below the banner. The old line therefore read, to a zh reader: "waiting for this person; when this person is gone, this person forces it" — while HandleForceTaskDone's floor (Gated(principalAdminAgent, …)) 403s exactly that person. It also invented 「執行者」, a noun that occurred in no other user-visible zh string, and it described pressing a 結案 button this package removed.

The fix uses the card's own noun rather than minting a third one, states that the ordinary close is not a control on this screen, and addresses the way out in the second person.

zh (now):

所有步驟都已回報完成,這張票在等它的負責人自己呼叫結案——一般結案只有負責人本人做得到,這個畫面上沒有那顆按鈕。負責人已離場或卡住時,你可以用「強制結案」把它收掉。

en (now):

Every step is reported done — this task is waiting for its assignee to call the ordinary close itself. Only the assignee can make that call, and there is no button for it on this screen. If the assignee has left or is stuck, you can use Force close to end it here.

The second person is accurate because AuthGate and viewerMayForceTaskDone share the predicate USE_MOCK || hasToken() — anyone who can see this banner is inside the set the route floor admits.

assigneeLabel / filterExecutorNoun are not touched; that naming collision is out of this package's scope, so the banner conforms to it rather than changing it.

N2 — the confirm dialog hid two consequences, one of which bills

closeTask does two things the dialog never declared:

  • expireWaitingCardsForTask (api_tasks.go:684api_replycards.go:931) retires every reply card the task still has waiting. Questions this ticket raised vanish from the owner's 等我回覆 pane and can never be answered.
  • releaseDependentsOnClose (api_tasks.go:718api_tasks_handoff.go:363) releases the tasks this one was blocking and, for an outsource dependent with no executor, calls tickOutsource — whose own comment says that tick is what actually turns "design done" into "dev worker spawned".

⇒ The dialog announced that the press dismisses an outsource worker while saying nothing about it minting one. Opposite directions, and only the second one spends money. Both are now declared, in zh and en.

N3 — the refusal answered a question nobody asked

HandleForceTaskDone refuses in exactly three ways: 422 (decode / unknown key), 404, 409 (already terminal). 409's necessary-and-sufficient condition is "this task has already ended" — so the shared line

這張票沒有被結案。它現在的狀態是:已完成

contradicts itself end to end on the only refusal this screen can actually produce. Worse, the test guarding it seeded status: "waiting_owner" — a state force-done can never 409 on, so it pinned a sentence against a situation nobody can construct.

reportCloseRefused now branches on isHttpStatus:

code what it says
409 the task has already ended, and which terminal state (done / terminated / duplicated imply different next moves). Status still comes from the fresh re-read, not the card's stale copy
404 the task is gone — names no status, because the server just said there isn't one
422 the page and the wire disagree; explicitly does not invite a retry
other keeps the original status line — honest when the reason is unknown

The 409 fixture is now a terminal status, plus a second arm on terminated.


The three mutants the review left alive

Why it survived Guard added Kill message
M1 doForceDone() read-back deleted Every test in ④ expanded the card first, and expanding hydrates — the gesture was doing the fetch for it New ④ test drives a bare TaskCard with a fake onHydrate and never expands expected "spy" to be called with arguments: [ 'task-readback' ]
M2b viewerMayForceTaskDone() real arm Vitest runs in mock mode, so USE_MOCK || hasToken() short-circuits and the real arm has zero coverage New src/api/viewerMayForceTaskDone.test.ts reaches it with vi.stubEnv + resetModules + dynamic import, asserting USE_MOCK === false first so it cannot pass on the mock arm expected true to be false ×2
M4 banner && !expanded The banner was only ever asserted on a collapsed card New ① test asserts it after expanding Unable to find an element by: [data-testid="task-ready-done"]

Plus one freshly planted mutant per fix, each grep-verified as actually planted before running:

  • N1 restore the old zh banner → ① red: expected '…執行者按下結案…' to contain '你'
  • N2 delete both new clauses → ③b ×2 red: expected '確定要略過…' to contain '請示卡'
  • N3 remove the 409 branch → ⑤ red: expected '這張票沒有被結案。它現在的狀態是:已完成' to contain '這張票已經結束了('

No assertion weakened, nothing skipped, no expected value bent to fit.


Verification, and its limits

Ran: vitest 3343 tests, 3322 passed; frontend typecheck (all three tsconfigs) clean; ocserverd go vet clean and go test ./... ok (339s). The two generated message-key twins (messageKeys.generated.ts, message_keys_gen.go) were regenerated in the same commit.

The 21 reds are pre-existing load flakes, not regressions: the same three files failed with the same counts at the base commit 3ec9e19f, and rerunning those three files isolated gives 58/58 green on both sides.

Not verified — read this before treating a green PR as coverage:

  • 🔴 Nothing ran in a browser. All of the above is vitest/jsdom. No build → preview → Playwright, no test:ct, no test:paint. The banner copy got materially longer (zh ≈ 68 chars, en ≈ 250) and its behaviour at ≤400px was never measured. test:paint and test:ct are separate scripts that CI's vitest job does not run and that do not measure layout — so a green PR is not evidence in either direction on that cell. It needs an actual measurement on a narrow viewport, not a reading of the CSS.
  • No full bin/ci.sh — no conformance, no e2e, no lint targets, no CLI modules.
  • The 404 and 422 branches are unreachable from this screen in practice and were exercised only through injected rejections. Their copy is derived from the handler's guard order, not from an observed server response.
  • The en copy is asserted by nobody. Every test anchor reads zh. The en strings are type-checked for existence only; no test would notice if they drifted.
  • "The cockpit has exactly one principal" is the load-bearing claim under the second-person wording. AuthGate and viewerMayForceTaskDone were confirmed to share the predicate, and /api/login mints an owner token — but every route into TasksPage was not audited for a path that renders it outside AuthGate. If one exists, 「你可以」 is wrong there.

🤖 Generated with Claude Code

pkyosx and others added 7 commits September 13, 2026 11:15
…s reason optional

[why]
A task whose every step is reported done does NOT close itself — it settles in
`ready_for_done` and stays there until somebody acts, with no timer and nobody
chased. That produced two problems the cockpit could not answer:

  * a ticket parked waiting for a human read exactly like a ticket being worked
    on — the state was one more word in the status badge and nothing else;
  * a ticket whose executor had already left (an outsource worker released, a
    member gone) could not be closed from this screen at all. The server has had
    `POST /api/tasks/{id}/force-done` since T-182, but the frontend had ZERO
    product code for it — it appeared only in the generated types.

And the server demanded a `reason` on that force (422 on blank). owner ruled
otherwise in rc-a92a6252c3bd:「可以不給理由」.

[how]
Frontend (the bulk):
  * a 可結案 line under the title of every ready_for_done card — on the COLLAPSED
    card, because "which tickets are waiting for somebody" is a question about
    the LIST — carrying a 結案 button (`mark_task_done`).
  * 強制結案 appended to the 狀態 dropdown, with a second-step confirm whose
    reason textarea is optional. Appended LAST so both of the owner's existing
    rulings about that menu's order stay literally true.
  * the item is gated on `viewerMayForceTaskDone()` (api/index.ts) and HIDDEN,
    not greyed, for a principal the route floor would refuse. That gate defends
    nothing — `Gated(principalAdminAgent, …)` is the real door; it only stops the
    cockpit offering a button that could only ever 403.
  * a refused close NAMES the status the task is actually in, re-read from the
    server first, because the card's own copy is what the refusal proves wrong.
  * a forced close is read back and shown: who forced it, and the reason — or a
    visible "no reason given". `forced_done_by`/`forced_done_reason` are on
    `TaskDTO` and NOT on `TaskListItemDTO`, so the card learns them by hydrating
    the one task; the light list row is left honestly `undefined`.
  * NO generic set-status entry (ticket DoD) — both closes go through their own
    named action, and a test refuses any control that would write an arbitrary
    status. The executor's ordinary close is not widened and no member gains the
    force.

Backend (one place):
  * `reason` relaxed from required to optional: spec/openapi.json (route summary,
    the x-mcp description and its legacy descriptor, the DTO description and its
    `required` array), then `bin/gen-ocapi`, `bin/gen-mcp-catalog` and
    `npm run gen:api` re-run — no generated file hand-edited. The handler drops
    `decodeJSONBodyRequired(…, "reason")` and the blank 422; strict decoding
    (unknown keys → 422) is unchanged.
  * `forced_done_by` / `forced_done_reason` behave exactly as before: a reason
    that IS given is still trimmed, stored and served, and `forced_done_by` is
    stamped on every forced close — that stamp, never the reason, is what tells a
    forced close from a self-closed one.

Claude-Session: https://claude.ai/code/session_01GsKVVAVhQYmEenC1qyNAsY
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why]
兩個獨立的理由,任一個就足以拿掉它。

① 射程。票面逐字寫著「原本 AC 寫的『owner 可以直接按下結案』已作廢」,
   現行 AC 只列強制結案。這顆按鈕是在那條 AC 作廢之前做的。

② 它按下去必定失敗。`mark_task_done` 的閘門是「按的人必須是這張票的
   執行者」(spec/openapi.json 逐字:the task's OWN executor…the owner and
   the admin assistant do not share this door),而這個 SPA 只有一個
   principal —— `/api/login` 發的是 owner token,沒有成員登入、沒有
   impersonation。owner 永遠不是執行者,所以這顆按鈕在建構上就按不動。

「不端出一個按了只會 403 的按鈕」這條規則,同一包裡實作者已經套在強制
結案上了(`canForceDone` 是隱藏而不是灰掉),這顆只是漏掉。

[how]
拿掉按鈕、它的處理函式(`doMarkDone`/`markDoneOpen` 與那個確認框)、
`onMarkDone` prop、`useTasks.markDone`、TasksPage 的接線、只為它存在的
CSS(`.task-card__ready-done-btn`)與三個 locale key,並重跑
`npm run gen:msgkeys`(產生檔沒有手改)。

⚠️ 可結案橫幅本身留著 —— AC 要求「看得出來它在等誰做什麼」,而那是一句
關於這張票卡在哪裡的資訊,不因為這個畫面能不能動手而改變。橫幅的文字本身
另有問題,下一顆處理。

`api.markTaskDone`(adapter/http/mock)保留:它是這個前端對那條 route 長
什麼樣的唯一陳述,而不是那顆按鈕。它現在沒有產品呼叫者,後一顆 commit 會
把它跟 forceTaskDone 一起放進測試。

測試:① 從「有按鈕」翻面成「沒有按鈕,而且沒有任何呼叫得到
mark_task_done 的路」—— 重新加回那顆按鈕會弄紅這個檔。④「一般結案的票
沒有強制結案列」改成直接注入已結案的票(它的主詞本來就不是按鈕);⑤ 改走
強制結案觸發同一條 `reportCloseRefused`。

mutant:把按鈕原樣加回 TaskCard.tsx 的橫幅裡並接上 api.markTaskDone
⇒ TaskCard.force-done.test.tsx 14 支紅 2 支
(`expected <button …> to be null`、
 `expected "markTaskDone" to not be called at all, but actually been called 1 times`)。

Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why]
橫幅寫死「這張票正在等負責人按結案」。而能按一般結案的只有這張票的
執行者:`mark_task_done` 的閘門逐字是 the task's OWN executor…the owner
and the admin assistant do not share this door(spec/openapi.json),
owner 按下去是 403。

更糟的是它假的地方正好在這包的主要目標情境。強制結案存在的理由就是
「執行者已經不在了」(外包被釋出、成員離場),那時橫幅指著一個從頭到尾
按不到那顆鈕的人,等於給了一條沒有出口的死路,而且不承認它是死路。

[how]
zh 與 en 兩份都改成同時涵蓋兩種情況的真話:在等它的執行者按下結案;
執行者已離場或卡住時,由負責人用「強制結案」收掉。前半句對「執行者還在」
成立,後半句對「執行者已離場」成立,並且點名了出口與它在誰手上。

測試:這一格不能只斷言 `toContain(zh.tasks.readyForDoneHint)` —— 那是
跟被測物讀同一個常數,把常數換成相反的句子它一樣綠(這正是原本那句假話
活下來的原因)。改成額外斷言兩個字面字串:「執行者」與「強制結案」。

mutant:把 zh.readyForDoneHint 換回舊句
「所有步驟都已回報完成,這張票正在等負責人按結案。」
⇒ TaskCard.force-done.test.tsx 13 支紅 1 支
(`expected '所有步驟都已回報完成,這張票正在等負責人按結案。' to contain '執行者'`)。

Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why]
確認框原本只說「結案後無法恢復,是誰強制的會記在票上」。實際上按下去還會
發生兩件它沒講的事,而第一件是不可逆而且有人在付代價的:

① 遣散綁定的外包 worker。讀碼確認(不是照抄路由說明):
   `HandleForceTaskDoneApiTasksTaskIdForceDonePost` 呼叫 `closeTask`
   (server/ocserverd/api_tasks.go:663),`closeTask` 呼叫
   `dismissOutsourceWorkersForTask`(worker_spawn.go:2690),後者做兩件事
   —— `ReleaseWorkersForTask`(名冊列釋出)與 `reclaimWorkerSession`
   (工作階段回收)。四道結案門沒有任何一道豁免。

② 紀錄凍結。`TaskRecordFrozen`(domain.go:1548)含 done,所以交付物三個
   動詞(api_tasks.go:3226、3388、api_tasks_artifact_upload.go:77)與步驟
   備註(api_tasks_note.go:239)從此都回 409。

為什麼這很重要:按強制結案最典型的時機是「這張票看起來卡住了」,而
「worker 正在跑但還沒回報」跟「真的卡住」在駕駛艙畫面上分不出來。所以
一次看起來只是收拾死票的點擊,可能把一個還在做事的外包攔腰砍斷 —— 這個
代價必須出現在他按下去之前看得到的地方,而不是只寫在沒有人會打開的路由
說明裡。

[how]
zh 與 en 的 `forceDoneConfirmBody` 各補上這兩項,並在 locale 旁邊記下是
從哪幾個函式讀出來的,讓下一個人可以複驗而不是相信這段話。

測試:新增 ③b,用字面字串斷言確認框裡出現「外包/遣散/工作階段/凍結」,
並確認原本就有的「無法恢復」沒有被擠掉。刻意不用
`toContain(zh.tasks.forceDoneConfirmBody)` —— 那個形狀跟被測物讀同一個
常數,整句話被刪掉它也一樣綠。

mutant:把 zh.forceDoneConfirmBody 砍回舊句(刪掉新補的那一段)
⇒ TaskCard.force-done.test.tsx 14 支紅 1 支
(`expected '確定要略過「步驟尚未全部完成」這個前提,強制把這張票結案嗎?結案後無法恢復…' to contain '外包'`)。

Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why]
`httpApi.forceTaskDone` 在產品碼裡做了一個決定,還配了一整段註解替它辯護:

    const trimmed = reason.trim();
    body: trimmed === "" ? {} : { reason: trimmed }

而在這顆 commit 之前,全 repo 沒有任何測試碰過 `httpApi.forceTaskDone`
或 `httpApi.markTaskDone`。獨立審查把 body 改成恆為 `{}`(owner 打的理由
靜默丟掉、強制結案照樣成功、對話框照樣關閉),前端 13 支與 `src/api`
465 支全綠。一段解釋決定的註解不是那個決定的守衛。

[how]
照 `src/api/http.mutations.test.ts` 的形狀(openapi-fetch 會把真的
`Request` 打進 global fetch,所以 stub 回真的 `Response`,每次新的)寫一支
`http.task-close.test.ts`,斷言請求本身而不是結果:

  * 有理由 → body 帶 `reason`,而且已經 trim;
  * 空白 / 只有空白字元 → body 不含 `reason` 這個 key;
  * task id 落在 path 不在 body;
  * `markTaskDone` → POST 到自己的路徑、不送 body。

⚠️ 空白那一格斷言的是 KEY 不是 VALUE。`{reason: ""}` 與 `{}` 今天在伺服器
端都「結案且不留理由」,所以從結果看不出差別 —— 但它們在線上說的是兩件事
(「我的理由是空字串」vs「我沒有給理由」),而 owner 裁定 rc-a92a6252c3bd
要的是後者。所以用 `Object.keys(body)`;單用 `toEqual({})` 不夠,因為
`toEqual` 把明確的 `undefined` 當成不存在,而那正好是「省略寫壞了」會產生
的形狀。

`markTaskDone` 一併納入:駕駛艙已經不端一般結案的按鈕(上上上顆 commit),
但那個 port binding 仍是前端對那條 route 長什麼樣的唯一陳述,而且同樣沒有
守衛。

mutant:把 http.ts 的 body 改成審查者那顆恆為 `{}`
⇒ `src/api` 471 支紅 3 支
(`AssertionError: expected {} to deeply equal { reason: '負責人已離場,活早就交付了' }`)。

Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[why]
那一格原本是

    expect(reason.textContent).toBe(zh.tasks.forcedDoneNoReason);

斷言讀的常數,就是元件渲染的那個常數。把常數的語意反轉
(`forcedDoneNoReason: "未填理由"` → `"已填理由"`)—— 畫面上會對使用者說
「已填理由」然後接一片空白 —— 而這個斷言跟著常數一起移動,13 支全綠。
自我一致,而且對「這句話有沒有講對」零鑑別力。

[how]
在原本那一行旁邊多加一條字面字串斷言。常數那條保留 —— 它仍然釘住「元件
是從 locale 讀,不是寫死在 JSX 裡」,兩條問的是不同的問題;這是多一個錨點,
不是替換。

mutant:把 zh.tasks.forcedDoneNoReason 從「未填理由」反轉成「已填理由」
⇒ TaskCard.force-done.test.tsx 14 支紅 1 支,而且紅的正是新加的那一行
(test 檔 386 行,`AssertionError: expected '已填理由' to be '未填理由'`)
—— 上面那條同源斷言在同一次執行裡先通過了,這就是它的盲點。

Claude-Session: https://claude.ai/code/session_01Fq3L9pAd5dTicyqcy2UCPF
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… what actually happens

[why] Three findings from the independent review, each verified against the
server before touching anything:

N1 — the zh 可結案 banner handed Force close to the one principal the route
floor refuses. `assigneeLabel` / `filterExecutorNoun` are both 「負責人」, so in
this UI that word IS the executor and the card prints it two rows below; the
line therefore read "waiting for this person; when this person is gone, this
person forces it", while HandleForceTaskDone 403s exactly that person. It also
invented 「執行者」, which occurred in no other user-visible zh string, and it
described pressing a 結案 button this package removed — nothing in the frontend
product code calls api.markTaskDone any more.

N2 — closeTask does two things the confirm dialog never declared:
expireWaitingCardsForTask retires every reply card the task still has waiting
(the owner's own questions, unanswerable from then on), and
releaseDependentsOnClose ticks the outsource scheduler for an unassigned
dependent — "what actually turns design done into dev worker spawned". The
dialog announced that the press DISMISSES a worker while hiding that it can
MINT one, which is the opposite direction and the only consequence that bills.

N3 — force-done refuses in exactly three ways (422 decode, 404, 409 terminal),
and 409's necessary-and-sufficient condition is "already ended". The shared
line answered all of them with 「這張票沒有被結案。它現在的狀態是:已完成」,
self-contradictory on the only refusal this screen can actually produce. The
test guarding it seeded `waiting_owner`, a state force-done can never 409 on.

[how] Banner: reuse the card's own noun for the waited-for party, say the
ordinary close is not a control on this screen, and address the way out in the
second person (true because AuthGate and viewerMayForceTaskDone share the
predicate `USE_MOCK || hasToken()`). Dialog: both missing consequences in zh
and en, pinned by literals. Refusal: branch on isHttpStatus — 409 says the task
has already ended and which terminal state, 404 names no status at all, 422
does not invite a retry, everything else keeps the status line.

Also kills the three mutants the review left alive: the post-force-done
read-back is pinned on a card nothing expands (M1); viewerMayForceTaskDone's
real-mode arm gets its own file, reached with stubEnv + resetModules since
mock mode short-circuits it away (M2b); the banner is asserted on an EXPANDED
card too (M4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pkyosx pkyosx changed the title [T-192] Surface 可結案/結案/強制結案 on the task card [T-192] Surface 可結案 and add 強制結案 to the task card Sep 13, 2026
pkyosx and others added 6 commits September 13, 2026 19:36
[why] 一輪獨立的改壞實驗在這包上種了八顆,五顆存活 —— 也就是這五格
沒有任何測試守著。五顆都是同一種洞:既有斷言問的是「這個詞在不在」
或「這個 DOM 節點在不在」,而那兩種問法都看不見一句被講反的話、一個
讀錯來源的值,或一個被翻面的預設值。

  M8 readyForDoneHint 從「一般結案只有負責人本人做得到,這個畫面上
     沒有那顆按鈕」翻成「你自己也做得到…就有那顆按鈕」,全套仍全綠。
     ①只驗了負責人/你/強制結案三個詞在不在(翻面後三個都還在),
     以及 DOM 裡沒有 button(那是關於「畫面」,不是關於「這句話宣稱
     畫面上有什麼」)。這正是這包存在的理由 —— 前兩版草稿就是因為
     描述了不存在的操作被退回。
  M3 forceDoneConfirmBody 的「會起一位新的 worker,那會產生費用」翻成
     「不會…所以不會產生費用」,全套仍全綠。③b 驗的四個詞翻面後全在。
     詞在不在擋得住「被刪掉」,擋不住「被講反」;而一個主動保證不收費
     的對話框,比什麼都不講更糟。
  M2 409 分支的 `isHttpStatus(e, 409)` 換成 `TERMINAL.has(status)`。
     ⑤ 的每一支都讓「HTTP 狀態碼」與「票的終態性」同進同退,所以兩個
     述詞在每一格都給同一個答案。它們不是同一個述詞:重讀是第二次、
     更晚的請求,會失敗、會賽跑,而且報進來的錯不一定來自這個端點。
  M4 橫幅顯示條件從 `view.status` 改成 `task.status`。檔案註解宣稱讀
     view 是刻意的,但「展開後還在」那支的 hydrate 沒有把狀態搬走,
     兩個來源給同一個答案。
  M5 `canForceDone` 預設值從 false 翻成 true。②兩個臂都有,但兩臂都
     「有傳這個 prop」,所以預設值從來不是被測物。

[how] 只動測試,產品碼一行未改。

  ①b 橫幅語意極性(zh + en):把句子切成子句,取出談「按鈕」的那一句
     與談「一般結案」的那一句,斷言其極性 —— 一般結案專屬於負責人且
     讀者不在那個集合裡;提到按鈕的子句必須否定畫面上有按鈕。同一支
     測試裡再對 DOM 交叉核對,因為這包要防的缺陷正是「句子與畫面互相
     矛盾」。lookbehind 讓 /有…按鈕/ 不會被「沒有那顆按鈕」誤判。
  ①c 橫幅讀的是 hydrate 後的狀態:裸 TaskCard,列表列與 hydrate 給
     相反的狀態,兩個方向各一支(該出現要出現、該消失要消失)。
  ②b canForceDone 的預設形狀:渲染一張刻意不傳這個 prop 的卡,狀態選單
     不得出現強制結案(不是變灰,是不存在)。
  ③c 對話框後果的極性(zh + en):取出生 worker 的子句與計費的子句,
     兩句都必須是肯定式。`toContain("會產生費用")` 不夠 —— 「不會產生
     費用」把它整個包含在內。en 那條另補齊 zh 已釘住的五項後果。
  ⑤ 兩支新的臂把 HTTP 狀態碼與終態性拆開:500/斷線但票剛好讀成終態
     ⇒ 仍報泛用訊息;真 409 但重讀還沒追上 ⇒ 仍報已結束。

  🔴 中英兩條路都補。既有錨點幾乎全在中文上,en 的兩個字串在此之前
  沒有任何測試錨定 —— 英文讀者一樣會去找一顆不存在的按鈕。

[verify] 五顆 mutant 逐顆重種(cp 存檔 → 種 → 讀回確認 → 跑 → 還原),
七顆全紅(五顆原始 + 另外種的 M8/M3 英文孿生,用來證明新的 en 錨點不是
空轉);還原後對照組 rc=0。完整前端單元套件 353 檔 / 3352 測試全綠
(基準 353 / 3343,+9 正是本次新增的九支);tsc --noEmit rc=0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VarEHmhc3VvUMLGfkLRTea
…croll

[why] The shared ConfirmModal's box had no overflow anywhere — neither
.confirm-modal (position: fixed, align-items: center) nor
.confirm-modal__box declared overflow-y, and the box's height came only
from its content (强制結案 body: 499.2px en / 394.6px zh, independent of
viewport height). On a window shorter than that the box is centred
THROUGH the scrim, so the TOP of the consequence paragraph sits above
y=0 with `scrollHeight - clientHeight === 0` on both the box and the
page — nothing anywhere can bring it back. The buttons stay clickable
down to ~397px, so the dialog looks operable while the consequences it
names are off-screen. That paragraph is the entire reason this dialog
exists: it is what a person reads before an irreversible action that
expires every waiting reply card, dismisses the bound outsource worker,
freezes deliverables, and releases downstream tasks — one of which can
mint a NEW worker and spend money.

[how] .confirm-modal__box gets `max-height: 100%` + `max-height:
calc(100dvh - 40px)` + `overflow-y: auto` (+ overscroll-behavior:
contain). The cap is stated twice and the 40 (2 * 20px scrim padding) is
written out rather than var()'d, following the rule documented in the
header of md-preview.css: a declaration containing var() is not
syntax-checked at parse time, so on an engine without `dvh` the cap
would compute to `none` with the fallback already having lost the
cascade. Content that fits is untouched — the cap never binds and
`auto` paints no scrollbar, so the worst case for the other 14
render sites of this shared shell (13 files, 15 sites total,
including force-done) is "identical to today".

Adds frontend/visual-guards/confirm-modal-overflow.ct.spec.tsx (+ its
story), the first height guard this dialog has ever had — which is why
the defect survived. Two arms: the long body must be capped AND
scrollable at 900x420 with the paragraph starting on screen, and a
one-line confirm must have nothing to scroll, no scrollbar gutter, and
stay centred. jsdom cannot see any of this (no layout engine), which is
why it goes in the CT lane.

MUTANTS (each seeded, read back, RUN):
  A drop max-height x2 + overflow-y -> red: "the box must be the
    scrolling element"
  B drop overflow-y only            -> red: same assertion
  C drop max-height x2 only         -> red: "the box must have
    somewhere to scroll to ..." (0 > 0)
In all three the short-content arm stayed green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VarEHmhc3VvUMLGfkLRTea
@pkyosx
pkyosx merged commit c4e8c79 into main Sep 13, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant