Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
11837db
feat: redesign branch divergence as a quiet branch-control signal
claude May 29, 2026
2df18f6
refactor: enforce the gh-* palette; remove stray stock colors
claude May 29, 2026
d8b6776
feat: unify empty/loading states behind x-empty-state + diff skeleton
claude May 29, 2026
f34ad11
feat: theme the Flux layer — toast home + on-brand surface, no native…
claude May 29, 2026
447e8c1
feat: sharpen the diff gutter and tidy the file-header actions
claude May 29, 2026
e94f228
feat: sharpen the sidebar and collapse the context tree
claude May 29, 2026
c096c72
feat: chrome consistency + motion polish
claude May 29, 2026
d8b7677
feat: unify diff expand controls behind one "Show" affordance
claude May 29, 2026
603e849
feat: unfold the comment form on open instead of popping in
claude May 29, 2026
55f4c68
refactor: trim expand-control props and dedupe the expander button co…
claude May 29, 2026
83f1b90
fix: recompute divergence after undoing a branch switch
claude May 29, 2026
34c2054
fix: address PR review feedback on expanders and motion
claude May 29, 2026
5a2e21c
test: stabilize browser tests against the redesigned UI
claude May 29, 2026
160afb3
fix: de-flake draft re-open test and rework the stale cancel test
claude May 29, 2026
483b6cc
fix: restore keyboard focus after expanding a diff gap
claude May 29, 2026
0ee6bd1
test: harden gap-refocus browser test against slow CI runners
claude May 29, 2026
42ee9f1
test: stabilize Csrf419 reload test under parallel contention
claude May 29, 2026
e09fa2e
Fix stuck expand spinner when expandGap returns early
claude May 30, 2026
7a28ab0
ci: raise benchmark-perf absolute floor to 15ms
claude May 30, 2026
7eac844
fix: scope expand spinner clear per-file; harden path collapse + draf…
claude May 30, 2026
2f7d4c6
Fix split diff comment anchoring
fgilio May 31, 2026
4732d2d
Strengthen split diff anchoring tests
fgilio May 31, 2026
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
19 changes: 11 additions & 8 deletions config/theme.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,22 @@
// Full color values - no opacity modifier needed
'raw' => [
'light' => [
'add-bg' => 'rgba(22,163,74,0.08)',
'add-line' => 'rgba(22,163,74,0.25)',
'del-bg' => 'rgba(220,38,38,0.08)',
'del-line' => 'rgba(220,38,38,0.25)',
// Row fills stay light so syntax highlighting reads through; the
// change-marker stripe (add-line/del-line) carries the signal at a
// confident alpha so adds/dels segment pre-attentively.
'add-bg' => 'rgba(22,163,74,0.10)',
'add-line' => 'rgba(22,163,74,0.60)',
'del-bg' => 'rgba(220,38,38,0.10)',
'del-line' => 'rgba(220,38,38,0.60)',
'hunk-bg' => 'rgba(9,9,11,0.03)',
'hover-bg' => 'rgba(9,9,11,0.04)',
'selected-bg' => 'rgba(9,9,11,0.08)',
],
'dark' => [
'add-bg' => 'rgba(74,222,128,0.10)',
'add-line' => 'rgba(74,222,128,0.30)',
'del-bg' => 'rgba(248,113,113,0.10)',
'del-line' => 'rgba(248,113,113,0.30)',
'add-bg' => 'rgba(74,222,128,0.12)',
'add-line' => 'rgba(74,222,128,0.65)',
'del-bg' => 'rgba(248,113,113,0.12)',
'del-line' => 'rgba(248,113,113,0.65)',
'hunk-bg' => 'rgba(250,250,250,0.04)',
'hover-bg' => 'rgba(250,250,250,0.05)',
'selected-bg' => 'rgba(250,250,250,0.10)',
Expand Down
64 changes: 63 additions & 1 deletion public/js/diff-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@
return lines.length ? lines.join('\n').trimEnd() : null;
}

// The expander to refocus after a gap expand re-renders, keyed by hunk index.
// A keyboard-activated expander loses focus when its node is replaced by the
// post-expand morph; this hands focus to the expander that now occupies the
// same gap. Returns null when the gap fully closed (the "all N hidden lines"
// target) — nothing remains to focus there, so focus is left to fall back.
function expanderToRefocus(root, gapKey) {
if (!root || gapKey === null || gapKey === undefined || gapKey === '') {
return null;
}
return root.querySelector(`[data-expand-gap="${gapKey}"]`);
}

function createDiffFile({ fileId, filePath, oldPath = null, status = 'modified', isReviewed, singleFile = false }) {
const pending = window.__rfaPendingExpandFiles;
const wantsExpand = pending && pending.has(fileId);
Expand Down Expand Up @@ -87,6 +99,37 @@
});
},

