Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/frontend-ui-audit-2026-08-05/DiffViewConsistency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Frontend UI Audit: Diff View Consistency

**Files:**

- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/components/SourceControlHeaderContent.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/index.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainPane.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/index.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/FocusView.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/SourceControlMainContent/AllChangesView.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/GitDiffContent/index.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/GitCommitDetailContent/index.tsx`
- `src/modules/WorkStation/CodeEditor/Panels/EditorPrimarySidebar/content/PullRequestContent/detail/PrChangesTab.tsx`
- `src/modules/WorkStation/Diff/SessionReplay/index.tsx`
- `src/modules/WorkStation/Diff/SessionReplay/diffSessionReplay.useDetailContent.tsx`
- `src/modules/WorkStation/shared/DiffFileSection/index.tsx`
- `src/modules/WorkStation/shared/DiffSectionList/index.tsx`
- `src/modules/shared/components/FileHeader/index.tsx`
- `src/features/CodeMirror/Diff/index.tsx`
- `src/features/CodeViewer/GitDiffViewer.tsx`

**Date:** 2026-08-05

**Auditor:** Codex

## D1 — Design-system component usage

| Line | Element | Verdict | Reason | Suggested change |
| -------------------------------------- | ------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `SourceControlHeaderContent.tsx:217` | Unified / split selector | keep with reason | Uses the existing `TabPill` design-system control, matching the file diff header interaction. | None. |
| `SessionReplay/index.tsx:269` | Unified / split selector | keep with reason | Uses the same `TabPill` control and shared preference as the other diff surfaces. | None. |
| `GitCommitDetailContent/index.tsx:377` | Collapsed file-list rail button | keep with reason | The control occupies a full-height 24 px rail; the current design-system `Button` variants do not model that geometry. | Keep the raw semantic button until the rail pattern is abstracted. |
| `PrChangesTab.tsx:214` | Collapsed file-list rail button | keep with reason | Same full-height rail geometry as commit details. | Keep the raw semantic button until the rail pattern is abstracted. |
| `DiffFileSection/index.tsx:418` | Sticky expandable file header | keep with reason | It is a full-width, multiline disclosure row rather than a standard action button. | Continue using a semantic button; consider a dedicated diff-section header only if the pattern gains more consumers. |
| `FocusView.tsx:51` | No-file Focus empty state | keep with reason | Reuses the workstation `NoTabsPlaceholder` with a focused instruction instead of assembling a bespoke empty state. | None. |

## D2 — Color and token usage

| Line | Element | Verdict | Reason | Suggested change |
| ---- | ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| — | Audited visible changes | keep with reason | New controls use existing semantic border, fill, and text tokens; no raw colors or duplicated token expressions were introduced. | None. |

## D3 — Spacing and typography

| Line | Element | Verdict | Reason | Suggested change |
| ---------------------------------------- | --------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `DiffSectionList/index.tsx:51` | Virtual-list footer buffer `h-[100px]` | keep with reason | This is an exact scroll affordance rather than visual component sizing, and the spacing scale has no exact 100 px token. | None. |
| `DiffFileSection/index.tsx:321` | Loading placeholder `h-[480px] min-h-[320px]` | fix | Both values have exact Tailwind scale equivalents and are unrelated to the current diff-mode behavior. | In a separate visual-cleanup sweep, use `h-120 min-h-80` and visually verify loading layout. |
| `DiffFileSection/index.tsx:424` | Chevron reservation `w-[14px]` | keep with reason | The width deliberately matches the 14 px icon and has no exact standard spacing class. | None. |
| `DiffFileSection/index.tsx:436-463` | Dense diff metadata typography | keep with reason | The 13 px filename and 11 px metadata match the established dense diff header hierarchy; the standard text scale has no exact equivalents. | Keep until diff typography is tokenized system-wide. |
| `SourceControlHeaderContent.tsx:119-123` | Dense source-control header typography | keep with reason | The 11 px prefix and 13 px title preserve the existing editor-header hierarchy and were not introduced by this change. | Keep until editor header typography is tokenized system-wide. |

## D4 — Accessibility basics

| Line | Element | Verdict | Reason | Suggested change |
| -------------------------------------- | ---------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `SourceControlHeaderContent.tsx:217` | Unified / split selector | keep with reason | Both options have visible translated labels and expose button semantics through `TabPill`. | None. |
| `SessionReplay/index.tsx:269` | Unified / split selector | keep with reason | Both options have visible translated labels and expose button semantics through `TabPill`. | None. |
| `GitCommitDetailContent/index.tsx:377` | Icon-only collapsed file-list rail | fix | It has a tooltip title but no explicit accessible label. | Add the translated “show file list” string as `aria-label` in a coordinated rail-control sweep. |
| `PrChangesTab.tsx:214` | Icon-only collapsed file-list rail | fix | It has a tooltip title but no explicit accessible label. | Add the translated “show file list” string as `aria-label` in the same sweep. |
| `DiffFileSection/index.tsx:418` | Expandable file header | keep with reason | The visible filename supplies an accessible name and `aria-expanded` communicates disclosure state. | None. |
| `FocusView.tsx:51` | No-file Focus empty state | keep with reason | The translated caption explicitly tells the user to select a changed file and exposes no unrelated actions. | None. |

## D5 — Duplication and abstraction candidates

- **Abstract candidate:** the collapsed 24 px file-list rail appears in commit details, pull-request changes, and `GitFileDiffSplit`. A small shared rail-control component could centralize geometry, tooltip, and accessible naming. This is a multi-file sweep candidate and is intentionally not mixed into the diff-view consistency fix.
- The new unified / split selectors intentionally reuse `TabPill` and one shared persisted atom; no new visual-pattern duplication was introduced.

## Summary

- **Fix candidates:** 3 (two rail-control accessible labels; one standardizable loading height)
- **Keep with reason:** 12
- **Abstract candidates:** 1
- **Current change verdict:** the new controls follow the design system and introduce no new arbitrary colors, spacing, or inaccessible icon-only actions.
3 changes: 2 additions & 1 deletion src/engines/GitWorkflow/GitHubDiff/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* Type definitions for the diff viewer architecture.
*/
import type { DiffViewMode } from "@src/types/git/types";

// ============================================
// Diff Line Types
Expand Down Expand Up @@ -98,7 +99,7 @@ export interface FileDiff {
// ============================================

/** The view mode for the diff */
export type DiffViewMode = "unified" | "split";
export type { DiffViewMode } from "@src/types/git/types";

/** Expansion type for hunks */
export type DiffHunkExpansionType = "up" | "down" | "short" | "full";
Expand Down
3 changes: 2 additions & 1 deletion src/features/CodeMirror/Diff/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { GitFileStatus } from "@src/config/gitStatus";
import { createLogger } from "@src/hooks/logger";
import { useEditorAppearanceSettings } from "@src/hooks/settings";
import { EditorService } from "@src/services/workStation/EditorService";
import type { DiffViewMode } from "@src/types/git/types";

import { useSelectionExtension } from "../Editor/hooks/useSelectionExtension";
import type { TextSelectionInfo } from "../Editor/types";
Expand Down Expand Up @@ -67,7 +68,7 @@ export interface CodeMirrorDiffProps {
/** Container height */
height?: string;
/** Diff view mode: unified (inline) or split (side-by-side) */
viewMode?: "unified" | "split";
viewMode?: DiffViewMode;
/** Read-only mode */
readOnly?: boolean;
/** Show merge controls (accept/reject buttons) */
Expand Down
3 changes: 2 additions & 1 deletion src/features/CodeViewer/GitDiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import React, { useState } from "react";

import { Placeholder } from "@src/modules/shared/layouts/blocks";
import type { DiffViewMode } from "@src/types/git/types";

import { ModernSplitDiff } from "./ModernSplitDiff";
import { VirtualizedModernDiff } from "./VirtualizedModernDiff";
Expand All @@ -15,7 +16,7 @@ import { VirtualizedModernDiff } from "./VirtualizedModernDiff";
// Types
// ============================================

export type DiffViewMode = "unified" | "split";
export type { DiffViewMode } from "@src/types/git/types";

export interface GitDiffViewerProps {
/** Original/old content */
Expand Down
103 changes: 103 additions & 0 deletions src/hooks/ui/layout/useElementDimensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
import React, { act, createElement, useRef } from "react";
import { type Root, createRoot } from "react-dom/client";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";

import { useElementDimensions } from "./useElementDimensions";

function DimensionProbe(): React.ReactNode {
const elementRef = useRef<HTMLDivElement>(null);
const dimensions = useElementDimensions(elementRef);

// eslint-disable-next-line react-hooks/refs -- createElement is required because Vitest only includes `.test.ts`; this is a normal React ref prop.
return createElement("div", {
ref: elementRef,
"data-testid": "dimension-probe",
"data-dimensions": `${dimensions.width}x${dimensions.height}`,
});
}

describe("useElementDimensions", () => {
let container: HTMLDivElement;
let root: Root;
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};

beforeAll(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
});

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

afterAll(() => {
Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT");
});

it("keeps the window resize fallback when ResizeObserver is unavailable", () => {
vi.stubGlobal("ResizeObserver", undefined);
const addWindowListener = vi.spyOn(window, "addEventListener");
const removeWindowListener = vi.spyOn(window, "removeEventListener");

expect(() => {
act(() => root.render(createElement(DimensionProbe)));
}).not.toThrow();

expect(addWindowListener).toHaveBeenCalledWith(
"resize",
expect.any(Function)
);

act(() => root.unmount());
root = createRoot(container);

expect(removeWindowListener).toHaveBeenCalledWith(
"resize",
expect.any(Function)
);
});

it("disconnects the observer when the measured element unmounts", () => {
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal(
"ResizeObserver",
class ResizeObserverMock {
observe = observe;
unobserve = vi.fn();
disconnect = disconnect;
}
);

act(() => root.render(createElement(DimensionProbe)));

expect(observe).toHaveBeenCalledWith(
container.querySelector('[data-testid="dimension-probe"]')
);

act(() => root.unmount());
root = createRoot(container);

expect(disconnect).toHaveBeenCalledOnce();
});
});
2 changes: 1 addition & 1 deletion src/hooks/ui/layout/useElementDimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export function useElementDimensions(

// Set up ResizeObserver for accurate tracking
let resizeObserver: ResizeObserver | null = null;
if (element) {
if (element && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(measureDimensions);
resizeObserver.observe(element);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { TFunction } from "i18next";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";

import type { WorkStationTab } from "@src/store/workstation/tabs";

import { SourceControlHeaderContent } from "./SourceControlHeaderContent";

vi.mock("@src/components/Button", () => ({
default: ({ title }: { title?: string }) =>
createElement("button", { "data-title": title }),
}));

vi.mock("@src/components/TabPill", () => ({
default: ({
activeTab,
tabs,
}: {
activeTab: string;
tabs: Array<{ key: string }>;
}) =>
createElement("div", {
"data-active-tab": activeTab,
"data-tabs": tabs.map((tab) => tab.key).join(","),
}),
}));

const t = ((key: string) => key) as TFunction;

function sourceControlTab(mode: "focus" | "all-changes"): WorkStationTab {
return {
id: "source-control:changes",
type: "source-control",
title: "Review",
data: {
mode,
staged: false,
fileCount: 1,
focusPath: null,
historySelection: null,
},
} as WorkStationTab;
}

function renderHeader(mode: "focus" | "all-changes"): string {
return renderToStaticMarkup(
createElement(SourceControlHeaderContent, {
activeTab: sourceControlTab(mode),
sourceControlFilterMode: "uncommitted",
showSourceControlModePill: true,
gitReviewNavigationTotal: 0,
selectedIssue: null,
sourceControlRefreshSpinClass: undefined,
diffViewMode: "split",
t,
onDiffViewModeChange: vi.fn(),
onModeChange: vi.fn(),
onOpenHistoryInNewTab: vi.fn(),
onReviewPrevFile: vi.fn(),
onReviewNextFile: vi.fn(),
onCollapseAll: vi.fn(),
onRefresh: vi.fn(),
})
);
}

describe("SourceControlHeaderContent diff view controls", () => {
it("shows the shared unified/split control in All Changes", () => {
const markup = renderHeader("all-changes");

expect(markup).toContain('data-active-tab="split"');
expect(markup).toContain('data-tabs="unified,split"');
});

it("keeps the aggregate diff control out of Focus mode", () => {
const markup = renderHeader("focus");

expect(markup).not.toContain('data-tabs="unified,split"');
expect(markup).toContain('data-tabs="focus,all-changes"');
});
});
Loading
Loading