From 86100530e97ab429c377715d35cd762b86a6eeeb Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 16 Oct 2026 00:07:30 +1100 Subject: [PATCH 1/4] fix(staged): compact created/deleted badge and horizontal scroll in narrow diff view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created and deleted files rendered an empty 80px column holding only a vertically rotated "CREATED"/"DELETED" label for the full height of the file. On a phone-width viewport that ate a quarter of the width for no content. Drop the rotated label at every width and show the status as a compact badge in the pane header next to the path instead: a green "+" for created files and a red "−" for deleted ones, keeping the existing status colour semantics. The "Reference" label is unchanged. On narrow viewports long lines in created/deleted files could not be scrolled horizontally (or vertically) by touch. DiffModal's mobile gesture layer only recognised code inside `.after-pane`, but single-pane files render as `.single-pane-content`, so pointerdown bailed out before any gesture was tracked and the browser swallowed the pan. Recognise the single pane too, tag panes with `data-pane-side` so deleted files drive the before-side scroll controller, and give the single pane's code area `touch-action: none` on small viewports like the two-pane after pane already has. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../src/lib/features/diff/DiffModal.svelte | 30 +++++-- .../src/lib/components/DiffViewer.svelte | 81 ++++++++++--------- 2 files changed, 66 insertions(+), 45 deletions(-) diff --git a/apps/staged/src/lib/features/diff/DiffModal.svelte b/apps/staged/src/lib/features/diff/DiffModal.svelte index c9ec582b7..4f92303ae 100644 --- a/apps/staged/src/lib/features/diff/DiffModal.svelte +++ b/apps/staged/src/lib/features/diff/DiffModal.svelte @@ -329,6 +329,7 @@ let mobileDiffGestureMode: MobileDiffGestureMode = 'pending'; let mobileDiffCanDragPane = false; let mobileDiffCanScrollCode = false; + let mobileDiffScrollSide: 'before' | 'after' = 'after'; let mobileDiffMarkdownScrollEl: HTMLElement | null = null; let mobileDiffLastY = 0; let diffViewerScrollApi: DiffViewerScrollApi | null = $state(null); @@ -1288,6 +1289,7 @@ mobileDiffGestureMode = 'pending'; mobileDiffCanDragPane = false; mobileDiffCanScrollCode = false; + mobileDiffScrollSide = 'after'; mobileDiffMarkdownScrollEl = null; mobileDiffDragX = 0; } @@ -1298,10 +1300,14 @@ ); } - function isMobileDiffCodeTarget(target: HTMLElement): boolean { - return !!target.closest( - '.after-pane .code-container, .after-pane .lines-wrapper, .after-pane .line' - ); + // The visible code pane: the after pane in two-pane mode (the before pane sits + // behind it), or the only pane for created/deleted files. Returns which side + // the scroll controller should move, or null when the target isn't code. + function getMobileDiffCodeSide(target: HTMLElement): 'before' | 'after' | null { + if (!target.closest('.code-container, .lines-wrapper, .line')) return null; + const pane = target.closest('.after-pane, .single-pane-content') as HTMLElement | null; + if (!pane) return null; + return pane.dataset.paneSide === 'before' ? 'before' : 'after'; } function getMobileDiffMarkdownScrollTarget(target: HTMLElement): HTMLElement | null { @@ -1318,7 +1324,7 @@ return; } - diffViewerScrollApi?.scrollBy('after', deltaY); + diffViewerScrollApi?.scrollBy(mobileDiffScrollSide, deltaY); } function isMobileDiffRevealTarget( @@ -1341,8 +1347,9 @@ const target = event.target as HTMLElement; const canDragPane = isMobileDiffRevealTarget(target, event); const markdownScrollEl = !canDragPane ? getMobileDiffMarkdownScrollTarget(target) : null; - const canScrollCode = - !canDragPane && markdownScrollEl === null && isMobileDiffCodeTarget(target); + const codeSide = + !canDragPane && markdownScrollEl === null ? getMobileDiffCodeSide(target) : null; + const canScrollCode = codeSide !== null; if (!canDragPane && !canScrollCode && markdownScrollEl === null) return; const isMarkdownLink = markdownScrollEl !== null && target.closest('a') !== null; if (isMobileDiffInteractiveTarget(target) && !isMarkdownLink) return; @@ -1357,6 +1364,7 @@ mobileDiffGestureMode = 'pending'; mobileDiffCanDragPane = canDragPane; mobileDiffCanScrollCode = canScrollCode; + mobileDiffScrollSide = codeSide ?? 'after'; mobileDiffMarkdownScrollEl = markdownScrollEl; } @@ -1396,7 +1404,7 @@ mobileDiffGestureMode = 'pane-drag'; mobileDiffIsDragging = true; diffViewerContainerEl?.setPointerCapture(event.pointerId); - } else if (mobileDiffCanScrollCode && diffViewerScrollApi?.canScrollX('after')) { + } else if (mobileDiffCanScrollCode && diffViewerScrollApi?.canScrollX(mobileDiffScrollSide)) { mobileDiffGestureMode = 'horizontal-scroll'; mobileDiffLastX = event.clientX; diffViewerContainerEl?.setPointerCapture(event.pointerId); @@ -2067,6 +2075,12 @@ var(--shadow-elevated); } + /* Created/deleted files render one pane; its scrolling is driven by the + pointer handlers above, so stop the browser from claiming the pan. */ + :global(.diff-viewer-container .diff-content.single-pane .single-pane-content .code-area) { + touch-action: none; + } + :global(.diff-viewer-container .diff-content:not(.single-pane) .after-pane .pane-header) { padding-left: calc(var(--mobile-diff-spine-width) + 12px); } diff --git a/packages/diff-viewer/src/lib/components/DiffViewer.svelte b/packages/diff-viewer/src/lib/components/DiffViewer.svelte index 03b73e261..99fd2b925 100644 --- a/packages/diff-viewer/src/lib/components/DiffViewer.svelte +++ b/packages/diff-viewer/src/lib/components/DiffViewer.svelte @@ -3,8 +3,8 @@ Handles three display modes: 1. Two-pane diff: Side-by-side before/after with synchronized scrolling and spine connectors - 2. Created file: Status label + spine + single after pane (commentable) - 3. Deleted file: Single before pane + spine + status label + 2. Created file: Spine + single after pane (commentable), "+" badge in the pane header + 3. Deleted file: Spine + single before pane, "−" badge in the pane header The spine is always present - it shows bezier connectors for two-pane diffs, and comment highlights for all modes. @@ -2295,21 +2295,17 @@ {:else}
- + {#if isReferenceFile}
Reference
- {:else if isNewFile} -
- Created -
{/if} {#if isTwoPaneMode} -
+
{beforeLabel} {beforePath ?? 'No file'} @@ -2429,9 +2425,15 @@ {#if isDeletedFile} -
+
{beforeLabel} + − {beforePath ?? 'No file'}
@@ -2501,7 +2503,7 @@ {#if isTwoPaneMode} -
+
{afterLabel} {afterPath ?? 'No file'} @@ -2627,9 +2629,15 @@ {:else if isNewFile} -
+
{afterLabel} + + {afterPath ?? 'No file'}
@@ -2707,13 +2715,6 @@
{/if} - - - {#if isDeletedFile} -
- Deleted -
- {/if}
@@ -2865,22 +2866,37 @@ flex: 1; } - /* Status labels for created/deleted files */ - .status-label { - display: flex; + /* Compact created/deleted badge in the single pane header */ + .status-badge { + display: inline-flex; align-items: center; - width: 80px; + justify-content: center; + width: 16px; + height: 16px; flex-shrink: 0; + border-radius: 4px; + font-family: var(--font-mono, 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace); + font-size: var(--size-sm); + font-weight: 700; + line-height: 1; } - .status-label.created { - justify-content: flex-end; - padding-right: 12px; + .status-badge.created { + color: var(--status-added); + background-color: color-mix(in srgb, var(--status-added) 18%, transparent); + } + + .status-badge.deleted { + color: var(--status-deleted); + background-color: color-mix(in srgb, var(--status-deleted) 18%, transparent); } - .status-label.deleted { - justify-content: flex-start; - padding-left: 12px; + /* Status label for reference files */ + .status-label { + display: flex; + align-items: center; + width: 80px; + flex-shrink: 0; } .status-text { @@ -2893,15 +2909,6 @@ text-orientation: mixed; } - .status-label.created .status-text { - transform: rotate(180deg); - color: var(--status-added); - } - - .status-label.deleted .status-text { - color: var(--status-deleted); - } - .status-label.reference { justify-content: flex-end; padding-right: 12px; From 9d06f16fa64fa9feb268ac201edb86d5e615ab38 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 16 Oct 2026 10:01:30 +1100 Subject: [PATCH 2/4] fix(diff-viewer): hide the pane divider in single-pane mode Created and deleted files render a single pane, but DiffViewer still emitted the 16px `.spine` divider between the (absent) before pane and the after pane. With nothing to divide or connect it was an empty flex slot that landed on the left of a created file and the right of a deleted one. At desktop width that pushed the created pane 16px further in than the before pane sits in two-pane mode; at 390px it was a dead strip on a quarter-inch of a phone screen, and the deleted file's card stopped 16px short of the right edge. Only render the spine in two-pane mode. `connectorCanvas` becoming null was already guarded at every use, and the divider drag and double-click handlers only make sense with two panes. When the canvas unmounts, tear down the connector renderer too so scroll handlers stop painting into a detached canvas; the existing effect recreates it when a two-pane file mounts a fresh canvas. Verified in the DiffModal harness: `.single-pane-content` now spans 0..390 for both created and deleted files at 390x844 (previously 16..390 and 0..374), the two-pane rects at that width are unchanged, and at 1280x800 the connector canvas still paints, the divider drags and double-click resets. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../src/lib/components/DiffViewer.svelte | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/diff-viewer/src/lib/components/DiffViewer.svelte b/packages/diff-viewer/src/lib/components/DiffViewer.svelte index 99fd2b925..f01720d3e 100644 --- a/packages/diff-viewer/src/lib/components/DiffViewer.svelte +++ b/packages/diff-viewer/src/lib/components/DiffViewer.svelte @@ -766,6 +766,12 @@ }); previousCanvas = connectorCanvas; scheduleConnectorRedraw(); + } else if (!connectorCanvas && connectorRenderer) { + // The spine unmounts in single-pane mode; drop the renderer so scroll + // handlers stop painting into a detached canvas. + connectorRenderer.destroy(); + connectorRenderer = null; + previousCanvas = null; } }); @@ -2484,21 +2490,23 @@
{/if} - - -
-
- -
+ + {#if isTwoPaneMode} + +
+
+ +
+ {/if} {#if isTwoPaneMode} From 26d27e1530b0569af1b456a43777e13f6e8e3a04 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 16 Oct 2026 13:33:50 +1100 Subject: [PATCH 3/4] docs(diff-viewer): update DiffViewer header to match single-pane spine removal The component header still described created and deleted files as "Spine + single pane" and said the spine is always present with comment highlights for all modes. Since the spine is now only rendered in two-pane mode, reword the header so it states that the divider, connector canvas, and comment highlights exist only for two-pane diffs and that the connector effect tears down the renderer when the canvas unmounts. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../diff-viewer/src/lib/components/DiffViewer.svelte | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/diff-viewer/src/lib/components/DiffViewer.svelte b/packages/diff-viewer/src/lib/components/DiffViewer.svelte index f01720d3e..09430392c 100644 --- a/packages/diff-viewer/src/lib/components/DiffViewer.svelte +++ b/packages/diff-viewer/src/lib/components/DiffViewer.svelte @@ -3,11 +3,12 @@ Handles three display modes: 1. Two-pane diff: Side-by-side before/after with synchronized scrolling and spine connectors - 2. Created file: Spine + single after pane (commentable), "+" badge in the pane header - 3. Deleted file: Spine + single before pane, "−" badge in the pane header + 2. Created file: Single after pane (commentable), "+" badge in the pane header + 3. Deleted file: Single before pane, "−" badge in the pane header - The spine is always present - it shows bezier connectors for two-pane diffs, - and comment highlights for all modes. + The spine (divider, connector canvas, and comment highlights) only exists in + two-pane mode. Single-pane modes render no spine, and the connector effect + tears down the renderer when its canvas unmounts. Uses custom scroll implementation for frame-perfect sync between panes. From 709f9d9f3df83083d84c4af2778867b3c7f70c01 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 16 Oct 2026 13:37:36 +1100 Subject: [PATCH 4/4] refactor(diff-viewer): drop the unused reference-file status label With created and deleted files now flagged by a compact badge in the pane header, the 80px vertically rotated `.status-label` column only remained for `isReferenceFile`. Nothing in `apps/` or `packages/` ever passes that prop: Staged lists persisted reference files in the sidebar and can remove them, but has no UI that adds one, and selecting one just renders the path as an ordinary identical two-pane diff without setting the flag. The label was dead code, and had anything ever set it it would have reintroduced the full-height empty column on phone widths that the badge change removed. Remove the prop, the conditional markup, and the `.status-label` / `.status-text` CSS. If reference files get a real entry point later they should use the same header-badge treatment as created/deleted files rather than a side column. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../src/lib/components/DiffViewer.svelte | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/packages/diff-viewer/src/lib/components/DiffViewer.svelte b/packages/diff-viewer/src/lib/components/DiffViewer.svelte index 09430392c..b536a79a1 100644 --- a/packages/diff-viewer/src/lib/components/DiffViewer.svelte +++ b/packages/diff-viewer/src/lib/components/DiffViewer.svelte @@ -132,8 +132,6 @@ loading?: boolean; /** Message shown when no diff content is currently available. */ emptyMessage?: string; - /** Whether this is a reference file (not part of the diff). */ - isReferenceFile?: boolean; /** Label for the before pane header (e.g. base branch name). */ beforeLabel?: string; /** Label for the after pane header (e.g. head branch name). */ @@ -167,7 +165,6 @@ syntaxThemeVersion = 0, loading = false, emptyMessage = 'Select a file to view changes', - isReferenceFile = false, beforeLabel = 'before', afterLabel = 'after', annotations = [], @@ -2302,13 +2299,6 @@
{:else}
- - {#if isReferenceFile} -
- Reference -
- {/if} - {#if isTwoPaneMode} @@ -2900,34 +2890,6 @@ background-color: color-mix(in srgb, var(--status-deleted) 18%, transparent); } - /* Status label for reference files */ - .status-label { - display: flex; - align-items: center; - width: 80px; - flex-shrink: 0; - } - - .status-text { - font-family: var(--font-mono, 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace); - font-size: var(--size-lg); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.1em; - writing-mode: vertical-rl; - text-orientation: mixed; - } - - .status-label.reference { - justify-content: flex-end; - padding-right: 12px; - } - - .status-label.reference .status-text { - transform: rotate(180deg); - color: var(--text-muted); - } - /* Pane header */ .pane-header { display: flex;