Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
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>
20 changes: 20 additions & 0 deletions resources/views/components/diff/expand-button.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{{--
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.
--}}
@props([
'action',
'args' => '',
])

<button
wire:click="{{ $action }}({{ $args }})"
wire:loading.attr="disabled"
wire:target="{{ $action }}"
@click="loading = true; markDiffActionStart('{{ $action }}')"
{{ $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
91 changes: 91 additions & 0 deletions resources/views/components/divergence/marker.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{{--
Branch-divergence marker for the header branch control.

Renders a small dot on the branch pill when the repo's checked-out HEAD has
drifted from the review target, with a popover offering the choice. Replaces
the old full-width warning band so the canvas stays calm — the signal lives
where its cause does. Only shows for the quiet, recoverable states
(Diverged / Detached); MissingTarget is a blocking bar (<x-divergence.missing-bar>).

Buttons call ReviewPage actions directly (this is rendered inside its DOM).
--}}
@props(['state', 'context' => []])

@php
$isDiverged = $state === \App\Enums\DivergenceState::Diverged;
$isDetached = $state === \App\Enums\DivergenceState::Detached;
@endphp

@if($isDiverged || $isDetached)
@php
$target = $context['target'] ?? '';
$currentBranch = $context['currentBranch'] ?? null;
$shortSha = $context['shortSha'] ?? '';
$commentCount = (int) ($context['commentCount'] ?? 0);

// Literal class strings so Tailwind's source scan keeps them in the build.
$dotClasses = $isDiverged ? 'bg-gh-attention ring-gh-attention/30' : 'bg-gh-link ring-gh-link/30';
$testid = $isDiverged ? 'divergence-banner-diverged' : 'divergence-banner-detached';
@endphp

<div x-data="{ open: false }" class="relative inline-flex -ml-0.5" data-testid="{{ $testid }}">
<button
type="button"
@click="open = ! open"
@keydown.escape.window="open = false"
:aria-expanded="open"
aria-label="{{ $isDiverged ? 'Checkout moved off the review branch — show options' : 'Repo detached from the review branch — show options' }}"
class="inline-flex items-center justify-center size-6 rounded-md hover:bg-gh-border/40 transition-colors"
>
<span class="relative inline-flex h-2 w-2 rounded-full ring-2 {{ $dotClasses }}"></span>
</button>

<div
x-show="open"
x-cloak
@click.outside="open = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 -translate-y-1 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 -translate-y-1 scale-95"
role="dialog"
class="absolute left-0 top-full mt-2 z-50 w-[320px] origin-top-left rounded-xl border border-gh-border bg-gh-bg shadow-xl shadow-black/10 overflow-hidden"
>
<div class="px-4 pt-3.5 pb-3 space-y-2.5">
<div class="flex items-center gap-2">
<span class="inline-flex h-2 w-2 rounded-full {{ $dotClasses }}"></span>
<span class="font-display font-semibold tracking-brutal">{{ $isDiverged ? 'Your checkout moved' : 'Repo is detached' }}</span>
</div>

@if($isDiverged)
<p class="text-xs text-gh-muted leading-relaxed">
Repo is on <span class="font-mono text-gh-text">{{ $currentBranch }}</span>.
RFA is still showing your review of <span class="font-mono text-gh-text">{{ $target }}</span>.
</p>
@if($commentCount > 0)
<p class="text-xs text-gh-muted">
<span class="text-gh-text font-medium tabular-nums">{{ $commentCount }}</span>
{{ \Illuminate\Support\Str::plural('comment', $commentCount) }} on <span class="font-mono">{{ $target }}</span>
</p>
@endif
@else
<p class="text-xs text-gh-muted leading-relaxed">
Repo is detached at <span class="font-mono text-gh-text">{{ $shortSha }}</span>.
RFA is still showing your review of <span class="font-mono text-gh-text">{{ $target }}</span>.
</p>
@endif
</div>

<div class="flex items-center justify-end gap-1 px-3 py-2.5 border-t border-gh-border bg-gh-surface/50">
@if($isDiverged)
<button type="button" wire:click="keepReviewing" @click="open = false" class="text-xs font-medium text-gh-muted hover:text-gh-text px-2.5 py-1.5 rounded-md transition-colors">Keep reviewing</button>
<button type="button" wire:click="switchReviewToHead" @click="open = false" class="text-xs font-medium font-display rounded-md px-3 py-1.5 bg-gh-accent text-gh-bg hover:opacity-90 transition-opacity">Switch review here</button>
@else
<button type="button" wire:click="dismissDetachedBanner" @click="open = false" class="text-xs font-medium text-gh-muted hover:text-gh-text px-2.5 py-1.5 rounded-md transition-colors">Dismiss</button>
@endif
</div>
</div>
</div>
@endif
38 changes: 38 additions & 0 deletions resources/views/components/divergence/missing-bar.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{{--
Branch-divergence "missing target" bar.

The one genuinely blocking divergence state: the branch being reviewed no
longer exists (deleted / renamed / mid-rebase). Unlike Diverged/Detached
(quiet header marker), this stays a full-width bar — but on-brand (house
pattern from update-banner, gh-* tokens) rather than a stock-Flux callout,
and now dismissible. Buttons call ReviewPage actions directly.
--}}
@props(['state', 'context' => []])

@if($state === \App\Enums\DivergenceState::MissingTarget)
@php
$target = $context['target'] ?? '';
$currentBranch = $context['currentBranch'] ?? null;
@endphp

<div class="bg-gh-surface border-b border-gh-border px-5 py-2.5" role="alert" aria-live="assertive" data-testid="divergence-banner-missing">
<div class="flex items-center gap-3">
<flux:icon icon="exclamation-triangle" variant="outline" class="!size-4 shrink-0 text-gh-red" />
<div class="min-w-0 flex-1">
<p class="font-display font-semibold tracking-brutal text-sm">Review target <span class="font-mono">{{ $target }}</span> no longer exists</p>
<p class="text-xs text-gh-muted">
The branch you were reviewing is gone — deleted, renamed, or mid-rebase.
@if($currentBranch)
Repo is now on <span class="font-mono text-gh-text">{{ $currentBranch }}</span>.
@endif
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<button type="button" wire:click="dismissMissingTarget" class="text-xs font-medium text-gh-muted hover:text-gh-text px-2.5 py-1.5 rounded-md transition-colors">Dismiss</button>
@if($currentBranch)
<button type="button" wire:click="switchReviewToHead" class="text-xs font-medium font-display rounded-md px-3 py-1.5 bg-gh-accent text-gh-bg hover:opacity-90 transition-opacity">Switch review here</button>
@endif
</div>
</div>
</div>
@endif
Loading
Loading