fix(projects): build the git panel the way the design specifies

The shipped panel was functionally right — every number matched git — but it
was not the approved design. I implemented from the plan's semantics (which
state is green, when to flag stale) and let the mock's layout drift, and
nothing caught it: the tests asserted behaviour, and no assertion covered
structure. Three deviations, now closed against docs/mockups/project-detail-git.html.

1. The sync band was compressed into one row of chips. Restored to the four
   labelled cells: Upstream / To push / To pull / Fetch, with display-size
   numerals and a footnote under each count. The footnotes are load-bearing,
   not decoration — "Last fetched 19d ago — this number cannot be trusted" is
   what tells a reader WHY the zero is suspect. Compressed to
   "fetched 19d ago", the causal link was left for the reader to infer, and
   not having to infer it is the entire point of the panel. The unverified
   state is a chip on the count again, so the doubt attaches to the digit.

2. The working-tree count sat inside the band. Moved to the title line beside
   the branch, where the design puts it: the band is about the remote, and the
   dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot
   remains as the fallback when a server sends no count.

3. Worktree rows were the old single line with the path right-aligned against
   the chips. Rebuilt as the two-line card the design calls for — identity and
   state on top, path underneath — plus the Open button, which was specified
   and simply missing, so a worktree row could be read but not entered. Open
   reuses the detail view pointed at the worktree path: a linked worktree is a
   project directory, so this needs no new concept and no new route.

Also added the unpushed count beside the commit-list heading, so the number is
readable before the eye walks the rows hunting for rails.

Tests now assert structure, not just behaviour: cell captions exist, the stale
footnote says why, counts render at display size, the path is outside the chip
row, and Open fires with its worktree. That is the gap that let the drift
through, so it is the gap that is now covered.

Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
This commit is contained in:
Yaojia Wang
2026-07-29 18:22:50 +02:00
parent e81c426329
commit f711db9315
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.'))
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.

View File

@@ -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 ───────────────────────── */