fix(projects): close the design gaps in the git panel

An audit of the project-detail panel against docs/mockups/project-detail-git.html
turned up 59 differences. The ones that changed what you see:

Controls that had class names but no rules, so they rendered as raw OS widgets:
- .proj-wt-form had no layout at all — the branch input and Create Worktree
  button sat flush together, and the button jumped to its own line the moment a
  validation error appeared between them.
- .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure
  rendered as 16px near-white body text, indistinguishable from content.
- .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either.
- .proj-fanout-mode never reset `appearance`, so WebKit kept its own light
  dropdown — the one control in the panel that stayed a native grey widget.
- --danger was never defined, so every validation message fell back to an
  off-palette red.

Cascade bugs — a later rule at equal specificity was silently winning:
- .proj-wt-row-current's amber wash and border were completely dead. "You are
  here" rendered identically to every other row.
- .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a
  branch name the largest text on the page. Now 13.5px mono.

Layout:
- .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows
  between 721px and 1000px — at 900px the Fetch button dropped alone onto a
  second row. One cell now absorbs the shrink so the band stays a single row
  down to the 720px column break.
- Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on
  every row instead of only unpushed ones. Previously no two lines agreed on
  where the sha started, so the unpushed group did not read as a group.
- Pushed commit subjects step down to --text-dim, which is what makes the
  upstream boundary mean anything.

