Merge git-panel-design-fidelity: match the approved design

Closes the three deviations between the shipped git panel and
docs/mockups/project-detail-git.html: the four-cell sync band with its
explanatory footnotes, the dirty count on the title line, and worktree rows as
two-line cards with a working Open button. Adds structural assertions so
layout drift is caught by the suite, not by eye.
This commit is contained in:
Yaojia Wang
2026-07-29 18:23:02 +02:00
5 changed files with 295 additions and 29 deletions

View File

@@ -106,6 +106,15 @@ export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
container.append(el('div', 'proj-empty', 'No commits yet.')) container.append(el('div', 'proj-empty', 'No commits yet.'))
return return
} }
// Design: the section label carries the unpushed count, so the number is
// readable before the eye has walked the rows looking for rails.
const unpushedCount = log.commits.filter((c) => c.unpushed === true).length
if (unpushedCount > 0 && log.upstream !== undefined) {
const badge = el('div', 'proj-commitlog-count', `${unpushedCount} unpushed`)
badge.title = `${unpushedCount} commit(s) not on ${log.upstream}`
container.append(badge)
}
const list = el('div', 'proj-commitlog-list') const list = el('div', 'proj-commitlog-list')
// w6/G4: draw the upstream's position ONCE, right after the last unpushed row. // w6/G4: draw the upstream's position ONCE, right after the last unpushed row.
// Only with a named upstream — with nothing to compare against there is no // Only with a named upstream — with nothing to compare against there is no

Binary file not shown.

View File