// Hunk index of a keyboard-activated gap expander, remembered so focus
// can return to that gap after the expand re-render replaces the
// activated node. Null for mouse clicks (focus shouldn't jump) and for
// the master "full file" expander (no gap remains to focus).
_refocusExpandKey: null,
_onDiffActionCompleted: null,

armExpandRefocus(event, gapKey) {
// Keyboard activation of a <button> fires a click with detail 0;
// mouse clicks report detail >= 1. Only keyboard users lose their
// place on the re-render, so only they get focus restored.
this._refocusExpandKey = (event && event.detail === 0 && gapKey != null) ? gapKey : null;
},

restoreExpandFocus(action) {
if (action !== 'expandGap' && action !== 'expandContext') {
return;
}
const gapKey = this._refocusExpandKey;
this._refocusExpandKey = null;
if (gapKey == null) {
return;
}
this.$nextTick(() => {
const target = expanderToRefocus(this.$root, gapKey);
if (target) {
target.focus();
}
});
},

isLineFolded(ancestors) {
if (!ancestors || ancestors.length === 0) return false;
for (let i = 0; i < ancestors.length; i++) {
Expand Down Expand Up @@ -252,10 +295,29 @@
this.isDragging = false;
},

init() {
// expandGap/expandContext dispatch rfa:diff-action-completed after
// their re-render. We attach imperatively rather than via @-binding
// because the colon in the event name is awkward in Alpine's @
// syntax — same reason runtime-diagnostics.js listens this way.
this._onDiffActionCompleted = (event) => {
const detail = event.detail || {};
if (String(detail.fileId) !== String(this.fileId)) {
return;
}
this.restoreExpandFocus(detail.action);
};
window.addEventListener('rfa:diff-action-completed', this._onDiffActionCompleted);
},

destroy() {
this.stopDragTracking();
if (this.escTimer) { clearTimeout(this.escTimer); this.escTimer = null; }
this.escHint = false;
if (this._onDiffActionCompleted) {
window.removeEventListener('rfa:diff-action-completed', this._onDiffActionCompleted);
this._onDiffActionCompleted = null;
}
},

handleEscape() {
Expand Down Expand Up @@ -427,5 +489,5 @@
}
}

return { getScrollSpeed, extractLineSnippet, createDiffFile, install, autoInstall };
return { getScrollSpeed, extractLineSnippet, expanderToRefocus, createDiffFile, install, autoInstall };
});
41 changes: 41 additions & 0 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@
--tracking-brutal-tight: -0.06em;
}

/* Map Flux's accent token (used by variant="primary", checkboxes, focus rings)
onto the app's monochrome `gh-accent` so Flux components stay on-brand
(near-black in light, near-white in dark) instead of Flux's stock indigo.
Custom properties resolve at use-time, so referencing --gh-* (defined in the
layout's inline style) here is safe regardless of stylesheet order. */
:root {
--color-accent: rgb(var(--gh-accent));
--color-accent-content: rgb(var(--gh-accent));
--color-accent-foreground: rgb(var(--gh-bg));
}

/* Bring Flux toasts onto the app surface so they match the hand-built
undo-toast (radius/shadow already align). Targets the toast dialog body;
higher specificity than Flux's bg-white/zinc utilities, loaded after them. */
[data-flux-toast-dialog] > div {
background-color: rgb(var(--gh-surface));
border-color: rgb(var(--gh-border));
}

* {
scrollbar-width: thin;
scrollbar-color: gray transparent;
Expand All @@ -57,3 +76,25 @@
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}

