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:
@@ -106,6 +106,15 @@ export function renderGitLog(container: HTMLElement, log: GitLogResult): void {
|
||||
container.append(el('div', 'proj-empty', 'No commits yet.'))
|
||||
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')
|
||||
// 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
|
||||
|
||||
Binary file not shown.
177
public/style.css
177
public/style.css
@@ -1560,26 +1560,74 @@ body {
|
||||
}
|
||||
|
||||
/* ── w6/G3: the project detail sync band ──────────────────────────────────────
|
||||
Semantic colours here are deliberately NOT the accent: --ok reads "verified,
|
||||
ignore this" and is the one state that may look reassuring, so it must never
|
||||
be reachable from a stale or unknowable comparison (see makeSyncBand). */
|
||||
Four labelled cells, per docs/mockups/project-detail-git.html. The captions
|
||||
and footnotes are load-bearing, not decoration: they are what tells a reader
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
margin: 10px 0 4px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-sunk, rgba(0, 0, 0, 0.22));
|
||||
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 {
|
||||
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11.5px;
|
||||
color: var(--fg-dim, #a8a299);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.proj-sync-chip {
|
||||
@@ -1594,12 +1642,10 @@ body {
|
||||
|
||||
.proj-sync-ahead {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.proj-sync-behind {
|
||||
color: var(--fg-dim, #a8a299);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--fg-faint, #6f6a61);
|
||||
}
|
||||
|
||||
/* The one reassuring state. Reachable only with an upstream AND a fresh fetch. */
|
||||
@@ -1614,6 +1660,11 @@ body {
|
||||
.proj-sync-noupstream,
|
||||
.proj-sync-detached {
|
||||
color: #cf6b4f;
|
||||
}
|
||||
|
||||
.proj-sync-chip.proj-sync-stale,
|
||||
.proj-sync-noupstream,
|
||||
.proj-sync-detached {
|
||||
background: rgba(207, 107, 79, 0.14);
|
||||
border-color: rgba(207, 107, 79, 0.36);
|
||||
}
|
||||
@@ -1621,10 +1672,98 @@ body {
|
||||
.proj-sync-dirty {
|
||||
color: var(--amber);
|
||||
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 {
|
||||
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-size: 11.5px;
|
||||
cursor: pointer;
|
||||
@@ -1635,14 +1774,14 @@ body {
|
||||
color: var(--fg, #ece9e3);
|
||||
}
|
||||
|
||||
.proj-sync-fetch:hover:not(:disabled) {
|
||||
.proj-wt-open:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.proj-sync-fetch:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
.proj-wt-open:focus-visible {
|
||||
outline: 2px solid var(--fg, #ece9e3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── w6/G4: unpushed commits + the upstream boundary ───────────────────────── */
|
||||
|
||||
@@ -211,3 +211,25 @@ describe('renderGitLog — unpushed boundary (w6 G4)', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -777,18 +777,42 @@ describe('makeSyncBand (w6 G3)', () => {
|
||||
expect(b.textContent).toContain('origin/develop')
|
||||
})
|
||||
|
||||
it('counts dirty files instead of showing a bare dot', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
|
||||
dirtyCount: 3,
|
||||
})!
|
||||
expect(b.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
|
||||
// The design puts the dirty count on the title line, not in the band — the
|
||||
// band is about the remote, the working tree is not. Asserted at the header
|
||||
// level in the renderProjectDetail block below.
|
||||
it('keeps the working-tree count out of the band', () => {
|
||||
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', () => {
|
||||
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
|
||||
dirtyCount: 0,
|
||||
})!
|
||||
expect(b.querySelector('.proj-sync-dirty')).toBeNull()
|
||||
// ── design fidelity: the captions and footnotes are load-bearing ──────────
|
||||
it('labels every cell, so a number is never shown without saying what it is', () => {
|
||||
const b = band({ upstream: 'origin/develop', ahead: 3, behind: 0, lastFetchMs: STALE })!
|
||||
const captions = Array.from(b.querySelectorAll('.proj-sync-k')).map((n) =>
|
||||
(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()
|
||||
})
|
||||
})
|
||||
|
||||
/* ── 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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user