diff --git a/public/git-log.ts b/public/git-log.ts index 54bc11a..e3a4994 100644 --- a/public/git-log.ts +++ b/public/git-log.ts @@ -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 diff --git a/public/projects.ts b/public/projects.ts index 8dafee2..c76b591 100644 Binary files a/public/projects.ts and b/public/projects.ts differ diff --git a/public/style.css b/public/style.css index b61e021..cc93923 100644 --- a/public/style.css +++ b/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 ───────────────────────── */ diff --git a/test/git-log.test.ts b/test/git-log.test.ts index 6a97092..97930f5 100644 --- a/test/git-log.test.ts +++ b/test/git-log.test.ts @@ -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() + }) +}) diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index 2484035..d39af2b 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -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 = {}) { + 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) + }) +})