/* Inline/file comment form reveal. The form is mounted via x-if (a single
instance, kept off-DOM until opened — see public/js/diff-file.js), so it
can't use an Alpine enter-transition. On mount it unfolds with a height
collapse (grid-template-rows 0fr -> 1fr) plus a fade, pushing the rows below
down instead of popping in. Open-only: x-if removes the node on close. The
global reduced-motion rule above neutralizes the duration. */
@keyframes rfa-comment-open {
from { grid-template-rows: 0fr; opacity: 0; }
to { grid-template-rows: 1fr; opacity: 1; }
}
.comment-open {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 1fr;
animation: rfa-comment-open 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.comment-open > * {
min-width: 0;
min-height: 0;
overflow: hidden;
}
2 changes: 1 addition & 1 deletion resources/views/components/comment-display.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class="hover:!text-gh-accent"
size="xs"
aria-label="Delete comment"
x-on:click.stop="$wire.dispatch('delete-comment', { commentId: '{{ $comment['id'] }}' })"
class="hover:!text-red-400"
class="hover:!text-gh-red"
/>
</flux:tooltip>
</div>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/components/comment-form.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ class="font-mono text-xs"
<div x-show="escHint" x-cloak class="text-xs text-gh-muted mt-1" data-testid="esc-hint">Press Esc again to save as draft</div>
<div class="flex justify-end gap-2 mt-2">
<flux:button variant="ghost" size="sm" x-on:click="cancelForm()">Cancel</flux:button>
<flux:button variant="primary" size="sm" color="green" x-on:click="{{ $save }}()" x-bind:disabled="!formBody.trim()">Save</flux:button>
<flux:button variant="primary" size="sm" x-on:click="{{ $save }}()" x-bind:disabled="!formBody.trim()">Save</flux:button>
</div>
</flux:card>
21 changes: 21 additions & 0 deletions resources/views/components/diff-skeleton.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{{--
Content-shaped placeholder for a diff file while its hunks load — a few
line-number + content bars in the diff's own rhythm, so the layout doesn't
jump when the real diff arrives. `animate-pulse` is collapsed by the global
reduce-motion rule. Decorative, so aria-hidden.
--}}
@props(['rows' => 7])

@php
// Varied bar widths read as code rather than a flat block.
$widths = [86, 58, 72, 44, 90, 64, 50, 78, 38, 68];
@endphp

<div class="px-4 py-3 motion-safe:animate-pulse" aria-hidden="true">
@for($i = 0; $i < $rows; $i++)
<div class="flex items-center gap-3 py-1">
<div class="h-3 w-6 rounded-sm bg-gh-border/60 shrink-0"></div>
<div class="h-3 rounded-sm bg-gh-border/40" style="width: {{ $widths[$i % count($widths)] }}%"></div>
</div>
@endfor
</div>
28 changes: 28 additions & 0 deletions resources/views/components/diff/expand-button.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{{--
A clickable target inside <x-diff.expand-control>. Centralizes the expand
button's wire contract so each call site names its Livewire `action` exactly
once — it drives wire:click, wire:target, and the markDiffActionStart timing
mark, and flips the shell's shared `loading` Alpine flag on click. The
gh-link affordance styling is the base; callers merge per-button classes
(chip padding, tabular-nums) via the class attribute.

`gapKey` (the hunk index, set by gap expanders) tags the button with
data-expand-gap and arms keyboard-only focus restoration: when the
post-expand re-render replaces this node, focus returns to the expander that
now sits at the same gap instead of dropping to <body>. Mouse clicks don't
arm it, and the master "full file" expander leaves it null (no gap remains).
--}}
@props([
'action',
'args' => '',
'gapKey' => null,
])

<button
wire:click="{{ $action }}({{ $args }})"
wire:loading.attr="disabled"
wire:target="{{ $action }}"
@if($gapKey !== null) data-expand-gap="{{ $gapKey }}" @endif
@click="loading = true; markDiffActionStart('{{ $action }}'); armExpandRefocus($event, @js($gapKey))"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset expand loading when the action returns early

When a gap expander is clicked, this sets the parent loading flag immediately, but it only gets cleared by a DOM replacement or the completion event. If the underlying diff changes between render and click so expandGap() hits one of its early returns (for example the full-context reload has no hunks), no completion event is dispatched and the keyed row does not change, leaving the expander stuck on “Loading…” until the file/page is refreshed. Either clear loading on Livewire request completion/failure or dispatch the completion event on the early-return paths.

Useful? React with 👍 / 👎.

{{ $attributes->class('text-gh-link hover:text-gh-text transition-colors disabled:opacity-50') }}
>{{ $slot }}</button>
46 changes: 46 additions & 0 deletions resources/views/components/diff/expand-control.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{{--
Unified chrome for every collapsed-content affordance in a diff: the
"show full file" master expander, per-gap tiered expanders, the trailing
gap, and the (non-expandable) section-context label.

Coherence contract: the verb ("Show") is rendered here, once, as a muted
lead-in — never inside a button. Everything blue (gh-link) is clickable and
lives in the slot: "full file", "8 hidden lines", the "15 · 50 · 100" chips.
So every expander reads `↕ Show <blue target>` with identical styling.

Expandable bands carry the `expand-all` (↕) icon, a dashed top/bottom rule
(dashed = collapsed content lives here), and own a shared `loading` Alpine
flag so any button in the slot can swap the row to a spinner with
`@click="loading = true"`. The section-context label passes `:icon="false"`
(and `align="start"`) — no affordance, no verb, no rule, just the context.
--}}
@props([
'icon' => 'expand-all',
'align' => 'center',
])

@php
$hasIcon = $icon !== false && $icon !== null;
@endphp

<div
{{ $attributes->class([
'diff-fullspan bg-gh-hunk-bg px-4 py-1.5 text-xs font-mono flex items-center gap-2',
'border-y border-dashed border-gh-border/20' => $hasIcon,
'justify-center select-none' => $align === 'center',
'justify-start' => $align === 'start',
]) }}
@if($hasIcon) x-data="{ loading: false }" @endif
>
@if($hasIcon)
<flux:icon :icon="$icon" variant="outline" class="!size-3.5 shrink-0 text-gh-muted/50" x-show="!loading" />
<span x-show="!loading" class="inline-flex min-w-0 items-center gap-2">
<span class="text-gh-muted/60">Show</span>
{{ $slot }}
</span>
<flux:icon icon="arrow-path" variant="outline" class="!size-3.5 shrink-0 animate-spin text-gh-muted" x-show="loading" x-cloak />
<span x-show="loading" x-cloak class="text-gh-muted">Loading…</span>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@else
{{ $slot }}
@endif
</div>
4 changes: 2 additions & 2 deletions resources/views/components/diff/file-header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class="text-sm"
</div>

<div class="flex items-center gap-2 text-xs shrink-0 font-mono">
<div class="flex items-center gap-0.5 opacity-30 group-hover:opacity-100 transition-opacity">
<div class="flex items-center gap-0.5 opacity-60 group-hover:opacity-100 transition-opacity">
<x-copy-paths-button
mode="single"
size="sm"
Expand All @@ -55,7 +55,7 @@ class="text-sm"

@if($showContentCopy)
<flux:dropdown position="bottom" align="end">
<flux:button icon="ellipsis-vertical" icon:variant="outline" variant="ghost" size="sm" aria-label="Copy content" />
<flux:button icon="clipboard-document" icon:variant="outline" variant="ghost" size="sm" tooltip="Copy content" aria-label="Copy content" />
<flux:menu>
<flux:menu.item icon="code-bracket" icon:variant="outline" @click="$wire.copyContent('diff')">
Copy diff
Expand Down
23 changes: 11 additions & 12 deletions resources/views/components/diff/hunk.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,20 @@
{{-- Each hunk is its own subgrid so split-mode `grid-auto-flow: dense` pairs
remove+add only within this hunk — never across hunk boundaries. --}}
<div class="diff-hunk">
{{-- Gap with expand controls (or hunk header when no preceding gap). --}}
{{-- Gap with expand controls (or hunk section-context label when no preceding gap). --}}
@if($hunkIndex > 0 || $hunk['newStart'] > 1)
<div class="diff-fullspan bg-gh-hunk-bg py-1.5 text-center text-xs border-y border-dashed border-gh-border/20">
@php
$hiddenCount = $hunkIndex > 0
? $hunk['newStart'] - ($prevHunk['newStart'] + $prevHunk['newCount'])
: $hunk['newStart'] - 1;
@endphp
@php
$hiddenCount = $hunkIndex > 0
? $hunk['newStart'] - ($prevHunk['newStart'] + $prevHunk['newCount'])
: $hunk['newStart'] - 1;
@endphp
<x-diff.expand-control wire:key="expand-gap-{{ $hunkIndex }}-{{ $hiddenCount }}">
<x-tiered-expand-gap :hunk-index="$hunkIndex" :hidden-count="$hiddenCount" />
</div>
</x-diff.expand-control>
@elseif($hunk['header'] !== '')
<div class="diff-fullspan bg-gh-hunk-bg px-4 py-1 text-gh-muted text-xs">
@@ -{{ $hunk['oldStart'] }} +{{ $hunk['newStart'] }} @@
<span class="text-gh-muted/60">{{ $hunk['header'] }}</span>
</div>
<x-diff.expand-control :icon="false" align="start">
<span class="text-gh-muted/70">{{ $hunk['header'] }}</span>
</x-diff.expand-control>
@endif

@foreach($hunk['lines'] as $line)
Expand Down
2 changes: 1 addition & 1 deletion resources/views/components/diff/line.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class="inline-flex align-middle -my-0.5 mr-1 size-4 items-center justify-center

@if($lineNum !== null)
<template x-if="showForm && formEndLine === {{ $lineNum }} && formSide !== 'file' && (@js($lineSide) === 'context' || formSide === @js($lineSide))">
<div class="diff-fullspan" @if($ancestorJs) x-show="!isLineFolded({{ $ancestorJs }})" @endif>
<div class="diff-fullspan comment-open" @if($ancestorJs) x-show="!isLineFolded({{ $ancestorJs }})" @endif>
<x-comment-form save="submitComment" placeholder="Write a comment..." border-class="border-y" />
</div>
</template>
Expand Down
Loading
Loading