@@ -1560,26 +1560,74 @@ body {
} }
/* ── w6/G3: the project detail sync band ────────────────────────────────────── /* ── w6/G3: the project detail sync band ──────────────────────────────────────
Semantic colours here are deliberately NOT the accent: --ok reads "verified, Four labelled cells, per docs/mockups/project-detail-git.html. The captions
ignore this" and is the one state that may look reassuring, so it must never and footnotes are load-bearing, not decoration: they are what tells a reader
be reachable from a stale or unknowable comparison (see makeSyncBand). */ WHY a zero cannot be trusted, which is the whole reason this band exists.
Semantic colours are deliberately NOT the accent; --ok reads "verified,
ignore this" and must stay unreachable from a stale or unknowable state. */
.proj-syncband { .proj-syncband {
display: flex; display: flex;
align-items: center; align-items: stretch;
gap: 8px;
flex-wrap: wrap;
margin: 10px 0 4px; margin: 10px 0 4px;
padding: 9px 12px;
background: var(--bg-sunk, rgba(0, 0, 0, 0.22)); background: var(--bg-sunk, rgba(0, 0, 0, 0.22));
border: 1px solid var(--border, rgba(255, 255, 255, 0.08)); border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
border-radius: 8px; border-radius: 9px;
overflow: hidden;
flex-wrap: wrap;
}
.proj-sync-cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 11px 18px;
min-width: 0;
border-right: 1px solid var(--border, rgba(255, 255, 255, 0.08));
}
.proj-sync-cell:last-child {
border-right: 0;
}
.proj-sync-actions {
margin-left: auto;
justify-content: center;
}
.proj-sync-k {
font-size: 10.5px;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--fg-faint, #6f6a61);
}
.proj-sync-v {
display: flex;
align-items: center;
gap: 7px;
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.proj-sync-big {
font-size: 19px;
font-weight: 600;
letter-spacing: -0.02em;
}
.proj-sync-sub {
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11px;
color: var(--fg-faint, #6f6a61);
}
.proj-sync-sub-bad {
color: #cf6b4f;
} }
.proj-sync-upstream { .proj-sync-upstream {
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11.5px;
color: var(--fg-dim, #a8a299); color: var(--fg-dim, #a8a299);
flex: none;
} }
.proj-sync-chip { .proj-sync-chip {
@@ -1594,12 +1642,10 @@ body {
.proj-sync-ahead { .proj-sync-ahead {
color: var(--accent); color: var(--accent);
background: var(--accent-soft);
} }
.proj-sync-behind { .proj-sync-behind {
color: var(--fg-dim, #a8a299); color: var(--fg-faint, #6f6a61);
background: rgba(255, 255, 255, 0.06);
} }
/* The one reassuring state. Reachable only with an upstream AND a fresh fetch. */ /* The one reassuring state. Reachable only with an upstream AND a fresh fetch. */
@@ -1614,6 +1660,11 @@ body {
.proj-sync-noupstream, .proj-sync-noupstream,
.proj-sync-detached { .proj-sync-detached {
color: #cf6b4f; color: #cf6b4f;
}
.proj-sync-chip.proj-sync-stale,
.proj-sync-noupstream,
.proj-sync-detached {
background: rgba(207, 107, 79, 0.14); background: rgba(207, 107, 79, 0.14);
border-color: rgba(207, 107, 79, 0.36); border-color: rgba(207, 107, 79, 0.36);
} }
@@ -1621,10 +1672,98 @@ body {
.proj-sync-dirty { .proj-sync-dirty {
color: var(--amber); color: var(--amber);
background: rgba(224, 151, 90, 0.13); background: rgba(224, 151, 90, 0.13);
border: 1px solid rgba(224, 151, 90, 0.3);
border-radius: 5px;
padding: 2px 8px;
font-size: 11.5px;
white-space: nowrap;
flex: none;
} }
.proj-sync-fetch { .proj-sync-fetch {
margin-left: auto; font: inherit;
font-size: 12.5px;
font-weight: 560;
cursor: pointer;
padding: 7px 15px;
border-radius: 7px;
border: 0;
background: var(--accent);
color: var(--bg, #100f0d);
}
.proj-sync-fetch:hover:not(:disabled) {
filter: brightness(1.08);
}
.proj-sync-fetch:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.proj-sync-fetch:focus-visible {
outline: 2px solid var(--fg, #ece9e3);
outline-offset: 2px;
}
@media (max-width: 720px) {
.proj-syncband {
flex-direction: column;
}
.proj-sync-cell {
border-right: 0;
border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.08));
}
.proj-sync-actions {
margin-left: 0;
}
}
/* ── w6: unpushed count beside the commit-list heading ───────────────────── */
.proj-commitlog-count {
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 11.5px;
color: var(--accent);
margin: -4px 0 6px;
}
/* ── w6/G5: worktree rows as two-line cards ──────────────────────────────── */
.proj-wt-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
}
.proj-wt-row-current {
border-color: rgba(227, 166, 74, 0.36);
background: linear-gradient(90deg, var(--accent-soft), transparent 46%);
}
.proj-wt-main {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.proj-wt-top {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
}
.proj-wt-right {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.proj-wt-open {
font: inherit; font: inherit;
font-size: 11.5px; font-size: 11.5px;
cursor: pointer; cursor: pointer;
@@ -1635,14 +1774,14 @@ body {
color: var(--fg, #ece9e3); color: var(--fg, #ece9e3);
} }
.proj-sync-fetch:hover:not(:disabled) { .proj-wt-open:hover {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
.proj-sync-fetch:disabled { .proj-wt-open:focus-visible {
opacity: 0.45; outline: 2px solid var(--fg, #ece9e3);
cursor: not-allowed; outline-offset: 2px;
} }
/* ── w6/G4: unpushed commits + the upstream boundary ───────────────────────── */ /* ── w6/G4: unpushed commits + the upstream boundary ───────────────────────── */

View File

@@ -211,3 +211,25 @@ describe('renderGitLog — unpushed boundary (w6 G4)', () => {
expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined() expect(normalizeGitLog({ commits: [], truncated: false, upstream: 42 })!.upstream).toBeUndefined()
}) })
}) })
describe('renderGitLog — unpushed count beside the heading (w6 design)', () => {
function entry(hash: string, subject: string, unpushed?: boolean) {
return { hash, at: Date.now(), subject, ...(unpushed === true ? { unpushed: true } : {}) }
}
it('states the count so it is readable before scanning the rows', () => {
const host = document.createElement('div')
renderGitLog(host, {
commits: [entry('a1', 'x', true), entry('b2', 'y', true), entry('c3', 'z')],
truncated: false,
upstream: 'origin/develop',
})
expect(host.querySelector('.proj-commitlog-count')!.textContent).toContain('2')
})
it('omits the count when nothing is unpushed', () => {
const host = document.createElement('div')
renderGitLog(host, { commits: [entry('a1', 'x')], truncated: false, upstream: 'origin/m' })
expect(host.querySelector('.proj-commitlog-count')).toBeNull()
})
})

View File

@@ -777,18 +777,42 @@ describe('makeSyncBand (w6 G3)', () => {
expect(b.textContent).toContain('origin/develop') expect(b.textContent).toContain('origin/develop')
}) })
it('counts dirty files instead of showing a bare dot', () => { // The design puts the dirty count on the title line, not in the band — the
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, { // band is about the remote, the working tree is not. Asserted at the header
dirtyCount: 3, // level in the renderProjectDetail block below.
})! it('keeps the working-tree count out of the band', () => {
expect(b.querySelector('.proj-sync-dirty')!.textContent).toContain('3') const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-dirty')).toBeNull()
}) })
it('omits the dirty chip entirely for a clean tree', () => { // ── design fidelity: the captions and footnotes are load-bearing ──────────
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, { it('labels every cell, so a number is never shown without saying what it is', () => {
dirtyCount: 0, const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: STALE })!
})! const captions = Array.from(b.querySelectorAll('.proj-sync-k')).map((n) =>
expect(b.querySelector('.proj-sync-dirty')).toBeNull() (n.textContent ?? '').toLowerCase(),
)
expect(captions).toContain('upstream')
expect(captions).toContain('to push')
expect(captions).toContain('to pull')
})
it('spells out WHY a stale zero cannot be trusted, not just that it is stale', () => {
const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: STALE })!
const bad = b.querySelector('.proj-sync-sub-bad')
expect(bad).not.toBeNull()
expect(bad!.textContent).toMatch(/cannot be trusted/i)
expect(bad!.textContent).toMatch(/fetched/i)
})
it('explains a missing upstream in words as well as colour', () => {
const b = band({ lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-sub')!.textContent).toMatch(/tracks nothing/i)
})
it('renders the counts at display size', () => {
const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-ahead')!.classList.contains('proj-sync-big')).toBe(true)
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-big')).toBe(true)
}) })
}) })
@@ -894,3 +918,75 @@ describe('makeWorktreeRow — session chip (w6 G7)', () => {
expect(row.querySelector('.proj-wt-sessions')).toBeNull() expect(row.querySelector('.proj-wt-sessions')).toBeNull()
}) })
}) })
/* ── design fidelity: header + worktree card ───────────────────────────────── */
describe('renderProjectDetail — dirty count on the title line (w6)', () => {
const hooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn(), onFanout: vi.fn() })
const cbs = () => ({ onBack: vi.fn(), onKill: vi.fn() })
function detail(over: Partial<import('../src/types.js').ProjectDetail> = {}) {
return {
name: 'repo',
path: '/p/repo',
isGit: true,
branch: 'develop',
worktrees: [],
sessions: [],
hasClaudeMd: false,
...over,
} as import('../src/types.js').ProjectDetail
}
it('shows the count beside the branch, not a bare dot', () => {
const root = renderProjectDetail(detail({ dirty: true, dirtyCount: 3 }), hooks(), cbs())
const chip = root.querySelector('.proj-detail-head .proj-sync-dirty')
expect(chip).not.toBeNull()
expect(chip!.textContent).toContain('3')
expect(root.querySelector('.proj-dirty')).toBeNull()
})
it('shows nothing for a clean tree', () => {
const root = renderProjectDetail(detail({ dirty: false, dirtyCount: 0 }), hooks(), cbs())
expect(root.querySelector('.proj-sync-dirty')).toBeNull()
expect(root.querySelector('.proj-dirty')).toBeNull()
})
it('falls back to the bare dot when the server sent no count', () => {
const root = renderProjectDetail(detail({ dirty: true }), hooks(), cbs())
expect(root.querySelector('.proj-dirty')).not.toBeNull()
})
})
describe('makeWorktreeRow — two-line card (w6 design)', () => {
const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' }
it('puts the path on its own line so it never competes with the chips', () => {
const row = makeWorktreeRow(wt, undefined, { sync: { upstream: 'o/d', ahead: 9 } })
const main = row.querySelector('.proj-wt-main')!
expect(main.querySelector('.proj-wt-top .proj-wt-branch')).not.toBeNull()
expect(main.querySelector('.proj-wt-path')!.textContent).toBe('/p/repo')
// The path must NOT sit inside the chip row.
expect(row.querySelector('.proj-wt-top .proj-wt-path')).toBeNull()
})
it('offers Open when a handler is wired, and calls it with the worktree', () => {
const onOpen = vi.fn()
const row = makeWorktreeRow(wt, { onRemove: vi.fn(), onOpen })
const btn = row.querySelector('.proj-wt-open') as HTMLButtonElement
expect(btn).not.toBeNull()
btn.click()
expect(onOpen).toHaveBeenCalledWith(wt)
})
it('renders no Open button when no handler is wired', () => {
expect(makeWorktreeRow(wt, { onRemove: vi.fn() }).querySelector('.proj-wt-open')).toBeNull()
})
it('marks the current worktree so it reads as "you are here"', () => {
expect(makeWorktreeRow(wt).classList.contains('proj-wt-row-current')).toBe(true)
expect(
makeWorktreeRow({ ...wt, isCurrent: false }).classList.contains('proj-wt-row-current'),
).toBe(false)
})
})