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.'))
|
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.
177
public/style.css
177
public/style.css
@@ -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 ───────────────────────── */
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user