Honesty of the sync band — the design's two hard rules:
- Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because
  `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —"
  and are marked unverified. Green requires real zeros and a fresh fetch.
- A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the
  default state, the largest glyph in the band was almost always red and the
  panel read as "this repo is broken". The doubt now sits on the `unverified`
  chip and the footnote, never on the number.

Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex
literals, one chip language across the panel, section headings as tracked caps
with the count as a separate lighter element, and a ≤720px rule that drops the
age column — placed after the base rule, since a media query adds no specificity.

Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band
holds one row down to 721px and stacks below it, commit columns align on every
row, and nothing overflows or scrolls the page horizontally.
This commit is contained in:
Yaojia Wang
2026-07-29 20:35:07 +02:00
parent c3613c2fae
commit 950d2298a1
4 changed files with 288 additions and 83 deletions

View File

@@ -87,12 +87,16 @@ function relTime(ms: number): string {
* individual rows. */
function renderCommitRow(c: CommitLogEntry): HTMLElement {
const row = el('div', 'proj-commit-row')
// The mark cell is rendered on EVERY row, empty when the commit is pushed.
// Appending it only for unpushed rows left the grid's first track unoccupied on
// pushed rows, so the sha, age and subject columns shifted between lines and
// the unpushed group stopped reading as a block.
const mark = el('span', 'proj-commit-mark', c.unpushed === true ? '↑' : '')
if (c.unpushed === true) {
row.classList.add('proj-commit-unpushed')
const mark = el('span', 'proj-commit-mark', '↑')
mark.title = 'Not pushed yet'
row.append(mark)
}
row.append(mark)
row.append(el('span', 'proj-commit-hash', c.hash))
row.append(el('span', 'proj-commit-time', `${relTime(c.at)} ago`))
row.append(el('span', 'proj-commit-subject', c.subject)) // attacker-influenced → textContent

Binary file not shown.

View File

@@ -2,6 +2,7 @@
:root {
/* palette (design tokens) — Amber 琥珀金 theme (warm-neutral base + gold accent) */
--sunk: #0c0b09; /* recessed well — BELOW --bg, for data sunk into the page */
--bg: #100f0d; /* terminal / deepest surface — warm near-neutral */
--surface-1: #181613; /* tab bar, key bar */
--surface-2: #1f1c17; /* tabs, buttons, cards */
@@ -18,6 +19,16 @@
--green: #46d07f; /* connection-status: connected */
--amber: #f5b14c; /* connection-status: connecting */
--red: #ff6b6b; /* connection-status: disconnected / error */
/* Git-panel semantics (docs/mockups/project-detail-git.html). Deliberately NOT
* --accent: the accent means "work sitting on this machine", so a state colour
* must never borrow it. --ok is the only green and is reachable from exactly
* one state (ahead 0, behind 0, freshly fetched) — see the sync band. */
--warn: #cf6b4f; /* stale / no upstream / detached / validation error */
--ok: #7d9b76; /* verified in sync — the ONLY green */
--dirty: #e0975a; /* uncommitted changes */
--mono-font: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
--radius: 9px;
--radius-lg: 14px;
@@ -1568,23 +1579,33 @@ body {
.proj-syncband {
display: flex;
align-items: stretch;
margin: 10px 0 4px;
background: var(--surface-1);
margin: 16px 0 4px;
background: var(--sunk);
border: 1px solid var(--border);
border-radius: 9px;
border-radius: var(--radius);
overflow: hidden;
flex-wrap: wrap;
}
/* No flex-wrap. Wrapping produced ragged half-empty rows between 721px and
1000px — at 900px the Fetch cell dropped alone onto a second row. The cells
hold their natural width and only the footnote cell gives ground, so the band
stays one row all the way down to the 720px column break. */
.proj-sync-cell {
display: flex;
flex-direction: column;
gap: 3px;
padding: 11px 18px;
padding: 12px 18px;
min-width: 0;
flex: none;
border-right: 1px solid var(--border);
}
/* The one cell carrying a long sentence — it absorbs the shrink so the upstream
name and the Fetch button are never squeezed or clipped. */
.proj-sync-cell-flex {
flex: 0 1 auto;
}
.proj-sync-cell:last-child {
border-right: 0;
}
@@ -1623,7 +1644,7 @@ body {
}
.proj-sync-sub-bad {
color: #cf6b4f;
color: var(--warn);
}
.proj-sync-upstream {
@@ -1631,26 +1652,40 @@ body {
}
.proj-sync-chip {
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
border-radius: 5px;
padding: 2px 8px;
border-radius: 6px;
padding: 3px 9px;
white-space: nowrap;
flex: none;
border: 1px solid transparent;
}
/* Accent means "there is work sitting only on this machine". A zero is not that,
so it drops to the faint role rather than glowing gold at you. */
.proj-sync-ahead {
color: var(--accent);
}
/* Same class, two jobs: on the band's big number it is bare coloured text, but
in a worktree row it is a chip and needs the fill every other chip has. */
.proj-sync-chip.proj-sync-ahead {
background: var(--accent-soft);
border-color: rgba(227, 166, 74, 0.36);
}
.proj-sync-behind {
color: var(--text-dim);
}
.proj-sync-zero {
color: var(--text-faint);
}
/* The one reassuring state. Reachable only with an upstream AND a fresh fetch. */
.proj-sync-ok {
color: #7d9b76;
color: var(--ok);
background: rgba(125, 155, 118, 0.13);
border-color: rgba(125, 155, 118, 0.3);
}
@@ -1659,7 +1694,7 @@ body {
.proj-sync-stale,
.proj-sync-noupstream,
.proj-sync-detached {
color: #cf6b4f;
color: var(--warn);
}
.proj-sync-chip.proj-sync-stale,
@@ -1670,11 +1705,12 @@ body {
}
.proj-sync-dirty {
color: var(--amber);
font-family: var(--mono-font);
color: var(--dirty);
background: rgba(224, 151, 90, 0.13);
border: 1px solid rgba(224, 151, 90, 0.3);
border-radius: 5px;
padding: 2px 8px;
border-radius: 6px;
padding: 3px 9px;
font-size: 11.5px;
white-space: nowrap;
flex: none;
@@ -1721,14 +1757,17 @@ body {
}
}
/* ── w6: unpushed count beside the commit-list heading ───────────────────── */
.proj-commitlog-count {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
/* ── w6: counts sitting on a section heading ─────────────────────────────────
The heading is tracked-out uppercase; a count is data and opts out of both,
so it reads as a qualifier rather than more heading. */
.proj-commitlog-count,
.proj-section-count {
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
color: var(--accent);
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
letter-spacing: 0.04em;
margin-left: 10px;
}
@@ -1736,9 +1775,14 @@ body {
/* NOTE: the row's own display/grid lives with the ORIGINAL .proj-wt-row rule
further down. Declaring it twice let the later copy silently win and the
Open button never reached the right edge. One rule per selector. */
.proj-wt-row-current {
/* Two classes, not one: the plain `.proj-wt-row` rule lives further down the file
and its `background`/`border` SHORTHANDS were silently winning at equal
specificity, so "you are here" rendered identically to every other row. The
gradient must land on --surface-2 (the card colour), not `transparent`, or the
panel background shows through the right 54% of the card. */
.proj-wt-row.proj-wt-row-current {
border-color: rgba(227, 166, 74, 0.36);
background: linear-gradient(90deg, var(--accent-soft), transparent 46%);
background: linear-gradient(90deg, var(--accent-soft), var(--surface-2) 46%);
}
.proj-wt-main {
@@ -1762,7 +1806,13 @@ body {
flex: none;
}
.proj-wt-open {
/* The design has exactly two button roles: filled accent for the action that
commits a form (Fetch, Create Worktree), and this ghost for anything that only
opens or reveals. Everything in a worktree's action rail is a ghost — including
the ✕, which previously had no rule at all and rendered as a raw grey OS button. */
.proj-wt-open,
.proj-wt-remove,
.proj-wt-prune {
font: inherit;
font-size: 11.5px;
cursor: pointer;
@@ -1770,24 +1820,48 @@ body {
border-radius: 6px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text);
color: var(--text-dim);
}
.proj-wt-open:hover {
.proj-wt-open:hover,
.proj-wt-remove:hover,
.proj-wt-prune:hover {
border-color: var(--accent);
color: var(--accent);
}
.proj-wt-open:focus-visible {
.proj-wt-open:focus-visible,
.proj-wt-remove:focus-visible,
.proj-wt-prune:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
/* Square icon variant — same role, just not a word. Height matches Open so the
two sit flush in the rail. */
.proj-wt-remove {
width: 26px;
padding: 0;
line-height: 24px;
text-align: center;
flex: none;
}
.proj-wt-remove:hover {
border-color: var(--red);
color: var(--red);
}
/* Destructive and rarely wanted — it sits under the list, not in a row. */
.proj-wt-prune {
margin-top: 8px;
}
/* ── w6/G4: unpushed commits + the upstream boundary ───────────────────────── */
.proj-commit-unpushed {
position: relative;
background: linear-gradient(90deg, var(--accent-soft), transparent 55%);
border-radius: 4px;
border-radius: 5px;
}
.proj-commit-unpushed::before {
@@ -1801,11 +1875,12 @@ body {
background: var(--accent);
}
/* Fills its own 22px grid track — sizing it here would knock the columns out of
alignment again. Pushed rows render an empty mark so the track still exists. */
.proj-commit-mark {
font-family: var(--mono-font);
color: var(--accent);
font-size: 11px;
flex: none;
width: 12px;
font-size: 12px;
text-align: center;
}
@@ -1813,16 +1888,20 @@ body {
.proj-commit-boundary {
display: flex;
align-items: center;
gap: 10px;
margin: 7px 0 5px;
gap: 12px;
margin: 9px 0 7px;
padding-left: 8px;
}
.proj-commit-boundary-label {
font-size: 10.5px;
font-family: var(--mono-font);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-dim);
background: var(--sunk);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
padding: 3px 11px;
white-space: nowrap;
flex: none;
}
@@ -1833,18 +1912,30 @@ body {
background: var(--border-strong);
}
/* w6/G7: running sessions attributed to one worktree row. */
/* w6/G7: running sessions attributed to one worktree row. Muted mono text with a
live dot, not a filled pill — a session count is ambient status, and an accent
fill here competed with the ↑ backlog chip beside it for the same attention. */
.proj-wt-sessions {
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
color: var(--accent);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 7px;
color: var(--text-dim);
white-space: nowrap;
flex: none;
}
.proj-wt-sessions::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
/* W3(b): cost chip in the per-tab telemetry gauge, warn-styled over budget. */
.tg-cost-warn {
color: var(--red);
@@ -1862,36 +1953,63 @@ body {
.proj-commitlog-list {
display: flex;
flex-direction: column;
gap: 2px;
}
/* A grid, not a flex row: every column has to land in the same place on every
line, or the unpushed block above the boundary does not read as a block. Flex
sized each row to its own content, and the ↑ mark only existed on unpushed
rows, so no two lines agreed on where the sha started. */
.proj-commit-row {
display: flex;
display: grid;
grid-template-columns: 22px 74px 56px 1fr;
align-items: baseline;
gap: 8px;
gap: 10px;
padding: 4px 8px;
font-size: 12px;
min-width: 0;
}
.proj-commit-hash {
font-family: Menlo, Consolas, monospace;
color: var(--accent);
flex: none;
font-family: var(--mono-font);
color: var(--accent-2);
}
.proj-commit-time {
font-family: var(--mono-font);
font-size: 11.5px;
color: var(--text-faint);
flex: none;
text-align: right;
white-space: nowrap;
}
.proj-commit-subject {
font-size: 13.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* Already pushed — history, not your working set. Stepping it down is what makes
the boundary line mean something. */
.proj-commit-row:not(.proj-commit-unpushed) .proj-commit-subject {
color: var(--text-dim);
}
.proj-commit-more {
font-size: 11px;
font-family: var(--mono-font);
font-size: 11.5px;
color: var(--text-faint);
margin-top: 4px;
padding: 9px 8px 0;
}
/* Must sit AFTER .proj-commit-row: a media query adds no specificity, so an
earlier block at the same specificity simply loses. Dropping the age column
while the row still declared four tracks pushed the subject into the 56px age
track and squeezed it to nothing. */
@media (max-width: 720px) {
.proj-commit-row {
grid-template-columns: 18px 66px 1fr;
}
/* On a phone the message is the only column worth the width. */
.proj-commit-time {
display: none;
}
}
/* W3(c): "while you were away" reconnect banner (compact, dismissible top bar). */
@@ -2236,14 +2354,30 @@ body {
color: var(--text-faint);
word-break: break-all;
}
/* Section headings are wayfinding, not emphasis — wide tracking at low weight,
the way the design draws them. Bold caps competed with the content below. */
.proj-section-title {
margin: 22px 0 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
margin: 22px 0 9px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-faint);
}
/* New Worktree form — had no container rule at all, so the input and its submit
button sat flush against each other and the button jumped to its own line the
moment a validation error appeared between them. Mirrors the fan-out form. */
.proj-wt-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.proj-wt-branch-input {
width: 100%;
}
.proj-wt-submit {
align-self: flex-start;
}
/* W5 fan-out form — sibling of the New Worktree form. */
.proj-fanout-form {
display: flex;
@@ -2270,17 +2404,19 @@ body {
flex: 1 1 160px;
min-width: 120px;
}
.proj-fanout-error {
color: var(--danger, #e5534b);
/* --danger was never defined, so every validation message fell back to an
off-palette red. These three are the same message in three places and now say
so — previously the two worktree ones had no rule at all and rendered as 16px
near-white body text, indistinguishable from content. */
.proj-fanout-error,
.proj-wt-error,
.proj-wt-actions-error {
color: var(--warn);
font-size: 12px;
}
.proj-fanout-submit {
align-self: flex-start;
}
.proj-fanout-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Projects panel controls ──────────────────────────────────────────────────
These eight controls shipped with class names but no visual rules, so they
rendered as raw user-agent widgets: white boxes and grey buttons in a dark
@@ -2323,7 +2459,7 @@ body {
/* A branch name is an identifier, so it reads as one. */
.proj-wt-branch-input,
.proj-fanout-branch {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--mono-font);
font-size: 12.5px;
}
@@ -2332,19 +2468,36 @@ body {
font-variant-numeric: tabular-nums;
}
/* Without appearance:none WebKit keeps its own light control, so this was the one
widget in the panel that stayed a native grey dropdown. The arrow has to be
redrawn by hand once the native one is gone. */
.proj-fanout-mode {
cursor: pointer;
appearance: none;
-webkit-appearance: none;
padding-right: 30px;
background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%),
linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
background-position:
right 14px top 50%,
right 9px top 50%;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
}
/* Role 1 — filled accent, for the action that commits a form. Matches the Fetch
button exactly (padding 7/15, radius 7), which already implements the spec. */
.proj-wt-submit,
.proj-fanout-submit {
font: inherit;
font-size: 12.5px;
font-weight: 560;
cursor: pointer;
padding: 8px 15px;
padding: 7px 15px;
border: 0;
border-radius: 8px;
border-radius: 7px;
background: var(--accent);
color: var(--on-accent);
}
@@ -2354,15 +2507,22 @@ body {
filter: brightness(1.08);
}
.proj-wt-submit:disabled,
.proj-fanout-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Role 2 — ghost, for anything that only reveals or toggles. */
.proj-diff-toggle {
font: inherit;
font-size: 12.5px;
cursor: pointer;
padding: 7px 14px;
border-radius: 8px;
padding: 7px 15px;
border-radius: 7px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text);
color: var(--text-dim);
}
.proj-diff-toggle:hover {
@@ -2441,7 +2601,7 @@ body {
.proj-wt-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
}
.proj-wt-row {
/* w6: two-line card — content column, then the actions rail pinned right. */
@@ -2449,37 +2609,49 @@ body {
grid-template-columns: 1fr auto;
align-items: center;
gap: 14px;
padding: 11px 14px;
padding: 12px 15px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
}
/* A branch name is an identifier, so it reads as one. Without these it inherited
the 16px user-agent default in the UI sans — the largest text on the page. */
.proj-wt-branch {
font-family: var(--mono-font);
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
/* One chip language across the panel: recessed fill, hairline border, mono. */
.proj-wt-tag {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
font-family: var(--mono-font);
font-size: 11px;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-dim);
padding: 2px 8px;
border-radius: 6px;
background: var(--sunk);
border: 1px solid var(--border);
color: var(--text-faint);
}
/* "Current" is orientation, not an alarm — muted, the same as every other tag.
The row's amber wash is what marks where you are. */
.proj-wt-tag-current {
background: var(--accent);
color: var(--on-accent);
background: var(--accent-soft);
border-color: rgba(227, 166, 74, 0.36);
color: var(--accent);
}
.proj-wt-path {
/* No margin-left:auto — that was for the old single-line row, where it pushed
the path to the far right. Inside the two-line card it sits directly under
the branch it belongs to, so auto margin would strand it across the card. */
font-family: Menlo, Consolas, monospace;
font-size: 11px;
the branch it belongs to, so auto margin would strand it across the card.
One line with an ellipsis: a wrapped absolute path re-flows the whole list
and is no more readable for it. */
font-family: var(--mono-font);
font-size: 11.5px;
color: var(--text-faint);
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Detailed session rows */
.proj-detail-sessions {

View File

@@ -752,7 +752,34 @@ describe('makeSyncBand (w6 G3)', () => {
const ahead = b.querySelector('.proj-sync-ahead')!
expect(ahead.textContent).toContain('9')
expect(ahead.classList.contains('proj-sync-stale')).toBe(false)
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-stale')).toBe(true)
})
it('carries staleness on the chip, never by recolouring the count', () => {
const b = band({ upstream: 'origin/main', ahead: 9, behind: 2, lastFetchMs: STALE })!
// Being behind is information, not an error. Painting the largest glyph in
// the band warn-red made every un-fetched repo look broken.
for (const big of b.querySelectorAll('.proj-sync-big')) {
expect(big.classList.contains('proj-sync-stale')).toBe(false)
}
expect(b.querySelector('.proj-sync-chip.proj-sync-stale')!.textContent).toBe('unverified')
})
it('does not paint a zero in the accent — accent means work is waiting', () => {
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
expect(b.querySelector('.proj-sync-behind')).toBeNull()
expect(b.querySelectorAll('.proj-sync-zero')).toHaveLength(2)
})
it('refuses green when a count is unknown rather than treating it as zero', () => {
// `undefined` is not 0 — it means the count could not be computed. Folding
// the two together let a repo whose counts failed to resolve claim it was
// in sync, which is the one thing the design forbids.
const b = band({ upstream: 'origin/main', ahead: undefined, behind: undefined, lastFetchMs: FRESH })!
expect(b.querySelector('.proj-sync-ok')).toBeNull()
expect(b.querySelector('.proj-sync-chip.proj-sync-stale')).not.toBeNull()
expect(b.textContent).toContain('↑ —')
expect(b.textContent).toContain('↓ —')
})
it('says "no upstream" instead of inventing counts, and is not green', () => {
@@ -812,7 +839,9 @@ describe('makeSyncBand (w6 G3)', () => {
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)
// behind is 0, so it takes the neutral zero role — still at display size.
expect(b.querySelector('.proj-sync-zero')!.classList.contains('proj-sync-big')).toBe(true)
expect(b.querySelectorAll('.proj-sync-big')).toHaveLength(2)
})
})