From 41cf98eea3b6e16ee54227810acc0294a1f0f45f Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Thu, 6 Aug 2026 18:05:14 -0700 Subject: [PATCH 01/41] docs: add diff hunk/block affordance design Design for a nested hover affordance (hunk + change-block rings with floating stage/discard actions) in the working-copy diff, hunk- and line-level discard, and a replacement for the line-selection visual that is effectively invisible in the NERV theme. Interaction model validated against a live prototype before writing. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + .../2026-08-06-diff-hunk-affordance-design.md | 253 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-diff-hunk-affordance-design.md diff --git a/.gitignore b/.gitignore index ea390ad..01abd7f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ node_modules !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# superpowers brainstorming scratch (mockups, prototypes) +.superpowers/ diff --git a/docs/superpowers/specs/2026-08-06-diff-hunk-affordance-design.md b/docs/superpowers/specs/2026-08-06-diff-hunk-affordance-design.md new file mode 100644 index 0000000..95295bc --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-diff-hunk-affordance-design.md @@ -0,0 +1,253 @@ +# Diff hunk/block affordance + line discard — Design + +**Goal:** Give the diff view a Fork/SourceTree-style hover affordance (ring + floating actions) at two nested +levels — hunk and change-block — add **Discard** for hunks and line ranges (which the app does not have at any +granularity below whole-file), and replace the line-selection visual that is effectively invisible in the NERV +theme. Unified **and** Split views. Working Copy (Local Changes) only; commit and PR diffs stay inert. + +Interaction model was validated against a live prototype before this spec was written +(`.superpowers/brainstorm/*/content/interactive-prototype-v2.html`, gitignored). + +## Background (verified) + +### The NERV selection bug + +`DiffView.svelte:889-896` is the entire selection visual: + +```css +.diff-row.selected { box-shadow: inset 2px 0 0 var(--accent); + background: color-mix(in srgb, var(--accent) 15%, transparent); } +.split-cell.selected { /* identical */ } +``` + +Both properties derive from `--accent`. In Classic that is `#2563eb` (blue) over a near-white row — legible. +In NERV (`+page.svelte:762-774`) `--accent` is `#F2542D` (orange) and the row underneath a deletion is already +`--diff-del-bg: rgba(255, 68, 56, 0.15)`. A 15% orange wash over a 15% red wash on a `#12171C` panel is very +close to no change at all. Contributing factors: + +- `nerv.css:97-106` paints a fixed root `::after` (scanlines + a `color-mix(--accent 1.5%)` sheet) at + `z-index: 9999` over the whole app, which further flattens low-alpha differences. +- `nerv.css:298-302` recolors `--accent` per scheme and nothing else, so **crimson** (`#E0445A`) collides with + deletions and **phosphor** (`#46E88B`) collides with additions. Any accent-derived 15% wash is unfixable by + tuning — it is the wrong mechanism. + +### Existing staging machinery (all reused) + +- `ops_worktree.rs:242` `split_hunks(diff) -> (header, Vec)`. +- `ops_worktree.rs:264` `git_apply(repo, patch, reverse)` — **hardcodes `--cached`**, pipes the patch via stdin. +- `ops_worktree.rs:339` `build_partial_hunk(hunk, selected, reverse)` — `selected` holds **change-line ordinals** + (0-based over the `+`/`-` lines of one hunk; context and `\ No newline` excluded). With `reverse: true` it emits + a patch whose **new side matches the post-change image**: unselected `+` stay as context, unselected `-` are + dropped. Recomputes the `@@` header, so any `-U{context}` works. +- `ops_worktree.rs:297/311/456/469` `stage_hunk` / `unstage_hunk` / `stage_lines` / `unstage_lines`. +- Chain: `gitActions.ts:865-872` → `api.ts:193-199` → `invoke` → `commands.rs:446-462` → `ops_worktree`. + Commands registered at `lib.rs:199-202`. Context flows from `appState.effectiveDiffContext`. +- `gitActions.ts:932` `discard(paths)` — the file-level danger-confirm pattern to match ("This cannot be undone. + (Stash instead to keep them.)"), routed through `runWorktree`. + +### DiffView structure + +- `DiffView.svelte:435` — a **single ``** wraps every hunk of every file. +- `DiffView.svelte:422` ``; split adds a `` (`:428`). +- `DiffView.svelte:260` `toSplitRows(hunk)` pairs a contiguous run of `del`/`add` lines into side-by-side rows; + context lines span both sides. Selection in split view lives on the ``. +- `DiffView.svelte:210` `selected = $state>>` keyed `"fileIdx:hunkIdx"`; `:230` `toggleLine` + toggles on single click; `:213` an `$effect` clears the selection whenever `patch` changes. +- `DiffView.svelte:450-478` renders "Stage N line(s)" buttons inside the hunk-header row. +- `DiffView.svelte:721-733` `.diff-table-wrap { overflow: auto }` is the single scroll container; the table is + `width: max-content`, so the view scrolls **horizontally** as one unit. +- Three hosts: `WorkingCopyView.svelte:549` (passes staging callbacks), `CommitFilesDiff.svelte:94` and + `github/PrFilesTab.svelte:174` (pass none). +- `+page.svelte:819` opts `.diff-cell` back into `user-select` (the body baseline is `user-select: none`) so diff + code can be copied. + +## Interaction model + +One ring visual, used by both hover and selection. They are **mutually exclusive**, so they cannot be confused +and do not need to look different. + +| Gesture | Result | +|---|---| +| Hover a changed line | **Block ring** (contiguous run of `+`/`-` lines) + block actions | +| Hover a context line | **Hunk ring** (outline on the hunk) + hunk actions | +| Double-click a changed line | **Lock** a one-line selection; hover goes inert | +| Shift+click another line, same hunk | Extend the selection to a contiguous range | +| `Esc`, or any plain click | Clear the selection; hover resumes | + +While a selection is locked: no hunk ring, no block ring, no hover response of any kind. The only ring on screen +is the selection, and its toolbar is persistent rather than hover-transient. + +Nesting: hovering a changed line draws **both** rings — the hunk ring (outer, subtle) and the block ring (inner, +solid) — but only the innermost unit carries buttons. Never two toolbars. + +**Actions by pane:** + +| Pane | Buttons | +|---|---| +| Unstaged | `Stage ` · `Discard ` (or `Stage hunk` · `Discard hunk`) | +| Staged | `Unstage ` (or `Unstage hunk`) | + +Discard is deliberately **unstaged-only**. On the staged side it would have to mean unstage-and-revert — a +two-step destruction whose blast radius is not visible from the pane you are looking at, sitting one hover away +from a routine action. Discarding staged work stays a deliberate two-step: unstage, then discard. + +**Ranges may span context lines.** A range anchored on a change line and extended past unchanged rows stays +visually contiguous; `ordsInRange` collects only the change lines inside it, so `Stage 5` means five real +changes even when the ring covers seven rows. + +**Where the affordance does not appear:** untracked files (no index entry — they render via +`git diff --no-index` and have no hunk-level staging today), and any host that supplies no action callbacks +(`CommitFilesDiff`, `PrFilesTab`). Gate on prop presence, matching how `onLineComment` already gates the +PR-review comment button. + +## Frontend design + +### `src/lib/diff/blocks.ts` (new, pure, vitest-covered) + +Per the convention that logic which can be pure lives outside `.svelte`: + +```ts +export interface Block { rows: number[]; ords: number[] } // row indices within the hunk, change ordinals +export function blocksOf(hunk: DiffHunk): Block[] // contiguous runs of non-context lines +export function ordinalsOf(hunk: DiffHunk): (number | null)[] // per row: change ordinal, or null for context +export function ordsInRange(hunk: DiffHunk, from: number, to: number): number[] +``` + +`blocksOf` and `ordinalsOf` replace the inline `hunkOrdinals` / `indexHunkLines` helpers +(`DiffView.svelte:195-225`), which stay as thin callers or are removed. + +### `DiffView.svelte` + +**DOM:** +- Emit **one `` per hunk** instead of the single wrapping `` at `:435`. This is the + whole reason the hunk ring is free: a `` is a real element and takes a CSS `outline`. A run of ``s + is not, which is why the block ring cannot use the same mechanism. +- Add **one overlay `
` and one `
`** inside `.diff-table-wrap`, + which gains `position: relative`. Both are positioned in JS from the first/last row of the target range. +- `pointer-events: none` on the ring. The toolbar is interactive and must sit above the rows. + +**State:** +```ts +let sel = $state<{ fi: number; hi: number; from: number; to: number } | null>(null); +let hov = $state<{ fi: number; hi: number; block: number | null } | null>(null); +``` +`sel` replaces `Map>` — contiguity makes a set unnecessary. The existing `$effect` that +clears selection on `patch` change (`:213`) keeps working and stays. + +**Positioning:** the ring and toolbar are absolutely positioned against `.diff-table-wrap`, offset by both +`scrollTop` **and `scrollLeft`** — the table is `width: max-content` and scrolls horizontally. A `scroll` listener +on the wrap repaints. The prototype validated this approach including the horizontal case. + +**Split view:** the ring spans the **full row width**, covering both columns. A paired row's change ordinals are +the same set as in Unified, so `stage_lines` / `discard_lines` receive identical arguments in both modes and no +backend branching is needed. Accepted cost: a paired row selects as a unit, so Split loses "stage the addition +but not its deletion" precision. Unified retains it and is one toolbar click away. + +**Removed:** `.diff-row.selected` / `.split-cell.selected` CSS (`:889-896`), the `toggleLine` single-click +handler and its `onkeydown` twin, and the hunk-header selection buttons (`:450-457`, `:471-478`). The original +NERV bug is fixed by **deleting** the rule that caused it, not by restyling it. + +**Also removed:** the hunk-header `Stage hunk` / `Unstage hunk` buttons (`:444-448`, `:465-469`). The hover +toolbar supersedes them; leaving both would mean two live paths to the same action. The hunk header keeps its +`@@` range label. + +**After any action** the selection clears and hover resumes. This falls out of the existing `$effect` at `:213` +— every op triggers a working-copy refresh, the `patch` prop changes, and the hunk indices the selection was +expressed in are stale by definition. + +### Keyboard + +Focus behaves as hover, so the feature is not mouse-only (today's rows are focusable and handle Enter/Space — +this preserves that rather than regressing it): + +- Focusing a changed row raises its block ring + toolbar; focusing a context row raises the hunk ring. +- `Enter` / `Space` on a focused changed row locks a one-line selection there. +- `Shift`+`↑`/`↓` extends the locked range within the hunk. +- `Esc` clears. +- Toolbar buttons are real `
`-per-hunk is a structural change** to a table that also renders split mode with a ``. + Verify column sizing is unaffected in both modes (the `` sits outside `` and should not care). From 3c23ce2c2ed504b4828095d8070141b413732ef4 Mon Sep 17 00:00:00 2001 From: Ash Shah <494shah@gmail.com> Date: Thu, 6 Aug 2026 18:14:52 -0700 Subject: [PATCH 02/41] docs: add diff hunk affordance implementation plan Eight TDD tasks: pure block/ordinal helpers, git-core discard ops, Tauri/api/gitActions plumbing, hover rings, floating toolbar, range selection, split view, and keyboard parity. Also corrects the spec's ring mechanism: box-shadow does not merge across rules but custom properties do, so the block ring is pure CSS rather than a JS-positioned overlay. Only the toolbar needs measuring. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-06-diff-hunk-affordance.md | 1527 +++++++++++++++++ .../2026-08-06-diff-hunk-affordance-design.md | 29 +- 2 files changed, 1546 insertions(+), 10 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-06-diff-hunk-affordance.md diff --git a/docs/superpowers/plans/2026-08-06-diff-hunk-affordance.md b/docs/superpowers/plans/2026-08-06-diff-hunk-affordance.md new file mode 100644 index 0000000..a100ff6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-diff-hunk-affordance.md @@ -0,0 +1,1527 @@ +# Diff Hunk/Block Affordance + Line Discard — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a nested hover affordance (hunk ring + change-block ring with a floating Stage/Discard/Unstage toolbar) to the working-copy diff, add hunk- and line-level Discard, and replace the line-selection visual that is invisible in the NERV theme. + +**Architecture:** Pure block/ordinal math moves to a vitest-covered `src/lib/diff/blocks.ts`. `DiffView.svelte` emits one `` per hunk (so the hunk ring is a plain CSS `outline`) and draws the block/selection ring with four *separate CSS custom properties* feeding one `box-shadow` on every ``s possible without JS measurement. Only the floating toolbar needs JS, and only for its vertical offset. In Rust, `git_apply` gains a `cached` flag; dropping `--cached` turns the existing reverse-apply into a worktree discard. + +**Tech Stack:** SvelteKit 5 (runes), TypeScript, vitest, Rust (cargo workspace), Tauri 2. + +## Global Constraints + +- **Never `:global()` in `src/lib/theme/nerv.css`** — it is not a Svelte `
`, not the `
` — these merge instead of overriding, which is what makes a ring around a run of `