From 308b607af383a140ccd0f2c63c86c5c0c867dca8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:36:52 +0000 Subject: [PATCH 1/9] feat: land leftover pair clicks on the named Post quality criterion (v2.12.8) Clicking a leftover closest or farthest pair now opens that post with the leftover criterion current in Post quality (IRT). Home-list and report-member opens do not carry leftover focus (ADR 0125). --- AGENTS.md | 5 +- ARCHITECTURE.md | 2 +- ...8-leftover-criterion-evaluation-landing.md | 9 +++ CHANGELOG.md | 11 +++ docs/adr/0049-leftover-pair-report-ui.md | 9 ++- ...5-leftover-criterion-evaluation-landing.md | 51 ++++++++++++ frontend/package.json | 2 +- frontend/src/App.css | 6 ++ frontend/src/App.test.tsx | 22 +++++- frontend/src/App.tsx | 78 +++++++++++++++++-- frontend/src/i18n.ts | 28 +++++++ pyproject.toml | 2 +- 12 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md create mode 100644 docs/adr/0125-leftover-criterion-evaluation-landing.md diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..cc946ed5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,12 +186,13 @@ in the same spirit) -- never against real data, per the hard rule above. against a live local stack (`make up`) and self-skip without one -- see [README.md](README.md#local-product-stack-docker-compose). -Period leftover pairs (ADR 0017 / 0018) are computed in +Period leftover pairs (ADR 0048 / 0049 / 0125) are computed in `lineageweave/leftover_pairs.py` from the residual after a real GRM/GPCM score, never invented. Missing cells stay out of the Gabriel factorization. Closest and farthest post–criterion pairs persist to `report_leftover_pair` and sit above the member list so -a click opens that post. +a click opens that post with the leftover criterion current in +Post quality. `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..a38200f9b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -588,7 +588,7 @@ on those same fixed parameters (Kim, 2006 FIPC). After scoring, `information_polytomous` ranks the shared-bank items by Fisher information at the group's mean θ (Lord, 1980 max-info CAT). Rankings persist to `report_item_information`. After those IRT main effects, -residual SVD leftover pairs (Jeon et al., 2021; ADR 0017) persist to +residual SVD leftover pairs (Jeon et al., 2021; ADR 0048 / 0049 / 0125) persist to `report_leftover_pair`. Results persist to `report_period_score` / `report_member_score`. `GET /api/reports/{grouping}` lists the trend; diff --git a/CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md b/CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md new file mode 100644 index 000000000..3e704c54f --- /dev/null +++ b/CHANGELOG.d/2.12.8-leftover-criterion-evaluation-landing.md @@ -0,0 +1,9 @@ +# 2.12.8 Leftover pair click lands Post quality on the named criterion + +## Changed + +- Clicking a leftover closest/farthest pair now opens that post with the + leftover criterion current in Post quality (IRT): the heading is + focused, the named criterion row is `aria-current`, and the next action + names the leftover score to read (ADR 0125). Home-list and report-member + opens do not carry leftover focus. diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ed1a099..9f96b4830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.12.8] - 2026-08-23 + +### Changed + +- Clicking a leftover closest or farthest pair now opens that post with + the leftover criterion current in Post quality (IRT). The Post quality + heading is focused, the named criterion row is `aria-current`, and the + next action names the leftover score to read (Jeon leftover map, + ADR 0125). Home-list and report-member opens do not carry leftover + focus. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/docs/adr/0049-leftover-pair-report-ui.md b/docs/adr/0049-leftover-pair-report-ui.md index a93985b40..a5831c2c9 100644 --- a/docs/adr/0049-leftover-pair-report-ui.md +++ b/docs/adr/0049-leftover-pair-report-ui.md @@ -18,12 +18,13 @@ second navigation surface. On each period-report group, render leftover pairs **above** the member list. Each pair is a button: closest or farthest label, post title, criterion short label, leftover-map distance, and the next -action (“Open this post to read the criterion it sat closest to / -farthest from after main effects.”). Clicking the button opens that -post with the same handler as a member row. +action (“Open this post so the leftover criterion is current in Post +quality.”). Clicking the button opens that post with leftover focus +so Post quality marks the named criterion current (ADR 0125). After `make seed`, closest and farthest leftover pairs sit above the -member list. Click a pair to open that post. +member list. Click a pair to open that post with the leftover +criterion current in Post quality. Missing leftover rows render nothing — never a placeholder pair. A hidden post never appears as a leftover pair. diff --git a/docs/adr/0125-leftover-criterion-evaluation-landing.md b/docs/adr/0125-leftover-criterion-evaluation-landing.md new file mode 100644 index 000000000..7d0a079cd --- /dev/null +++ b/docs/adr/0125-leftover-criterion-evaluation-landing.md @@ -0,0 +1,51 @@ +# ADR 0125 — Leftover pair click lands Post quality on the named criterion + +**Decision status:** Accepted +**Date:** 2026-08-23 + +## Context + +ADR 0049 puts closest and farthest leftover post–criterion pairs above +the period-report member list. Clicking a pair already opens that post. +The buyer still has to hunt through Post quality (IRT) for the named +criterion. The leftover residual (Jeon et al., 2021, eq. 3; Gabriel +1971 biplot) is a post–criterion fact, not a post-only fact. + +Event Lineage landing (ADR 0078) already shows the pattern: the click +must name the next action and mark the current node. Leftover pairs +must not reuse the Event Lineage `fromReportMember` path. That path +reorders Keyman and evaluation under Event Lineage. A leftover click +is about the IRT leftover criterion, not about reconstructing the DAG. + +## Decision + +Pass leftover focus with the leftover-pair click: `pair_kind` and +`criterion_code`. Opening the post: + +1. Focuses the **Post quality (IRT)** heading. +2. Marks the leftover criterion row `aria-current="true"`. +3. Shows a leftover-criterion next action under that heading: + “{criterion} is the leftover criterion this post sat closest to / + farthest from after main effects. Read that Post quality score next.” + +The leftover-pair button next action is “Open this post so the leftover +criterion is current in Post quality.” Home-list and report-member opens +do not carry leftover focus, so they do not show leftover copy or mark +a criterion current. + +Do not invent leftover scores. Do not persist a second theta. Do not mix +this landing into the leftover interaction-map stack (ADR 0121). + +## Consequences + +`SelectPostOptions.fromLeftoverPair` is the only leftover-focus carrier. +Evaluation rows already returned by `GET /api/posts/{id}/evaluation` +are sufficient; no new API. Missing leftover rows still render nothing +(ADR 0049). A hidden post still never appears as a leftover pair. + +## Related + +Depends on [ADR 0048](0048-persist-lsirm-leftover-pairs.md), +[ADR 0049](0049-leftover-pair-report-ui.md), and +[ADR 0003](0003-fast-mlsirm-report-integration.md). +Independent of leftover-map persistence on `feat/persist-lsirm-interaction-map-v2127`. diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..2e6f4ed8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.12.8", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index c72aab078..32b67c81b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -279,6 +279,12 @@ box-shadow: inset 0 0 0 1px var(--color-accent-info); } +.evaluation-criterion[aria-current="true"] { + outline: 2px solid var(--color-accent-info); + outline-offset: 2px; + border-radius: 4px; +} + .post-badge { font-size: 0.75rem; opacity: 0.7; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 7462abd2c..60e7732be 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3357,12 +3357,12 @@ describe("App, authenticated", () => { }); expect(closestPair).toHaveTextContent("Closest leftover: Public post · sales-lead"); expect(closestPair).toHaveTextContent( - "Open this post to read the criterion it sat closest to after main effects.", + "Open this post so the leftover criterion is current in Post quality.", ); expect(closestPair).toHaveTextContent("d 0.12"); expect(farthestPair).toHaveTextContent("Farthest leftover: Specification revision requested · negative"); expect(farthestPair).toHaveTextContent( - "Open this post to read the criterion it sat farthest from after main effects.", + "Open this post so the leftover criterion is current in Post quality.", ); expect(farthestPair).toHaveTextContent("d 1.84"); const memberButton = screen.getByRole("button", { name: /open report post: public post/i }); @@ -3423,6 +3423,23 @@ describe("App, authenticated", () => { await screen.findByRole("button", { name: /open leftover closest pair: public post/i }), ); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(await screen.findByRole("heading", { name: "Post quality (IRT)" })).toHaveFocus(); + expect(await screen.findByRole("status", { name: "Leftover criterion next action" })).toHaveTextContent( + "sales-lead is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.", + ); + expect((await screen.findByText("Sales-lead specificity: 3")).closest("li")).toHaveAttribute( + "aria-current", + "true", + ); + expect(screen.getByText("Constructive stance: 2").closest("li")).not.toHaveAttribute("aria-current"); + + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); + await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + expect(screen.queryByRole("status", { name: "Leftover criterion next action" })).not.toBeInTheDocument(); + expect((await screen.findByText("Sales-lead specificity: 3")).closest("li")).not.toHaveAttribute( + "aria-current", + ); }); it("opens Event Lineage, Keyman, and evaluation from a report member click", async () => { @@ -3432,6 +3449,7 @@ describe("App, authenticated", () => { await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); expect(screen.getByText("Constructive stance: 2")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Leftover criterion next action" })).not.toBeInTheDocument(); expect(screen.getAllByText(/Ada West/).length).toBeGreaterThan(0); expect(screen.getAllByLabelText("A-100 lineage")).toHaveLength(1); expect(screen.getByRole("status", { name: "Event Lineage next action" })).toHaveTextContent( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..c943ef656 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1288,18 +1288,36 @@ function KeymanPanel({ ); } +function leftoverCriterionNextAction( + criterion: string, + pairKind: "closest" | "farthest", +): string { + if (pairKind === "farthest") { + return tf( + "{criterion} is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.", + { criterion }, + ); + } + return tf( + "{criterion} is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.", + { criterion }, + ); +} + function EvaluationPanel({ postId, accessToken, responses, canExtract, onEvaluated, + leftoverFocus, }: { postId: string; accessToken: string; responses: EvaluationResponse[] | null; canExtract: boolean; onEvaluated: (rows: EvaluationResponse[]) => void; + leftoverFocus?: { pairKind: "closest" | "farthest"; criterionCode: string } | null; }) { const [evaluating, setEvaluating] = useState(false); const [error, setError] = useState(null); @@ -1329,7 +1347,9 @@ function EvaluationPanel({ return (
-

{t("Post quality (IRT)")}

+

+ {t("Post quality (IRT)")} +

{canExtract && !orchestratorOff && (
{t("Evidence operations")} @@ -1339,6 +1359,14 @@ function EvaluationPanel({
)}
+ {leftoverFocus ? ( +

+ {leftoverCriterionNextAction( + criterionShortLabel(leftoverFocus.criterionCode), + leftoverFocus.pairKind, + )} +

+ ) : null} {error &&

{error}

} {responses === null ? (

{t("Loading evaluation...")}

@@ -1347,7 +1375,13 @@ function EvaluationPanel({ ) : (
    {responses.map((row) => ( -
  • +
  • {row.criterion_label ?? row.criterion_code}: {row.response_category}
  • ))} @@ -1671,6 +1705,7 @@ function PostDetailPopup({ liveBodyWarning, knowledgeCutoff, focusEventLineage, + leftoverFocus, onClose, onSelectPost, onSearch, @@ -1682,6 +1717,7 @@ function PostDetailPopup({ liveBodyWarning?: string | null; knowledgeCutoff?: string | null; focusEventLineage?: boolean; + leftoverFocus?: { pairKind: "closest" | "farthest"; criterionCode: string } | null; onClose: () => void; onSelectPost?: (postId: string) => void; onSearch?: (query: string) => void; @@ -1881,6 +1917,15 @@ function PostDetailPopup({ heading?.scrollIntoView?.({ block: "nearest" }); }, [focusEventLineage, post]); + useEffect(() => { + if (!leftoverFocus || !post) { + return; + } + const heading = document.getElementById("post-quality"); + heading?.focus(); + heading?.scrollIntoView?.({ block: "nearest" }); + }, [leftoverFocus, post]); + return (
    event.stopPropagation()}> @@ -2276,6 +2321,7 @@ function PostDetailPopup({ responses={evaluation} canExtract={canExtract} onEvaluated={(rows) => setEvaluation(rows)} + leftoverFocus={leftoverFocus} /> )} @@ -2328,6 +2374,7 @@ function PostDetailPopup({ responses={evaluation} canExtract={canExtract} onEvaluated={(rows) => setEvaluation(rows)} + leftoverFocus={leftoverFocus} /> {keymen?.[0] ? (

    @@ -2534,10 +2581,16 @@ function analysisRunDigestPrefix(digest: string): string { return digest.slice(0, ANALYSIS_RUN_DIGEST_PREFIX_LENGTH); } +type LeftoverPairFocus = { + pairKind: "closest" | "farthest"; + criterionCode: string; +}; + type SelectPostOptions = { liveAfterCutoff?: boolean; knowledgeCutoff?: string; fromReportMember?: boolean; + fromLeftoverPair?: LeftoverPairFocus; }; /** @@ -3394,11 +3447,11 @@ function ReportsPanel({ {report.leftover_pairs.map((pair) => { const kindLabel = pair.pair_kind === "farthest" ? "Farthest leftover" : "Closest leftover"; - const nextAction = - pair.pair_kind === "farthest" - ? "Open this post to read the criterion it sat farthest from after main effects." - : "Open this post to read the criterion it sat closest to after main effects."; + const nextAction = t( + "Open this post so the leftover criterion is current in Post quality.", + ); const criterion = criterionShortLabel(pair.criterion_code); + const leftoverKind = pair.pair_kind === "farthest" ? "farthest" : "closest"; return (

  • onSelectPost(pair.post_id)} + onClick={() => + onSelectPost(pair.post_id, { + fromLeftoverPair: { + pairKind: leftoverKind, + criterionCode: pair.criterion_code, + }, + }) + } > {kindLabel}: {pair.post_title} · {criterion} @@ -3596,6 +3656,7 @@ function PostList({ const [openedGroupingLabel, setOpenedGroupingLabel] = useState(null); const [landOnComparison, setLandOnComparison] = useState(false); const [openedFromReportMember, setOpenedFromReportMember] = useState(false); + const [openedLeftoverPair, setOpenedLeftoverPair] = useState(null); const [corporateEntities, setCorporateEntities] = useState(null); const [entitiesLoadError, setEntitiesLoadError] = useState(null); const [totalPosts, setTotalPosts] = useState(0); @@ -3648,6 +3709,7 @@ function PostList({ setOpenedAfterCutoff(Boolean(options?.liveAfterCutoff)); setOpenedCutoffIso(options?.knowledgeCutoff ?? null); setOpenedFromReportMember(Boolean(options?.fromReportMember)); + setOpenedLeftoverPair(options?.fromLeftoverPair ?? null); } useEffect(() => { @@ -3661,6 +3723,7 @@ function PostList({ setOpenedAfterCutoff(false); setOpenedCutoffIso(null); setOpenedFromReportMember(false); + setOpenedLeftoverPair(null); const url = new URL(window.location.href); if (url.searchParams.has("post")) { url.searchParams.delete("post"); @@ -4065,6 +4128,7 @@ function PostList({ } knowledgeCutoff={openedAfterCutoff ? openedCutoffIso : null} focusEventLineage={openedFromReportMember} + leftoverFocus={openedLeftoverPair} onClose={closeSelectedPost} onSelectPost={selectPost} onSearch={searchBoard} diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 650acfca8..b0fe40d72 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -313,6 +313,13 @@ const TRANSLATIONS: Partial>> = { "Related next action": "관련 대상 다음 작업", "Ask next action": "Ask 다음 작업", "Keyman next action": "Keyman 다음 작업", + "Leftover criterion next action": "잔여 기준 다음 작업", + "Open this post so the leftover criterion is current in Post quality.": + "이 글을 열어 잔여 기준이 게시글 품질에서 현재 항목이 되게 하세요.", + "{criterion} is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.": + "{criterion}은(는) 주효과 이후 이 글이 가장 가까웠던 잔여 기준입니다. 다음으로 그 게시글 품질 점수를 읽으세요.", + "{criterion} is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.": + "{criterion}은(는) 주효과 이후 이 글이 가장 멀었던 잔여 기준입니다. 다음으로 그 게시글 품질 점수를 읽으세요.", "Ask seeded question: {question}": "시드 질문: {question}", "{post} is current in Event Lineage. Read Keyman and evaluation next.": "{post}은(는) 이벤트 계보의 현재 항목입니다. 다음으로 Keyman과 평가를 읽으세요.", @@ -651,6 +658,13 @@ const TRANSLATIONS: Partial>> = { "Related next action": "相关节点操作", "Ask next action": "Ask 操作", "Keyman next action": "关键联系人操作", + "Leftover criterion next action": "残差标准操作", + "Open this post so the leftover criterion is current in Post quality.": + "打开此文章,使残差标准成为文章质量中的当前项。", + "{criterion} is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.": + "{criterion} 是主效应后此文章最接近的残差标准。接下来查看该文章质量分数。", + "{criterion} is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.": + "{criterion} 是主效应后此文章最远离的残差标准。接下来查看该文章质量分数。", "Ask seeded question: {question}": "种子问题:{question}", "{post} is current in Event Lineage. Read Keyman and evaluation next.": "{post} 是事件谱系中的当前记录。接下来查看关键联系人和评估。", @@ -989,6 +1003,13 @@ const TRANSLATIONS: Partial>> = { "Related next action": "関連ノードの操作", "Ask next action": "Askの操作", "Keyman next action": "キーパーソンの操作", + "Leftover criterion next action": "残差基準の操作", + "Open this post so the leftover criterion is current in Post quality.": + "この投稿を開き、残差基準を投稿品質の現在の項目にしてください。", + "{criterion} is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.": + "{criterion}は主効果後にこの投稿が最も近かった残差基準です。次にその投稿品質スコアを確認してください。", + "{criterion} is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.": + "{criterion}は主効果後にこの投稿が最も遠かった残差基準です。次にその投稿品質スコアを確認してください。", "Ask seeded question: {question}": "シードされた質問: {question}", "{post} is current in Event Lineage. Read Keyman and evaluation next.": "{post}はイベント系譜の現在の記録です。次にキーパーソンと評価を確認してください。", @@ -1327,6 +1348,13 @@ const TRANSLATIONS: Partial>> = { "Related next action": "Thao tác nút liên quan", "Ask next action": "Thao tác Ask", "Keyman next action": "Thao tác người liên hệ chính", + "Leftover criterion next action": "Thao tác tiêu chí phần dư", + "Open this post so the leftover criterion is current in Post quality.": + "Mở bài viết này để tiêu chí phần dư trở thành mục hiện tại trong Chất lượng bài viết.", + "{criterion} is the leftover criterion this post sat closest to after main effects. Read that Post quality score next.": + "{criterion} là tiêu chí phần dư mà bài viết này nằm gần nhất sau hiệu ứng chính. Hãy xem điểm Chất lượng bài viết đó tiếp theo.", + "{criterion} is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.": + "{criterion} là tiêu chí phần dư mà bài viết này nằm xa nhất sau hiệu ứng chính. Hãy xem điểm Chất lượng bài viết đó tiếp theo.", "Ask seeded question: {question}": "Câu hỏi mồi: {question}", "{post} is current in Event Lineage. Read Keyman and evaluation next.": "{post} là bản ghi hiện tại trong Dòng sự kiện. Hãy xem người liên hệ chính và đánh giá tiếp theo.", diff --git a/pyproject.toml b/pyproject.toml index cb4be2916..e9d4b8f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.12.8" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } From 15f287bd5607e7d2507b72d2b53827dd30cd43cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:38:35 +0000 Subject: [PATCH 2/9] test: cover farthest leftover pair landing on Post quality The closest leftover path already marked sales-lead current. Also assert the farthest pair names the leftover criterion next action after open. --- frontend/src/App.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 60e7732be..f0e006fc9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -3433,6 +3433,20 @@ describe("App, authenticated", () => { ); expect(screen.getByText("Constructive stance: 2").closest("li")).not.toHaveAttribute("aria-current"); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + await userEvent.click( + await screen.findByRole("button", { + name: /open leftover farthest pair: specification revision requested/i, + }), + ); + await waitFor(() => + expect(screen.getByText("The evidence panel should show exactly this text.")).toBeInTheDocument(), + ); + expect(await screen.findByRole("heading", { name: "Post quality (IRT)" })).toHaveFocus(); + expect(await screen.findByRole("status", { name: "Leftover criterion next action" })).toHaveTextContent( + "negative is the leftover criterion this post sat farthest from after main effects. Read that Post quality score next.", + ); + await userEvent.click(screen.getByRole("button", { name: "Close" })); await userEvent.click(await screen.findByRole("button", { name: /open report post: public post/i })); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); From 95a33d5ecddf0e250b8c041a59e7ecc636d24563 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 20:47:11 +0900 Subject: [PATCH 3/9] fix: restore PR 485 frontend contracts --- frontend/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c943ef656..f93caec27 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4674,7 +4674,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
  • - {destination === "admin" ? : null}