Files
web-terminal/public/style.css
Yaojia Wang 38274a5271 fix(projects): style the diff panel — 52 classes had no CSS at all
The project-detail Git panel's diff viewer emitted 52 class names that
style.css had no rule for, so it rendered as browser defaults: Working /
Staged / the base picker / ✕ Close as native OS buttons on a dark warm
panel, and a diff body with no monospace font and no +/- colouring at
all. The user's screenshot showed the toolbar; the audit (literal AND
template-literal class names) found the rest.

Adds the B1 diff-viewer block and the proj-*/tl-* rules, all on :root
tokens, following the file's existing two-button-role doctrine.

Also fixes, in passing:
- .df-commit-btn hardcoded #1a1712 where THEME.md mandates
  var(--on-accent) for text on a gold background
- .proj-pr-hidden — a "hidden" state class with no rule, so it never hid
- .proj-dot-stuck — ClaudeStatus has 'stuck' but only four of the five
  dots were styled, so a stuck session was the same colour as "no signal"
- --warn-wash: a derived token for the deletion wash, documented in
  THEME.md, so a re-skin moves the wash with --warn

Notable decisions, recorded in the CSS comments:
- .proj-tl-container gets NO height cap. Every capping mechanism tried
  (overflow-y:auto, mask-image, an ::after fade, a :has() row gate) cost
  more than the height it saved — the 5s wipe-and-re-append render loop
  cannot host a scroll position, and mask-clip:border-box ate the well's
  own border. It grew freely before this CSS existed; maxEvents=50 bounds it.
- The horizontal scroller stays per-hunk on .df-lines. Hoisting it to
  .df-file would drag the file path out of view when you scroll right.
- .df-file > .df-file-header keeps a smaller unscoped padding: the
  terminal page's .approval-preview also calls renderDiffFile().

Verified against the real DOM in a browser at 900px and 390x844 via a
throwaway harness (removed before commit), not from the CSS text alone.
typecheck exit 0; 2216 unit + 27 e2e tests pass.
2026-07-30 22:34:40 +02:00

4075 lines
112 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Web Terminal — modern dark UI */
: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 */
--surface-3: #2a2620; /* hover / active */
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.14);
--text: #ece9e3;
--text-dim: #a8a299;
--text-faint: #6f6a61;
--accent: #e3a64a; /* amber gold */
--accent-2: #c9892f; /* deeper gold (gradients / hover) */
--accent-soft: rgba(227, 166, 74, 0.15);
--on-accent: #1a1305; /* text/icon color on a gold accent background (contrast) */
--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 */
/* Diff deletion wash. DERIVED from --warn, never a hand-copy of its channels: as a
* literal it froze when --warn was re-skinned, leaving the wash one hue and the sign
* bar and glyph beside it (both var(--warn)) another — a two-hue deletion row inside
* one rule. No rgba fallback either: a fallback pair is that same drift by another
* name, and this app already requires an engine with color-mix(). */
--warn-wash: color-mix(in srgb, var(--warn) 24%, transparent);
--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;
--tabbar-h: 40px;
--keybar-h: 46px;
/* iOS notch / home-indicator clearance. index.html sets viewport-fit=cover,
* so the layout spans the physical screen edges; without these the top bar
* hides under the status bar and the bottom key-bar covers terminal output.
* Falls back to 0px on platforms without safe areas. */
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
@media (pointer: coarse) {
:root {
--tabbar-h: 48px;
--keybar-h: 60px;
}
}
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: var(--bg);
color: var(--text);
font-family: var(--ui-font);
overflow: hidden;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--surface-3) transparent;
}
/* ── Tab bar ─────────────────────────────────────────────────────── */
#tabbar {
position: fixed;
inset: 0 0 auto 0;
box-sizing: border-box;
height: calc(var(--tabbar-h) + var(--safe-t));
padding-top: var(--safe-t);
background: linear-gradient(180deg, #181a22, var(--surface-1));
border-bottom: 1px solid var(--border);
display: flex;
align-items: stretch;
z-index: 1001;
}
#tabs {
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 2px;
padding: 0 4px;
overflow-x: auto;
overflow-y: hidden;
}
#tabs::-webkit-scrollbar {
height: 0;
}
/* tabs as floating rounded chips */
.tab {
display: flex;
align-items: center;
gap: 2px;
height: calc(var(--tabbar-h) - 12px);
padding: 0 4px 0 10px;
border-radius: 8px;
color: var(--text-dim);
background: transparent;
cursor: pointer;
white-space: nowrap;
font-size: 13px;
user-select: none;
max-width: 220px;
transition:
background 0.12s ease,
color 0.12s ease;
}
.tab:hover {
background: var(--surface-2);
color: var(--text);
}
.tab.active {
background: var(--surface-3);
color: #fff;
box-shadow: inset 0 0 0 1px var(--border-strong);
}
.tab-dot {
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 8px;
flex: none;
background: var(--text-faint);
}
.dot-connected {
background: var(--green);
box-shadow: 0 0 6px rgba(70, 208, 127, 0.5);
}
.dot-connecting,
.dot-reconnecting {
background: var(--amber);
}
.dot-exited {
background: var(--red);
}
.tab-label {
padding-right: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-rename {
width: 120px;
margin: 0 4px;
padding: 3px 6px;
font: inherit;
font-size: 13px;
color: #fff;
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 6px;
outline: none;
}
/* unread / Claude-waiting accents */
.tab.unread .tab-dot {
box-shadow: 0 0 0 3px var(--accent-soft);
}
.tab.unread .tab-label {
color: #fff;
font-weight: 600;
}
.tab-claude {
margin-right: 4px;
font-size: 12px;
flex: none;
}
.tab.claude-waiting {
background: rgba(245, 177, 76, 0.14);
box-shadow: inset 0 0 0 1px rgba(245, 177, 76, 0.5);
}
.tab.dragging {
opacity: 0.4;
}
.tab.drag-over {
box-shadow: inset 3px 0 0 var(--accent);
}
.tab-close {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 3px 6px;
border-radius: 6px;
flex: none;
}
.tab-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
@media (pointer: fine) {
.tab:not(.active):not(:hover) .tab-close {
opacity: 0;
}
}
.tab-add {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 18px;
padding: 0 10px;
flex: none;
border-radius: 7px;
}
.tab-add:hover {
color: #fff;
background: var(--surface-2);
}
/* v0.6: Home (⌂) button — opens the Sessions/Projects chooser over open tabs. */
.tab-home {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 17px;
padding: 0 10px;
flex: none;
border-radius: 7px;
}
.tab-home:hover {
color: #fff;
background: var(--surface-2);
}
.tab-home.active {
color: var(--on-accent, #1a1408);
background: var(--accent);
}
/* toolbar */
#toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
border-left: 1px solid var(--border);
}
.toolbtn {
position: relative; /* for the hover tooltip */
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 7px 9px;
border-radius: 8px;
line-height: 1;
transition: background 0.12s ease, color 0.12s ease;
}
.toolbtn svg {
display: block;
width: 18px;
height: 18px;
}
.toolbtn:hover {
color: var(--accent);
background: var(--surface-2);
}
/* Hover tooltip (right-anchored so it never clips the screen edge). */
.toolbtn[data-tip]::after {
content: attr(data-tip);
position: absolute;
top: calc(100% + 6px);
right: 0;
white-space: nowrap;
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border-strong);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
z-index: 60;
}
.toolbtn[data-tip]:hover::after {
opacity: 1;
}
/* ── Terminal area ───────────────────────────────────────────────── */
#term {
position: absolute;
inset: calc(var(--tabbar-h) + var(--safe-t)) 0 calc(var(--keybar-h) + var(--safe-b)) 0;
background: var(--bg);
overflow: hidden;
}
/* Each pane lives in a .term-cell wrapper (header + terminal + optional inline
* approve). In single mode the cell fills #term absolutely (only the focused one
* is shown) so the classic one-pane look is unchanged; split layouts turn #term
* into a CSS grid and the cells flow into tracks. */
.term-cell {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.term-pane {
position: relative;
flex: 1 1 auto;
min-height: 0;
padding: 6px 8px 0;
box-sizing: border-box;
overflow: hidden;
}
/* Cell header + inline-approve footer are hidden in single mode. */
.cell-head,
.cell-approve {
display: none;
}
/* ── Split-grid layouts (desktop watch board) ────────────────────────── */
/* Shared cell chrome keys off the single `grid` marker so it is independent of
* how many concrete layouts exist; each `lay-*` only sets the grid template. */
#term.grid {
display: grid;
gap: 6px;
padding: 6px;
}
#term.lay-split-2 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
#term.lay-row-3 {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr;
}
#term.lay-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
#term.lay-grid-6 {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
#term.grid .term-cell {
position: relative;
inset: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
}
/* Keep the last terminal row clear of the cell border/focus ring in a grid.
* Root cause (measured): the base .term-pane is box-sizing:border-box, so
* getComputedStyle(pane).height — which xterm's FitAddon reads to compute rows —
* returns the PADDING-box height and double-counts the pane's own padding, packing
* one extra row that overruns into the border (overflow +6px). content-box makes
* that height report the CONTENT box, so FitAddon drops the phantom row and the 6px
* padding becomes real clearance. (Single mode keeps padding 0 so the terminal
* still fills to the key-bar.) */
#term.grid .term-pane {
box-sizing: content-box;
padding-bottom: 6px;
}
/* Maximized quadrant fills the whole grid as an ABSOLUTE overlay (taken out of
* grid flow so the siblings keep their placement — spanning grid tracks instead
* would shove auto-placed siblings into implicit rows, resizing their live PTYs).
* #term is a positioned containing block, so inset:0 covers the whole area. */
#term.grid .term-cell.maximized {
position: absolute;
inset: 0;
z-index: 4;
}
/* A tab dragged from the tab bar is hovering this quadrant. */
#term.grid .term-cell.drag-target {
outline: 2px dashed var(--accent);
outline-offset: -3px;
}
/* Draggable splitter handles overlaying the track boundaries (v3). */
.grid-gutter {
position: absolute;
z-index: 3;
touch-action: none;
}
.grid-gutter::after {
content: '';
position: absolute;
background: var(--border-strong);
border-radius: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.grid-gutter:hover::after,
.grid-gutter:active::after {
opacity: 1;
background: var(--accent);
}
.grid-gutter-col {
top: 0;
bottom: 0;
width: 12px;
transform: translateX(-50%);
cursor: col-resize;
}
.grid-gutter-col::after {
top: 15%;
bottom: 15%;
left: 50%;
width: 3px;
transform: translateX(-50%);
}
.grid-gutter-row {
left: 0;
right: 0;
height: 12px;
transform: translateY(-50%);
cursor: row-resize;
}
.grid-gutter-row::after {
left: 15%;
right: 15%;
top: 50%;
height: 3px;
transform: translateY(-50%);
}
#term.grid .cell-head {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 9px;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border-bottom: 1px solid var(--border);
font-size: 12px;
cursor: pointer;
user-select: none;
}
.cell-max {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 2px 4px;
border-radius: 5px;
}
.cell-max:hover {
color: var(--text);
background: var(--surface-3);
}
.cell-keep {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 2px 4px;
border-radius: 5px;
}
.cell-keep:hover {
color: var(--text);
background: var(--surface-3);
}
.cell-monitor-btn {
border: 0;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 2px 4px;
border-radius: 5px;
opacity: 0.7;
}
.cell-monitor-btn:hover {
color: var(--text);
background: var(--surface-3);
opacity: 1;
}
.cell-monitor-btn.active {
color: var(--accent);
opacity: 1;
}
/* Read-only monitor preview fills the cell below the header. */
.cell-monitor {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
position: relative;
padding: 6px 8px 0;
box-sizing: border-box;
}
.cell-name {
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-status {
margin-left: auto;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
.cell-status.cs-working {
color: var(--amber);
}
.cell-status.cs-waiting {
color: var(--accent);
}
.cell-status.cs-idle {
color: var(--green);
}
.cell-status.cs-stuck {
color: var(--red);
}
/* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */
#term.grid .term-cell.focused {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
var(--shadow);
}
/* Pending glow — a non-focused quadrant is waiting for approval. */
#term.grid .term-cell.pending:not(.focused) {
border-color: var(--amber);
animation: cell-pending 2.4s ease-in-out infinite;
}
@keyframes cell-pending {
0%,
100% {
box-shadow: 0 0 0 1px rgba(245, 177, 76, 0.35);
}
50% {
box-shadow:
0 0 0 1px rgba(245, 177, 76, 0.7),
0 0 26px -8px rgba(245, 177, 76, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
#term.grid .term-cell.pending {
animation: none;
}
}
/* Inline per-quadrant approve footer. */
#term.grid .cell-approve {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 9px;
background: rgba(245, 177, 76, 0.08);
border-top: 1px solid rgba(245, 177, 76, 0.35);
font-size: 11.5px;
}
.cell-approve-label {
color: var(--amber);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-approve-btns {
margin-left: auto;
display: flex;
gap: 6px;
}
.cell-approve-yes,
.cell-approve-no {
border: 0;
border-radius: 6px;
padding: 3px 11px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cell-approve-yes {
background: var(--green);
color: #06231a;
}
.cell-approve-no {
background: var(--surface-3);
color: var(--text-dim);
}
.cell-approve-yes:hover {
filter: brightness(1.1);
}
.cell-approve-no:hover {
color: var(--text);
}
/* Empty-slot placeholder (fewer sessions than the layout holds). */
.term-cell.slot-empty {
border: 1px dashed var(--border-strong);
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.02) 10px,
rgba(255, 255, 255, 0.02) 20px
);
align-items: center;
justify-content: center;
cursor: pointer;
}
.term-cell.slot-empty:hover {
border-color: var(--accent);
}
.slot-new {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: none;
border: 0;
color: var(--text-faint);
font-size: 12px;
font-family: var(--ui-font);
cursor: pointer;
}
.slot-new b {
font-size: 26px;
font-weight: 300;
line-height: 1;
color: var(--text-dim);
}
.term-cell.slot-empty:hover .slot-new {
color: var(--text-dim);
}
/* ── Grid-toggle segmented control (toolbar) ─────────────────────────── */
.grid-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 2px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
}
.grid-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.grid-toggle-btn:hover {
color: var(--text);
background: var(--surface-3);
}
.grid-toggle-btn[aria-pressed='true'] {
color: var(--on-accent);
background: var(--accent);
}
.grid-glyph {
display: grid;
gap: 1.5px;
width: 14px;
height: 12px;
}
.grid-glyph.gl-single {
grid-template-columns: 1fr;
}
.grid-glyph.gl-split-2 {
grid-template-columns: 1fr 1fr;
}
.grid-glyph.gl-row-3 {
grid-template-columns: 1fr 1fr 1fr;
}
.grid-glyph.gl-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.grid-glyph.gl-grid-6 {
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.grid-glyph i {
background: currentColor;
border-radius: 1px;
}
/* Touch tablets clear the 1024px width gate but need bigger tap targets — mirror
* the project's existing coarse-pointer bump for the tab/key bars. */
@media (pointer: coarse) {
.grid-toggle {
gap: 3px;
}
.grid-toggle-btn {
width: 36px;
height: 34px;
}
.cell-max,
.cell-monitor-btn {
padding: 6px 9px;
font-size: 15px;
}
}
/* ── Key bar (rounded chips) ─────────────────────────────────────── */
#keybar {
position: fixed;
inset: auto 0 0 0;
box-sizing: border-box;
height: calc(var(--keybar-h) + var(--safe-b));
background: var(--surface-1);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
gap: 5px;
padding: 0 6px var(--safe-b);
overflow-x: auto;
overflow-y: hidden;
z-index: 1000;
}
#keybar::-webkit-scrollbar {
height: 0;
}
#keybar button {
flex: 1 0 auto;
min-width: 50px;
height: calc(var(--keybar-h) - 12px);
padding: 0 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
font-family: var(--ui-font);
cursor: pointer;
border-radius: 8px;
transition:
background 0.1s ease,
transform 0.05s ease;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
#keybar .kb-key {
font-size: 13px;
line-height: 1.1;
}
#keybar .kb-cap {
font-size: 9px;
line-height: 1;
color: var(--text-faint);
letter-spacing: 0.02em;
}
#keybar button:hover {
background: var(--surface-3);
}
#keybar button:active {
transform: translateY(1px);
background: var(--surface-3);
}
#keybar button.keybar-btn-primary {
background: var(--accent-soft);
color: #f0d49a;
border-color: rgba(227, 166, 74, 0.4);
font-weight: 600;
min-width: 54px;
}
#keybar button.keybar-btn-primary:hover {
background: rgba(227, 166, 74, 0.26);
}
#keybar button.keybar-btn-primary .kb-cap {
color: rgba(240, 212, 154, 0.7);
}
/* ── Shared: overlays, cards, inputs, buttons ────────────────────── */
.overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
background: rgba(8, 9, 12, 0.6);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
#searchbox {
position: fixed;
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
right: 10px;
z-index: 1100;
display: flex;
gap: 5px;
padding: 7px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow);
}
.search-input {
width: 190px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
color: #fff;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
outline: none;
}
.search-input:focus {
border-color: var(--accent);
}
#searchbox button {
background: var(--surface-3);
border: 1px solid var(--border);
color: var(--text);
cursor: pointer;
border-radius: 7px;
padding: 0 10px;
}
#searchbox button:hover {
background: var(--accent-soft);
}
/* QR + share modals */
#qrmodal,
#sharemodal {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 9, 12, 0.6);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.qr-card {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: 22px;
text-align: center;
max-width: 90vw;
box-shadow: var(--shadow);
}
.qr-card canvas {
border-radius: 10px;
background: #fff;
padding: 8px;
}
.qr-title {
font-weight: 600;
margin-bottom: 14px;
}
.qr-url {
margin-top: 12px;
font-size: 13px;
color: var(--accent);
word-break: break-all;
}
.qr-note {
margin-top: 8px;
font-size: 12px;
color: var(--text-dim);
max-width: 240px;
}
.qr-close {
margin-top: 16px;
padding: 8px 20px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-3);
color: #fff;
cursor: pointer;
font: inherit;
}
.qr-close:hover {
background: var(--accent-soft);
}
/* Settings panel */
#settingspanel {
position: fixed;
top: calc(var(--tabbar-h) + var(--safe-t) + 8px);
right: 10px;
z-index: 1100;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 12px;
padding: 12px;
min-width: 220px;
box-shadow: var(--shadow);
color: var(--text);
}
.settings-row {
display: flex;
align-items: center;
gap: 6px;
margin: 8px 0;
}
.settings-label {
width: 48px;
color: var(--text-dim);
font-size: 13px;
}
.settings-opt {
background: var(--surface-3);
border: 1px solid var(--border);
color: var(--text);
border-radius: 7px;
padding: 5px 10px;
cursor: pointer;
font: inherit;
font-size: 12px;
transition: background 0.1s ease;
}
.settings-opt:hover {
background: var(--surface-1);
}
.settings-opt.active {
background: var(--accent);
color: var(--on-accent);
border-color: var(--accent);
}
.settings-size {
min-width: 22px;
text-align: center;
}
/* Dashboard + history + shortcuts share the centered-card look */
#dashboard,
#historymodal,
#shortcutsmodal {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 60px;
background: rgba(8, 9, 12, 0.6);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.dash-card,
.hist-card,
.sc-card {
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
min-width: 340px;
max-width: 92vw;
max-height: 72vh;
overflow-y: auto;
box-shadow: var(--shadow);
}
.sc-card {
width: 540px;
}
.dash-title,
.hist-title,
.sc-title {
padding: 14px 18px;
font-weight: 600;
color: #fff;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--surface-2);
}
/* Shortcut cheat-sheet */
.sc-group {
padding: 12px 18px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
}
.sc-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 18px;
}
.sc-key {
flex: none;
min-width: 110px;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
color: #fff;
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: 6px;
padding: 3px 8px;
text-align: center;
}
.sc-desc {
flex: 1 1 auto;
color: var(--text-dim);
font-size: 13px;
}
.sc-tag {
flex: none;
font-size: 10px;
color: var(--accent);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 6px;
}
.dash-row,
.hist-row {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 18px;
cursor: pointer;
color: var(--text);
border-bottom: 1px solid var(--border);
transition: background 0.1s ease;
}
.dash-row:hover,
.hist-row:hover {
background: var(--surface-3);
}
.dash-row.active {
box-shadow: inset 3px 0 0 var(--accent);
}
.dash-name {
flex: 1;
}
.dash-claude {
color: var(--text-dim);
font-size: 13px;
}
.dash-claude.pending {
color: var(--amber);
font-weight: 600;
}
.hist-empty {
padding: 18px;
color: var(--text-dim);
}
.hist-row {
cursor: default;
}
.hist-main {
flex: 1 1 auto;
min-width: 0;
}
.hist-proj {
color: #fff;
font-size: 13px;
}
.hist-prev {
color: var(--text-dim);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hist-resume {
flex: none;
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: 8px;
padding: 7px 15px;
cursor: pointer;
font: inherit;
font-weight: 500;
transition: background 0.1s ease;
}
.hist-resume:hover {
background: var(--accent-2);
}
/* Approval banner (H3) */
#approvalbar {
position: fixed;
inset: auto 0 calc(var(--keybar-h) + var(--safe-b)) 0;
z-index: 1050;
display: flex;
flex-wrap: wrap; /* W1: a preview row wraps below the label + buttons */
align-items: center;
gap: 10px;
padding: 10px 14px;
background: linear-gradient(180deg, rgba(245, 177, 76, 0.16), rgba(245, 177, 76, 0.08));
border-top: 1px solid rgba(245, 177, 76, 0.5);
color: #ffe0ad;
font-size: 14px;
}
.approval-label {
flex: 1 1 auto;
}
/* W1: approval preview (command/diff) — its own full-width row, scrollable. */
.approval-preview {
flex: 1 1 100%;
order: 3; /* below the label + buttons regardless of insertion order */
max-height: 30vh;
overflow: auto;
overflow-x: auto;
border: 1px solid rgba(245, 177, 76, 0.35);
border-radius: 8px;
background: rgba(0, 0, 0, 0.35);
padding: 8px 10px;
}
.approval-cmd {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: Menlo, Consolas, monospace;
font-size: 13px;
color: #f4f5f7;
}
.approval-truncated {
margin-top: 6px;
font-size: 12px;
opacity: 0.7;
}
#approvalbar button {
flex: none;
border: none;
border-radius: 8px;
padding: 8px 16px;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.approval-yes {
background: var(--green);
color: #06210f;
}
.approval-no {
background: var(--red);
color: #2a0808;
}
/* ── Launcher (home session chooser, shown when no tab is open) ──── */
.launcher {
position: absolute;
inset: 52px 0 0 0; /* mobile: reserve a row for the stacked .home-seg (desktop overrides to 0) */
overflow-y: auto;
padding: 22px 18px 40px;
box-sizing: border-box;
background: var(--bg);
}
.launcher-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
margin-bottom: 18px;
}
.launcher-title {
font-size: 20px;
font-weight: 700;
color: #fff;
}
.launcher-sub {
flex: 1 1 auto;
color: var(--text-dim);
font-size: 13px;
}
/* A: recoverable (orphan) tmux sessions — a secondary section under the live grid,
separated by a rule so it never competes with what you are actually working on.
Hidden entirely when the list is empty. */
.launcher-orphans {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.launcher-orphans-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 16px;
margin-bottom: 16px;
}
.launcher-orphans-title {
font-size: 11px;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-faint);
}
.launcher-orphans-sub {
flex: 1 1 auto;
color: var(--text-dim);
font-size: 13px;
}
/* Destructive, so it is the ghost role rather than a filled button — the design
reserves the accent fill for the action you actually came to perform. */
.launcher-cleanup {
font: inherit;
font-size: 12.5px;
cursor: pointer;
padding: 7px 15px;
border-radius: 7px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text-dim);
flex: none;
}
.launcher-cleanup:hover {
border-color: var(--red);
color: var(--red);
}
.launcher-cleanup:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
/* The grid is capped, so say so rather than let the tail vanish silently. */
.launcher-orphans-more {
margin-top: 14px;
font-family: var(--mono-font);
font-size: 11.5px;
color: var(--text-faint);
}
.launcher-actions {
display: flex;
gap: 8px;
}
/* "New session" tile — leads the session grid, matching the session cards. */
.mg-new-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 200px;
background: none;
border: 2px dashed var(--border-strong);
border-radius: 12px;
color: var(--text-dim);
cursor: pointer;
font: inherit;
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.mg-new-card:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.mg-new-plus {
font-size: 34px;
font-weight: 300;
line-height: 1;
}
.mg-new-label {
font-size: 14px;
font-weight: 600;
}
/* ── Session Manager page (manage.html) ──────────────────────────── */
#manage-root {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px 60px;
height: 100%;
overflow-y: auto;
box-sizing: border-box;
}
.mg-header {
margin-bottom: 18px;
}
.mg-title {
font-size: 22px;
font-weight: 700;
color: #fff;
}
.mg-sub {
color: var(--text-dim);
font-size: 13px;
margin-top: 2px;
}
.mg-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.mg-btn {
background: var(--surface-2);
border: 1px solid var(--border-strong);
color: var(--text);
border-radius: 8px;
padding: 8px 14px;
cursor: pointer;
font: inherit;
font-size: 13px;
text-decoration: none;
transition: background 0.1s ease;
}
.mg-btn:hover {
background: var(--surface-3);
}
.mg-btn.warn {
border-color: rgba(245, 177, 76, 0.5);
color: var(--amber);
}
.mg-btn.danger {
border-color: rgba(255, 107, 107, 0.5);
color: var(--red);
}
.mg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 14px;
}
.mg-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
}
.mg-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.mg-name {
flex: 1 1 auto;
min-width: 0;
color: #fff;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Live preview thumbnail (scaled read-only xterm) */
.mg-thumb {
position: relative;
width: 100%;
min-height: 90px;
background: #0e0f13;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.mg-thumb:hover {
border-color: var(--accent);
}
.mg-thumb-inner {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
padding: 4px;
}
.mg-watch {
font-size: 11px;
color: var(--text-faint);
background: var(--surface-3);
border-radius: 5px;
padding: 1px 6px;
}
.mg-watch.live {
color: var(--green);
background: rgba(70, 208, 127, 0.14);
}
.mg-status {
font-size: 11px;
color: var(--text-dim);
}
.mg-status.mg-waiting {
color: var(--amber);
font-weight: 600;
}
.mg-status.mg-working {
color: var(--accent);
}
.mg-meta {
color: var(--text-faint);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: Menlo, Consolas, monospace;
}
.mg-actions {
display: flex;
gap: 6px;
}
.mg-actions .mg-open {
flex: 1 1 auto;
text-align: center;
}
.mg-open {
background: var(--accent);
color: var(--on-accent);
border-radius: 8px;
padding: 7px 13px;
text-decoration: none;
font-size: 13px;
}
.mg-open:hover {
background: var(--accent-2);
}
.mg-kill {
background: transparent;
border: 1px solid rgba(255, 107, 107, 0.4);
color: var(--red);
border-radius: 8px;
padding: 7px 13px;
cursor: pointer;
font: inherit;
font-size: 13px;
}
.mg-kill:hover {
background: rgba(255, 107, 107, 0.14);
}
.mg-empty {
color: var(--text-dim);
padding: 30px 0;
text-align: center;
}
/* ── v0.6 Projects panel ─────────────────────────────────────────────────── */
/* Panel wrapper (same host as .launcher; toggled via display) */
.proj-panel {
position: absolute;
inset: 52px 0 0 0; /* mobile: reserve a row for the stacked .home-seg (desktop overrides to 0) */
overflow-y: auto;
padding: 22px 18px 40px;
box-sizing: border-box;
background: var(--bg);
}
/* Search box */
.proj-search-wrap {
margin-bottom: 16px;
}
.proj-search {
width: 100%;
max-width: 320px;
padding: 8px 12px;
font: inherit;
font-size: 14px;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
outline: none;
box-sizing: border-box;
}
.proj-search:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
/* Outer container is now a vertical stack of collapsible namespace sections. */
.proj-grid {
display: flex;
flex-direction: column;
gap: 22px;
}
/* The actual card grid — one per group (and the flat fallback). Mirrors .mg-grid. */
.proj-grid-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
/* ── Namespace group section ─────────────────────────────────────────────── */
.proj-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.proj-group-head {
display: flex;
align-items: center;
gap: 9px;
padding: 4px 2px;
position: sticky; /* keep the namespace visible while scrolling a long group */
top: 0;
z-index: 1;
background: var(--bg);
user-select: none;
}
.proj-group-head[role='button'] {
cursor: pointer;
border-radius: 7px;
}
.proj-group-head[role='button']:hover {
background: var(--surface-1);
}
.proj-group-head[role='button']:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.proj-group-caret {
color: var(--text-faint);
font-size: 11px;
width: 12px;
flex: none;
text-align: center;
}
.proj-group-label {
color: var(--text);
font-weight: 600;
font-size: 13px;
letter-spacing: 0.01em;
}
.proj-group-count {
color: var(--text-faint);
font-size: 12px;
font-family: Menlo, Consolas, monospace;
}
/* "N active" badge on a namespace/other header — never lose running work behind a caret. */
.proj-group-active {
color: var(--green);
font-size: 11px;
margin-left: 2px;
}
/* "Active now" header — pinned, accent-tinted, non-collapsible. */
.proj-group-head.proj-group-active .proj-group-caret {
color: var(--green);
font-size: 9px;
}
.proj-group-head.proj-group-active .proj-group-label {
color: var(--accent);
}
.proj-group.collapsed {
gap: 0;
}
/* Project card */
.proj-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
transition: border-color 0.12s ease;
}
.proj-card:hover {
border-color: var(--border-strong);
}
/* Card header row */
.proj-card-head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* Repo name */
.proj-name {
flex: 1 1 auto;
color: #fff;
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Branch chip — same geometry and border as every other chip in the panel. It was
the odd one out: no border, no mono face, and 1px tighter all round, so the
detail header's branch did not match the branch chips in the worktree list. */
.proj-branch {
font-family: var(--mono-font);
font-size: 11.5px;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid rgba(227, 166, 74, 0.36);
border-radius: 6px;
padding: 3px 9px;
white-space: nowrap;
flex: none;
}
/* Dirty indicator (● dot) */
.proj-dirty {
font-size: 10px;
color: var(--amber);
flex: none;
}
/* W3(a): ahead/behind sync chip (mirrors the .proj-branch chip look). */
.proj-sync {
font-size: 11px;
color: var(--amber);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 7px;
white-space: nowrap;
flex: none;
}
/* ── w6/G3: the project detail sync band ──────────────────────────────────────
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: stretch;
margin: 16px 0 4px;
background: var(--sunk);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
/* 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: 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;
}
.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(--text-faint);
}
.proj-sync-v {
display: flex;
align-items: center;
gap: 7px;
font-family: 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: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
color: var(--text-faint);
}
.proj-sync-sub-bad {
color: var(--warn);
}
.proj-sync-upstream {
color: var(--text-dim);
}
.proj-sync-chip {
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
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: var(--ok);
background: rgba(125, 155, 118, 0.13);
border-color: rgba(125, 155, 118, 0.3);
}
/* "This number may be lying" — never applied to ahead, which needs no fetch. */
.proj-sync-stale,
.proj-sync-noupstream,
.proj-sync-detached {
color: var(--warn);
}
.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);
}
.proj-sync-dirty {
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: 6px;
padding: 3px 9px;
font-size: 11.5px;
white-space: nowrap;
flex: none;
}
.proj-sync-fetch {
font: inherit;
font-size: 12.5px;
font-weight: 560;
cursor: pointer;
padding: 7px 15px;
border-radius: 7px;
border: 0;
background: var(--accent);
color: var(--on-accent);
}
.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(--text);
outline-offset: 2px;
}
@media (max-width: 720px) {
.proj-syncband {
flex-direction: column;
}
.proj-sync-cell {
border-right: 0;
border-bottom: 1px solid var(--border);
}
.proj-sync-actions {
margin-left: 0;
}
}
/* ── 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(--text-dim);
text-transform: none;
letter-spacing: 0.04em;
margin-left: 10px;
}
/* ── w6/G5: worktree rows as two-line cards ──────────────────────────────── */
/* 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. */
/* 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), var(--surface-2) 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;
}
/* Role 2 of 2 — the ghost. The design has exactly two button roles: filled accent
for the action that commits a form (Fetch, Create Worktree), and this outline for
anything that only opens, reveals or toggles. ONE geometry for every one of them:
the role had drifted to three different sizes across the panel, all at
full-strength --text instead of the muted label the design asks for.
SCOPE — read this before adding a selector. The geometry below covers exactly two
surfaces: the worktree section (its Prune header button and each row's Open / ✕
rail) and the diff viewer's toggle and toolbar (View Diff, Working/Staged, ✕ Close).
Those are the buttons that only open, reveal, switch or dismiss, and every one of
them is in the list, so none of them can quietly pick its own size again. It is NOT
a claim about the whole projects panel: at least the following are outline buttons
by this file's own definition and are deliberately deferred, not overlooked —
.proj-back background:none, 1px --border, --text-dim, radius 8,
6px 12px, 13px
.proj-dsession-kill background:none, 1px --border, --text-dim, radius 7, 30×30
.proj-claudemd-btn --surface-2 fill, 1px --border-strong, radius 7, 6px 12px, 12px
.proj-launch --surface-2 fill, 1px --border, --text-dim, radius 9, 9px 4px
.df-push-btn --surface-2 fill, 1px --border-strong, --text, radius 6,
8px 16px, `font: inherit` → 13px from .df-viewer (W4)
.df-file-stage --surface-2 fill, 1px --border-strong, --text, radius 6,
3px 10px, 12px (W4)
Folding those in touches W4 and the project-detail header, which is a wider change
than this one. Until someone does it, do not add a further size in the meantime — a
new outline button either joins the selector list below or matches one of the shapes
above exactly.
One exception, and it is in the list not outside it: `.df-tab` can be disabled
(base-compare mode turns both tabs inert), so it joins :hover as
`.df-tab:not(:disabled)` — otherwise an inert tab still lit up gold under the
cursor. Nothing about the geometry is restated anywhere. */
.proj-wt-open,
.proj-wt-remove,
.proj-wt-prune,
.proj-diff-toggle,
.df-tab,
.df-close {
font: inherit;
font-size: 12.5px;
cursor: pointer;
padding: 7px 15px;
border-radius: 7px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text-dim);
}
.proj-wt-open:hover,
.proj-wt-remove:hover,
.proj-wt-prune:hover,
.proj-diff-toggle:hover,
.df-tab:not(:disabled):hover,
.df-close:hover {
border-color: var(--accent);
color: var(--accent);
}
/* .df-base-select is not this role — it is a form control, skinned with
.proj-fanout-mode — but it is keyboard-focusable and sits between .df-tab and
.df-close in the same rail, and it declared `outline: none` with only a 1px border
recolour to show focus. Two focus languages in three adjacent controls is one too
many, so it borrows the ring here. This rule declares outline only, so it restates
none of that control's geometry. */
.proj-wt-open:focus-visible,
.proj-wt-remove:focus-visible,
.proj-wt-prune:focus-visible,
.proj-diff-toggle:focus-visible,
.df-tab:focus-visible,
.df-base-select:focus-visible,
.df-close:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
/* Square icon variant — same role, just not a word. Keeps the shared height
(12.5px line + 7px padding twice) so it sits flush beside Open in the rail
rather than stretching the row. */
.proj-wt-remove {
width: 33px;
padding: 7px 0;
line-height: 1;
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: 5px;
}
.proj-commit-unpushed::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
border-radius: 2px;
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: 12px;
text-align: center;
}
/* Where the upstream actually sits in this history — never drawn without one. */
.proj-commit-boundary {
display: flex;
align-items: center;
gap: 12px;
margin: 9px 0 7px;
padding-left: 8px;
}
.proj-commit-boundary-label {
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: 3px 11px;
white-space: nowrap;
flex: none;
}
.proj-commit-boundary-rule {
flex: 1;
height: 1px;
background: var(--border-strong);
}
/* 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 {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
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);
font-weight: 600;
}
/* W3(d): recent-commit list in the project detail. */
.proj-commitlog {
margin: 4px 0 10px;
}
.proj-commitlog-loading {
font-size: 12px;
color: var(--text-faint);
}
.proj-commitlog-list {
display: flex;
flex-direction: column;
}
/* 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: grid;
grid-template-columns: 22px 74px 56px 1fr;
align-items: baseline;
gap: 10px;
padding: 4px 8px;
font-size: 12px;
min-width: 0;
}
.proj-commit-hash {
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);
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-family: var(--mono-font);
font-size: 11.5px;
color: var(--text-faint);
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). */
.wya-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
font-size: 13px;
color: var(--text);
background: var(--accent-soft);
border-bottom: 1px solid var(--accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.wya-title {
font-weight: 600;
color: var(--accent);
flex: none;
}
.wya-summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wya-cost {
color: var(--text-faint);
flex: none;
}
.wya-dismiss {
flex: none;
border: none;
background: transparent;
color: var(--text-faint);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 2px 6px;
}
.wya-dismiss:hover {
color: var(--text);
}
/* W3: PR + CI status chip (mirrors the .proj-branch chip look). */
.proj-pr-host {
display: inline-flex;
flex: none;
}
.proj-pr-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
border-radius: 5px;
padding: 2px 7px;
white-space: nowrap;
max-width: 260px;
color: var(--text-faint);
background: var(--accent-soft);
}
.proj-pr-chip .proj-pr-title {
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-faint);
}
.proj-pr-loading {
opacity: 0.6;
}
/* PR-state modifiers */
.proj-pr-open .proj-pr-label {
color: var(--green);
}
.proj-pr-draft .proj-pr-label {
color: var(--text-faint);
}
.proj-pr-merged .proj-pr-label {
color: var(--accent);
}
.proj-pr-closed .proj-pr-label {
color: var(--red);
}
.proj-pr-none,
.proj-pr-unavailable {
color: var(--text-faint);
background: transparent;
}
/* Checks-rollup modifiers (colour the whole chip label) */
.proj-pr-checks-ok .proj-pr-label {
color: var(--green);
}
.proj-pr-checks-fail .proj-pr-label {
color: var(--red);
}
.proj-pr-checks-pending .proj-pr-label {
color: var(--amber);
}
/* Mergeable = conflicting */
.proj-pr-conflict {
box-shadow: inset 0 0 0 1px var(--red);
}
/* Meta line (last-active time) */
.proj-meta {
font-size: 11px;
color: var(--text-faint);
font-family: Menlo, Consolas, monospace;
}
/* Favourite star toggle */
.proj-fav {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 15px;
padding: 0 2px;
line-height: 1;
flex: none;
transition: color 0.12s ease;
}
.proj-fav:hover {
color: var(--accent);
}
.proj-fav.on {
color: var(--accent);
}
/* Sessions list (1:N) */
.proj-sessions-list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* One running-session row */
.proj-session-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: var(--surface-3);
cursor: pointer;
font-size: 13px;
color: var(--text);
transition: background 0.1s ease;
}
.proj-session-row:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Status dot on a session row — colours from THEME.md v0.6 note */
.proj-session-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: var(--text-faint);
}
.proj-dot-working {
background: var(--accent);
box-shadow: 0 0 5px var(--accent-soft);
}
.proj-dot-waiting {
background: var(--amber);
}
/* sessionDotClass emits one class per ClaudeStatus member (src/types.ts) and 'stuck'
is one of them — without this rule a stuck session's dot rendered uncoloured.
--warn is the panel's stuck/attention colour: .tl-icon-stuck already uses it, so the
two surfaces say the same thing about the same state. */
.proj-dot-stuck {
background: var(--warn);
}
.proj-dot-idle {
background: #8b8a86;
}
.proj-dot-unknown {
background: var(--text-faint);
}
/* Session title */
.proj-session-title {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Kill (✕) on a session row — close the session straight from the card. */
.proj-session-kill {
flex: none;
width: 20px;
height: 20px;
line-height: 1;
padding: 0;
border: none;
border-radius: 5px;
background: none;
color: var(--text-faint);
cursor: pointer;
font-size: 11px;
transition: background 0.1s ease, color 0.1s ease;
}
.proj-session-kill:hover {
background: var(--red, #e5484d);
color: #fff;
}
/* Client-count badge (👁 N) */
.proj-session-badge {
font-size: 11px;
color: var(--text-dim);
background: var(--surface-2);
border-radius: 4px;
padding: 1px 5px;
flex: none;
}
/* " New" / " start claude here" action button */
/* Launcher row — Claude · Codex · VS Code, each a brand-logo icon button. */
.proj-launchers {
display: flex;
gap: 8px;
margin-top: 2px;
}
.proj-launch {
position: relative; /* for the active count badge */
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 9px 4px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 9px;
cursor: pointer;
font: inherit;
color: var(--text-dim); /* drives the logo colour (currentColor) by default */
transition: background 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
}
.proj-launch svg {
display: block;
}
.proj-launch-cap {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
}
.proj-launch:hover {
background: var(--surface-3);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.proj-launch:active {
transform: translateY(0);
}
.proj-launch:hover .proj-launch-cap {
color: var(--text);
}
/* Brand colours on the logos (currentColor) */
.proj-launch-claude {
color: #d97757; /* Claude coral */
}
.proj-launch-codex {
color: #e7e8ec; /* OpenAI mark — near-white on dark */
}
.proj-launch-vscode {
color: #3aa0e3; /* VS Code blue */
}
/* Active state — a matching session is running (Claude logo when a Claude
* session is live). Coral ring + faint tint draw the eye without shouting. */
.proj-launch-active {
background: rgba(217, 119, 87, 0.14);
border-color: #d97757;
box-shadow: 0 0 0 1px rgba(217, 119, 87, 0.45) inset;
}
.proj-launch-active .proj-launch-cap {
color: #e9b9a6;
}
.proj-launch-badge {
position: absolute;
top: 4px;
right: 6px;
min-width: 15px;
height: 15px;
padding: 0 3px;
box-sizing: border-box;
border-radius: 8px;
background: #d97757;
color: #1a1408;
font-size: 9px;
font-weight: 700;
line-height: 15px;
text-align: center;
}
/* ── Project detail view ─────────────────────────────────────────── */
.proj-name-link {
cursor: pointer;
}
.proj-name-link:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.proj-detail-inner {
max-width: 900px;
margin: 0 auto;
}
.proj-back {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 8px;
padding: 6px 12px;
font: inherit;
font-size: 13px;
cursor: pointer;
margin-bottom: 16px;
transition: background 0.12s ease, color 0.12s ease;
}
.proj-back:hover {
background: var(--surface-2);
color: var(--text);
}
.proj-detail-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.proj-detail-name {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #fff;
}
.proj-detail-path {
margin: 4px 0 8px;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
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 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;
flex-direction: column;
gap: 8px;
}
.proj-fanout-prompt {
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 44px;
font: inherit;
}
.proj-fanout-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.proj-fanout-lanes {
width: 72px;
}
.proj-fanout-branch {
flex: 1 1 160px;
min-width: 120px;
}
/* --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;
}
/* ── 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
panel. Layout stays with each element's own rule above; this block only sets
the shared skin, so there is no duplicate declaration of the same property.
Two button roles, matching what the panel already established: filled accent
for the action that commits the form (like Fetch), outline for anything that
only reveals or toggles (like Open). */
.proj-wt-branch-input,
.proj-fanout-prompt,
.proj-fanout-lanes,
.proj-fanout-branch,
.proj-fanout-mode {
font: inherit;
font-size: 13px;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 8px 11px;
outline: none;
box-sizing: border-box;
}
.proj-wt-branch-input::placeholder,
.proj-fanout-prompt::placeholder,
.proj-fanout-branch::placeholder {
color: var(--text-faint);
}
.proj-wt-branch-input:focus,
.proj-fanout-prompt:focus,
.proj-fanout-lanes:focus,
.proj-fanout-branch:focus,
.proj-fanout-mode:focus,
.df-base-select:focus {
border-color: var(--accent);
}
/* A branch name is an identifier, so it reads as one. */
.proj-wt-branch-input,
.proj-fanout-branch {
font-family: var(--mono-font);
font-size: 12.5px;
}
.proj-fanout-lanes {
text-align: center;
font-variant-numeric: tabular-nums;
}
/* Without appearance:none WebKit keeps its own light control, so these were the
two widgets in the panel that stayed a native grey dropdown. The arrow has to be
redrawn by hand once the native one is gone. The diff viewer's base picker joins
the list rather than getting its own copy of the caret — it differs only in size,
which its own rule sets with padding longhands so `padding-right` stays here. */
.proj-fanout-mode,
.df-base-select {
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: 7px 15px;
border: 0;
border-radius: 7px;
background: var(--accent);
color: var(--on-accent);
}
.proj-wt-submit:hover:not(:disabled),
.proj-fanout-submit:hover:not(:disabled) {
filter: brightness(1.08);
}
.proj-wt-submit:disabled,
.proj-fanout-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Role 2 (the ghost, incl. .proj-diff-toggle) is defined once, with the worktree
action rail — see "Role 2 of 2" above. Nothing for it here on purpose: a second
block at equal specificity is how the role drifted to three sizes before. */
.proj-wt-submit:focus-visible,
.proj-fanout-submit:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
/* W5 fan-out status/error banner — fixed, dismissible, textContent only. */
.fanout-banner {
position: fixed;
left: 50%;
bottom: 16px;
transform: translateX(-50%);
max-width: min(680px, 92vw);
z-index: 60;
padding: 10px 14px;
border-radius: 8px;
background: var(--surface-3, #2a2c33);
color: var(--text, #e7e8ec);
border: 1px solid var(--border, #3a3d46);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
font-size: 13px;
cursor: pointer;
}
/* Section header with an inline action (CLAUDE.md generate/update). */
.proj-section-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 22px 0 10px;
}
.proj-section-row .proj-section-title {
margin: 0;
}
.proj-claudemd-btn {
flex: none;
background: var(--surface-2);
border: 1px solid var(--border-strong);
color: var(--accent);
border-radius: 7px;
padding: 6px 12px;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
.proj-claudemd-btn:hover {
background: var(--accent-soft);
border-color: var(--accent);
}
.proj-claudemd {
margin: 0;
max-height: 320px;
overflow: auto;
padding: 12px 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 10px;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
/* Worktrees */
.proj-wt-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.proj-wt-row {
/* w6: two-line card — content column, then the actions rail pinned right. */
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 14px;
padding: 12px 15px;
background: var(--surface-2);
border: 1px solid var(--border);
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-family: var(--mono-font);
font-size: 11px;
letter-spacing: 0.04em;
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-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.
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);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Detailed session rows */
.proj-detail-sessions {
display: flex;
flex-direction: column;
gap: 8px;
}
.proj-dsession {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
}
.proj-dsession-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.proj-dsession-title {
font-weight: 600;
color: var(--text);
}
.proj-dsession-meta {
font-size: 12px;
color: var(--text-dim);
}
.proj-dsession-open {
background: var(--accent);
color: var(--on-accent);
border: none;
border-radius: 7px;
padding: 6px 14px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.proj-dsession-open:hover {
background: var(--accent-2);
}
.proj-dsession-kill {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 7px;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 14px;
}
.proj-dsession-kill:hover {
background: var(--red, #e5484d);
border-color: var(--red, #e5484d);
color: #fff;
}
/* Empty state */
.proj-empty {
color: var(--text-dim);
padding: 30px 0;
text-align: center;
}
/* ── Project detail: the diff section and the activity timeline ───────────────
These shipped as class names with no rules at all, so the "View Diff" panel and
the per-session activity logs rendered as raw user-agent boxes. The viewer's own
.df-* rules live with the W4 git-write block at the end of the file, so all diff
styling stays in one place; only the project-detail containers are here, beside
their .proj-* siblings. */
/* The detail column. Nothing visual belongs here — .proj-detail-inner owns the
width, and `display` is driven inline ('none' | 'block', projects.ts:1528/1569),
so a display declaration here would only ever fight it. min-width:0 is the guard
that keeps a wide descendant from widening the flex/grid column instead of
scrolling inside itself. */
.proj-detail {
min-width: 0;
}
/* Keeps the ghost "View Diff" toggle and the panel it reveals together as one
block, so the viewer does not sit flush against the next section heading. */
.proj-diff-section {
margin-bottom: 6px;
}
/* Open-state only: the closed state is an inline display:none that wins anyway,
and the open state clears it to ''. A display declaration here would close the
panel permanently. */
.proj-diff-panel {
margin-top: 10px;
}
/* One block per running session, so this stack compounds — three sessions is three
of these. */
.proj-activity-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.proj-activity-session {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
/* A per-item caption one level below .proj-section-title, and a session title is
an identifier, so it reads as one rather than as more heading. */
.proj-activity-label {
font-family: var(--mono-font);
font-size: 11.5px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The same sunk well the panel uses everywhere for read-only data — fill, hairline,
radius, padding. That is all this rule is.
NO HEIGHT CAP, deliberately, and please do not add one back. Three separate capping
mechanisms were tried on this element and each cost more than the height it saved:
- `max-height` + `overflow-y: auto` gave a scrollbar nobody can use. mountTimeline
polls every 5s and render() wipes and re-appends every child (timeline.ts:174-189),
so scrollTop returns to 0 on every tick — a scroll position cannot survive a
wipe-and-re-append render loop.
- `mask-image` softened the cut edge and dissolved the well with it: mask-clip
defaults to border-box, so the gradient multiplied the --sunk fill, the bottom
hairline and both bottom corners, and did it on every well including ones that
clipped nothing.
- a `::after` gradient overlay does not touch the chrome, but a pseudo-element
cannot know whether anything is actually clipped, so it fired unconditionally.
Measured: a 6-event well is 142px tall against a 220px cap and clips nothing,
yet the 44px overlay covered its last two rows at up to .84 alpha; and in the
56px empty well it swallowed "No activity yet" outright: --text-dim reads 7.77:1
on --sunk, and under the overlay that line ran from 5.2:1 at the top of the
glyphs to 2.6:1 at their baseline — for most of its height BELOW the 3.66:1
--text-faint value that promoting .tl-empty to --text-dim had just escaped.
Gating the fade on a row count (`:has(> .tl-row:nth-child(10))`) is refused for a
different reason, and NOT because it cannot measure: .tl-label is nowrap +
ellipsis, so every row is exactly one line high and a row count does track the
well's height. It is refused because it is still a cap. The gate only decides when
to cut; the cutting is the part every attempt above paid more for than the height
it saved.
Un-capped is not unbounded: render() slices to maxEvents, default 50
(timeline.ts:20), so the well's ceiling is 50 rows and it grows to whatever it
holds — which is what this element did before any of this CSS existed. What is not
bounded is the number of wells: buildActivitySection mounts one per running session,
so the Activity section's height scales with running sessions. That is the price of
no cap, stated here so the next reader does not have to re-derive it. */
.proj-tl-container {
padding: 6px 0;
background: var(--sunk);
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Every .tl-* rule is scoped to .proj-tl-container on purpose. timeline.ts also
mounts into #timeline-panel on the terminal page (tabs.ts:695) — a bare div on
document.body with no CSS, no background and no positioning, and out of scope
here. Unscoped .tl-* rules would drop finished-looking rows straight onto the
terminal and make an unfinished panel look done.
A grid, not a flex row, for the same reason .proj-commit-row is one: every column
has to land in the same place on every line or the log stops scanning as a column
of times. The icon track is fixed because emoji advance widths differ per glyph. */
.proj-tl-container .tl-row {
display: grid;
grid-template-columns: 40px 20px 1fr;
align-items: baseline;
gap: 8px;
padding: 3px 8px;
font-size: 12px;
min-width: 0;
}
/* --text-dim, not --text-faint: the timestamp is one of the log's three columns and
the only one that answers "when", and --text-faint at 11.5px is 3.67:1 on --sunk. */
.proj-tl-container .tl-time {
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
white-space: nowrap;
}
.proj-tl-container .tl-icon {
font-size: 11px;
line-height: 1.4;
text-align: center;
color: var(--text-dim);
}
/* Variants after the base — same element, same specificity, source order decides.
Only ✓ (done) and ⚠ (stuck) are monochrome text glyphs that actually take a
colour; 🔧 ⏳ 💬 are colour emoji and ignore `color` entirely. The `color` on all
of them is the fallback for a platform that renders them as text.
The two high-frequency ones used to be stepped back with opacity as well, since
that does bite on emoji — dropped, because it also bites on the fallback: 🔧 as
text at --text-faint × .7 is 2.36:1, and the whole point of the icon track is to
let you tell a tool call from a message without reading. The row's grid already
ranks the icon below the label; it does not also need to be faded.
💬 (user) has no variant rule at all and takes .tl-icon's --text-dim: it was the one
icon that stepped DOWN from the base, which rendered a message quieter than the tool
call it answered — the inverse of the ranking this track exists to give. Glyph, not
weight, is what separates the two. */
.proj-tl-container .tl-icon-tool {
color: var(--text-dim);
}
/* waiting = --amber is already the session-dot semantic (THEME.md). */
.proj-tl-container .tl-icon-waiting {
color: var(--amber);
}
/* Not --ok. "Done" here means one tool call finished, which is nothing like
"I checked your upstream, ignore this" — the one state green is allowed to
mean (:root L23-29). The ✓ is its own signal. */
.proj-tl-container .tl-icon-done {
color: var(--text-dim);
}
.proj-tl-container .tl-icon-stuck {
color: var(--warn);
}
/* The only elastic column, and frequently a 500-character absolute path. One line
with an ellipsis, for the reason .proj-wt-path gives: a wrapped path re-flows
every row below it and is no more readable for it. */
.proj-tl-container .tl-label {
min-width: 0;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Centred like .proj-empty and .df-empty, smaller because this well is one of
several on the page. --text-dim for the .tl-time reason: --text-faint is 3.66:1 on
--sunk, and "No activity yet" is the only thing the well says when a session has no
events — the one place that text appears is not the place to spend legibility. */
.proj-tl-container .tl-empty {
padding: 14px 8px;
text-align: center;
font-size: 12px;
color: var(--text-dim);
}
/* gh-chip.ts:215-217 already sets display:none inline on this element, so this rule
changes nothing today and can never conflict. It is here so the class states its
own intent instead of depending on one line of TypeScript to stay put. */
.proj-pr-hidden {
display: none;
}
/* Must sit AFTER the rules above — a media query adds no specificity, so an earlier
block at equal specificity simply loses. 720px is the panel's established phone
breakpoint (.proj-commit-row, .proj-syncband), not a new one. */
@media (max-width: 720px) {
/* Take back .proj-panel's 18px of side padding for the diff viewer: 36px is about
five columns of mono text, which on a 380px screen is the difference between
reading a line and scrolling it. The negative margins exactly cancel that
padding (.proj-panel is `padding: 22px 18px 40px` with box-sizing:border-box and
.proj-detail-inner adds none), so the viewer lands on the panel's padding-box
edge and adds no horizontal overflow. */
.proj-diff-panel {
margin-left: -18px;
margin-right: -18px;
}
/* Edge to edge means the side borders and corners have nothing to sit against. */
.proj-diff-panel .df-viewer {
border-left: 0;
border-right: 0;
border-radius: 0;
}
/* All three timeline columns carry distinct information — unlike the commit row,
where the subject dominates and the age can go — so this tightens the tracks
instead of dropping one. */
.proj-tl-container .tl-row {
grid-template-columns: 38px 18px 1fr;
gap: 6px;
}
}
/* ── Home segmented control (P6 builds the DOM; styles defined here per spec) */
/* Horizontal pill container, centered above the home grid */
/* Segmented control: a single rounded "track" holding two pill buttons, with
* the active one filled (iOS-style). Centred with fit-content + margin auto. */
.home-seg {
width: fit-content;
margin: 10px auto 12px;
display: flex;
gap: 3px;
padding: 3px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
}
/* Inactive segment */
.home-seg-btn {
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 500;
padding: 6px 22px;
border-radius: 999px;
transition: background 0.12s ease, color 0.12s ease;
}
.home-seg-btn:not(.active):hover {
color: var(--text);
background: var(--surface-3);
}
/* Active segment — Amber accent; text uses --on-accent for contrast */
.home-seg-btn.active {
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.home-seg-btn.active:hover {
background: var(--accent-2);
}
/* On the home chooser (Sessions/Projects), there is no active terminal, so the
* bottom key-bar is just noise — hide it. Toggled by TabApp.updateHomeView(). */
body.home-open #keybar {
display: none;
}
/* Home chooser hides the key-bar, so reclaim its reserved space at the bottom
* (keeping the home-indicator clearance so launcher content stays reachable). */
body.home-open #term {
bottom: var(--safe-b);
}
/* Desktop: float the Sessions/Projects toggle top-right, on the SAME row as the
* panel's filter/header — instead of a dedicated row that left a big empty band.
* The panels fill from the top; the launcher header reserves room for the pill.
* (Mobile keeps the stacked, centred toggle — see .home-seg above — to avoid
* overlapping the full-width filter box on narrow screens.) */
@media (min-width: 769px) {
.home-seg {
position: absolute;
top: 16px;
right: 20px;
margin: 0;
z-index: 6;
}
.launcher,
.proj-panel {
inset: 0;
}
.launcher-head {
padding-right: 240px; /* clear the absolutely-positioned .home-seg */
}
}
/* v0.7: timeline + push toggle reuse the minimal .toolbtn look. */
.toolbtn.active {
color: var(--accent);
}
.toolbtn.push-bell-disabled {
opacity: 0.45;
cursor: default;
}
.toolbtn.push-bell-disabled:hover {
background: none;
color: var(--text-dim);
}
/* v0.7 fix: timeline + push toggle render as minimal line-icon buttons that
match the .toolbtn toolbar style (transparent, monochrome, accent on
hover/active). !important defeats the earlier boxed default for these classes. */
.tab-timeline,
.push-toggle-btn,
.push-toggle-disabled {
background: none !important;
border: none !important;
box-shadow: none !important;
color: var(--text-dim);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 7px 9px;
border-radius: 8px;
font: inherit;
font-size: 12px;
line-height: 1;
transition: background 0.12s ease, color 0.12s ease;
}
.tab-timeline svg,
.push-toggle-btn svg,
.push-bell svg {
width: 18px;
height: 18px;
display: block;
}
.tab-timeline:hover,
.push-toggle-btn:hover {
color: var(--accent);
background: var(--surface-2) !important;
}
.tab-timeline.active,
.push-toggle-btn.push-toggle-on {
color: var(--accent);
}
.push-toggle-disabled {
color: var(--text-faint);
opacity: 0.6;
cursor: default;
}
.push-hint {
font-size: 11px;
color: var(--text-faint);
}
/* v0.7: styled hover tooltip on the timeline + push buttons (like .toolbtn).
Left-anchored — these sit on the left side of the tab bar. */
.tab-timeline,
.push-toggle-btn {
position: relative;
}
.tab-timeline[data-tip]::after,
.push-toggle-btn[data-tip]::after {
content: attr(data-tip);
position: absolute;
top: calc(100% + 6px);
left: 0;
white-space: nowrap;
background: var(--surface-3);
color: var(--text);
border: 1px solid var(--border-strong);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
z-index: 60;
}
.tab-timeline[data-tip]:hover::after,
.push-toggle-btn[data-tip]:hover::after {
opacity: 1;
}
/* ── Saved layout presets dropdown (v3) ──────────────────────────────── */
.grid-presets {
position: relative;
display: inline-flex;
margin-left: 2px;
}
.grid-presets-btn {
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-dim);
width: 30px;
height: 30px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
}
.grid-presets-btn:hover {
color: var(--text);
background: var(--surface-3);
}
.grid-presets-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 220px;
background: var(--surface-1);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 6px;
z-index: 40;
}
.grid-presets-menu[hidden] {
display: none;
}
.gp-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 240px;
overflow-y: auto;
}
.gp-empty {
color: var(--text-faint);
font-size: 12px;
padding: 8px;
text-align: center;
}
.gp-row {
display: flex;
align-items: center;
gap: 4px;
}
.gp-apply {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 0;
background: transparent;
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 12.5px;
text-align: left;
}
.gp-apply:hover {
background: var(--surface-2);
}
.gp-apply-layout {
color: var(--text-faint);
font-size: 11px;
}
.gp-del {
border: 0;
background: transparent;
color: var(--text-faint);
width: 22px;
height: 22px;
border-radius: 5px;
cursor: pointer;
font-size: 15px;
line-height: 1;
}
.gp-del:hover {
color: var(--red);
background: var(--surface-2);
}
.gp-save {
display: flex;
gap: 4px;
margin-top: 6px;
border-top: 1px solid var(--border);
padding-top: 6px;
}
.gp-name {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 5px 8px;
font-size: 12px;
font-family: var(--ui-font);
}
.gp-name:focus {
outline: none;
border-color: var(--accent);
}
.gp-save-btn {
border: 0;
background: var(--accent);
color: var(--on-accent);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.gp-save-btn:hover {
filter: brightness(1.05);
}
/* ── W4 git write: per-file Stage/Unstage toggle + commit/push bar ─────────── */
.df-file-header {
display: flex;
align-items: center;
gap: 8px;
}
.df-file-stage {
margin-left: auto;
flex: 0 0 auto;
padding: 3px 10px;
font-size: 12px;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
cursor: pointer;
}
.df-file-stage:hover {
background: var(--surface-3);
border-color: var(--accent);
}
.df-commitbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px;
border-top: 1px solid var(--border);
background: var(--surface-1);
}
.df-commit-msg {
flex: 1 1 220px;
min-width: 0;
resize: vertical;
padding: 8px;
font: inherit;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 6px;
}
.df-commit-msg:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.df-commit-btn,
.df-push-btn {
flex: 0 0 auto;
padding: 8px 16px;
font: inherit;
font-weight: 600;
border-radius: 6px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
}
.df-push-btn {
background: var(--surface-2);
color: var(--text);
border-color: var(--border-strong);
}
.df-commit-btn:disabled,
.df-push-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.df-op-status {
flex: 1 1 100%;
font-size: 13px;
word-break: break-word;
}
.df-op-error {
color: var(--red);
}
.df-op-notice {
color: var(--text-dim);
}
.df-op-busy {
opacity: 0.7;
}
/* ── B1 diff viewer: shell, file cards, diff body, states ─────────────────────
These classes shipped with names but no rules, so the whole viewer rendered as
user-agent defaults — white OS buttons and a grey native dropdown on a dark warm
panel — and the diff body had no +/- signal of any kind. That last part is a
correctness bug, not a polish one: src/http/diff.ts:143-148 does `line.slice(1)`,
so the marker character never reaches the DOM and the sign existed only as a
class name that nothing drew.
Sits beside the W4 block above so all diff styling lives together. The two button
roles and the <select>'s skin/caret are joined to their existing blocks further up
(the "Role 2 of 2" ghost list, and .proj-fanout-mode) rather than restated here,
so no property is declared twice for the same selector.
Four surfaces, one hairline each: --sunk code well < --bg viewer body <
--surface-1 file card < --surface-2 (the W4 Stage button, which needs something
to sit on top of). Colour is the panel's warm two-tone and nothing else —
--dirty for insertions (the token already means "uncommitted work", which is what
a + in a working diff is) and --warn for deletions. No green: :root L23-29
reserves --ok for the one sync state. No --accent on a state, for the same reason.
Source order is load-bearing: .df-line and .df-status each sit on the same element
as their modifiers at identical specificity, so every base is declared before its
variants and the media query comes last. */
/* One hairline box at --bg on the panel's own --bg — almost invisible on purpose;
the file cards inside it are what you see. overflow:hidden is load-bearing: the
toolbar and the W4 commit bar both carry their own fill and are the first and
last children (diff.ts:407/437), so without it they square off the rounded
corners.
font-size is the root of the panel's type scale, and it has to be stated: no
ancestor sets one (html/body set only a family), so without this the viewer
inherits the UA's 16px and the three W4 controls that say `font: inherit`
(.df-commit-msg, .df-commit-btn, .df-push-btn) render as the loudest type in a
panel whose rail is 12.5px. Every other descendant declares its own size, so
13px moves those three and nothing else. */
.df-viewer {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
font-size: 13px;
overflow: hidden;
}
/* Four controls, one of them a <select> sized by the longest branch name, in a phone's
width. Wrapping is the only honest answer.
The rail is view-controls-left, dismiss-right: ✕ Close takes `margin-left: auto`,
the same `1fr auto` idiom .proj-wt-row spells with a grid. Packing all four left
instead left a long dead rail after ✕ Close on a wide panel, and on a phone put the
two tabs alone on line 1 with the rest of that line empty — a toolbar that stops
partway across is not a group, it is an unfinished row.
The slack goes to the auto margin and not to .df-base-select (whose 200px cap
stands) for two reasons: the picker is optional — it only exists when opts.bases is
non-empty (diff.ts:384-403), so widening it fixes one of the two toolbars the viewer
can build — and a branch name stretched across half the panel is not more readable
than one at 200px. The phone half of the void is fixed in the ≤410px block at the
bottom of the file, where the two tabs split line 1 (`flex: 1 1 0`) rather than
growing this rule. */
.df-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 9px 10px;
background: var(--surface-1);
border-bottom: 1px solid var(--border);
}
.df-tab {
flex: none;
}
/* Selected view, in the panel's own "you are here" language (.proj-wt-tag-current),
not a filled button: switching views commits nothing, and a gold fill here would
outrank the Commit button forty pixels below, which is the one Role 1 action in
this viewer. */
.df-tab-active {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 36%, transparent);
color: var(--accent);
}
/* Both tabs go inert when a base revision is picked — a base diff cannot be staged.
Matched on :disabled as well, because the click handlers reassign `className`
wholesale (diff.ts:531/539/575/583) and can drop the modifier while the native
attribute survives. Must stay after .df-tab-active: same specificity, later wins.
That source order is doing real work here, not just guarding a hover: updateTabState
(diff.ts:411-418) only ADDS df-tab-disabled — it never removes df-tab-active — so
without these three resets the gold "Working" pill survives at half opacity while a
base diff is on screen, which reads as "selected, and you may click me". Nothing in
this rule needs a new selector; it just has to undo all three accent properties.
ONE dimming mechanism, not two. --text-faint AND opacity:.45 stacked to 1.64:1 over
the toolbar's --surface-1 and took the border with them: on screen both tabs were
blank rectangles, so you could not see what you would return to. Disabled has to
read as a label you cannot press, not as an erasure. --text-dim at .55 composites to
≈#67635d, 3.03:1 — quiet, still a word. The `color` line cannot simply be deleted to
get the same effect: .df-tab-active above sets color:var(--accent) at equal
specificity and would win, restoring the gold pill this rule exists to kill.
The border is RAISED a tier here, not lowered, and that is not a contradiction:
opacity multiplies the border along with the label, so this rule has to pre-pay for
the dimming the way the label does not. Stepping it down to --border (.07) as well
left .0385 effective — 1.10:1 on --surface-1, against 1.51:1 for every enabled ghost
button in the same rail — and the tabs read as words floating in nothing.
--border-strong (.14) × .55 lands at 1.23:1, the same weight as an ordinary --border
hairline (1.20:1) drawn at full opacity. Still a full reset of all three accent
properties, which is what the paragraph above requires. */
.df-tab-disabled,
.df-tab:disabled {
opacity: 0.55;
cursor: not-allowed;
background: transparent;
border-color: var(--border-strong);
color: var(--text-dim);
}
/* Skin and caret come from the .proj-fanout-mode rule this selector was added to;
only what is genuinely different lives here. Padding is longhand so that rule's
`padding-right: 30px` (which the caret offsets depend on) is not overwritten.
Sized to the ghost buttons beside it — 12.5px, 7px vertical — rather than to the
13px/8px form-field skin: a 1px height mismatch is invisible in a form and obvious
in a four-control rail. A <select>'s intrinsic width is its longest option and the
options are real branch names, so min-width:0 is what stops one 35-character
branch from claiming the whole toolbar. */
.df-base-select {
flex: 1 1 150px;
min-width: 0;
max-width: 200px;
box-sizing: border-box;
font: inherit;
font-family: var(--mono-font);
font-size: 12.5px;
color: var(--text-dim);
background-color: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 7px;
padding-top: 7px;
padding-bottom: 7px;
padding-left: 10px;
text-overflow: ellipsis;
outline: none;
}
/* Chrome and Firefox paint the open dropdown from the control's own colours; macOS
draws it natively and ignores this. */
.df-base-select option {
color: var(--text);
background: var(--surface-2);
}
.df-close {
flex: none;
margin-left: auto;
}
/* The "Loading…" state is a bare text node with no element and no class
(diff.ts:552), so this rule is the only thing that can style it — hence the size
and colour live on the container and every descendant declares its own.
text-align and min-height are here for the same reason: three states share this
slot and only two of them are elements. Left-aligned 40px "Loading…" between a
centred 100px .df-empty and a centred 100px .df-error meant every refetch — tab
switch, stage toggle, commit — collapsed the viewer and re-expanded it. Centred
with a floor, all three land in the same box. .df-result opts back out below. */
.df-content {
padding: 10px;
min-height: 92px;
font-size: 13px;
text-align: center;
color: var(--text-dim);
}
/* A gap, not margins on .df-file: renderDiffFile is also called standalone into
.approval-preview on the terminal page (tabs.ts:499), where a card margin would
strand a single file inside an already-padded well. text-align cancels the
centring .df-content applies to the three text states — a real diff is prose in
the code sense and reads from the left margin. */
.df-result {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
text-align: left;
}
/* Correctness is global, chrome is scoped. The same card renders into
.approval-preview, which already has its own border, radius and a 30vh budget —
a second bordered card in there would read as a box in a box and eat the height.
So the typography, the gutter and the chips below apply everywhere (that surface
needs those fixes just as much) while the card's own border/fill only applies
inside the viewer. */
.df-file {
min-width: 0;
}
.df-viewer .df-file {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
/* .df-file-header's flex/gap belong to the W4 rule above; these are the properties
it never had, declared on a child selector so that block stays byte-for-byte
untouched. Wrapping is not optional: at 344px a renamed file's "old → new" path
plus two counts, a status chip and a Stage button cannot share one line.
Scoped to .df-viewer for the same reason the card chrome is: this is the header's
own box, and .approval-preview (tabs.ts:492-501, which calls renderDiffFile) already
declares `padding: 8px 10px` of its own — unscoped, the header would nest a second
one inside it. The line-kind rules further down (.df-line/.df-added/.df-removed/…)
stay global on purpose: the approval preview should render a diff the same way, and
the sign gutter and the tints are the part it most needs.
Smaller unscoped padding, not none. Scoping removed the DOUBLE inset; it should not
have removed the inset. With only W4's `display:flex` left, the path in the approval
preview sat flush against the preview's edge and flush against .df-hunk-block's
border-top while every sibling kept its own (.df-hunk-header 5px 12px, .df-line
0 12px 0 6px, .df-binary 9px 12px — all still global). 6px 12px 6px 6px is a header
inset that adds to the preview's 8px 10px instead of doubling it; the scoped rule
below is (0,3,0) against this (0,2,0), so nothing inside the viewer changes.
A <button> does not inherit font-family, and the W4 .df-file-stage rule sets a size
but no family, so Stage was the one element in the panel rendering in the UA's Arial
beside three mono chips and a --ui-font path. One scoped line fixes it without
touching W4. */
.df-file > .df-file-header {
padding: 6px 12px 6px 6px;
}
.df-viewer .df-file > .df-file-header {
flex-wrap: wrap;
min-width: 0;
padding: 8px 10px;
}
.df-viewer .df-file-stage {
font-family: inherit;
}
/* A path is an identifier, so it reads as one, and it is the header's elastic
column. End-ellipsis rather than a second truncation idiom — .proj-wt-path makes
the same call for the same reason. (The phone gets a full row of its own; see the
media query.)
`flex: 1 1 0` and not `1 1 auto`: with an `auto` basis the path's hypothetical
size is its max-content width, so in a wrapping header the browser breaks the line
before it ever shrinks the item — the ellipsis never fired, and a long path evicted
the status chip and Stage onto a second row instead (61px header vs 38px). A zero
basis makes shrinking the first resort, which is what makes the truncation real. */
.df-path {
flex: 1 1 0;
min-width: 0;
font-family: var(--mono-font);
font-size: 12.5px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Counts are data: mono and tabular, so +9/-9 and +10/-10 do not jitter the chip
beside them as you scan down a list. Both are always rendered, even as "+0 -0"
(diff.ts:243-245), which is why neither shouts. */
.df-stats {
display: inline-flex;
flex: none;
gap: 8px;
font-family: var(--mono-font);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.df-stat-added {
color: var(--dirty);
}
.df-stat-removed {
color: var(--warn);
}
/* One chip language across the panel — the same recipe as .proj-wt-tag, so a status
here and a tag on a worktree row read as the same kind of object. Colour is left
to the variants: all six are covered below, so declaring one here would be dead. */
.df-status {
flex: none;
font-family: var(--mono-font);
font-size: 11px;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 6px;
background: var(--sunk);
border: 1px solid var(--border);
}
/* Six statuses, three tiers — one colour each would be confetti on a 20-file list.
The ordinary ones are the quiet baseline that makes the other two legible.
--text-dim, not --text-faint: this chip is the ONLY place a file's status appears,
and --text-faint at 11px is 3.67:1 on --sunk. --text-dim is 7.76:1 and still reads
as the tier below --text, so the baseline stays quiet without going unreadable. */
.df-status-modified,
.df-status-renamed,
.df-status-binary {
color: var(--text-dim);
}
.df-status-added {
color: var(--dirty);
}
/* New AND not yet known to git. The dashed hairline is a deliberate new idiom — the
panel's chips are otherwise all solid — because it is the only way to separate
untracked from added without spending a fifth colour on a six-value enum. */
.df-status-untracked {
color: var(--dirty);
border-style: dashed;
}
.df-status-deleted {
color: var(--warn);
}
/* Stands in for the hunks that will never come, so the card keeps its shape. */
.df-binary {
padding: 9px 12px;
border-top: 1px solid var(--border);
background: var(--sunk);
font-family: var(--mono-font);
font-size: 12px;
color: var(--text-faint);
}
/* Code drops a level below the card, the way the design sinks read-only data below
the page. The border-top doubles as the divider between hunks, which is why the
header carries none. */
.df-hunk-block {
border-top: 1px solid var(--border);
background: var(--sunk);
}
/* "@@ -1,3 +1,4 @@" is a coordinate label, not content: quiet enough to scan past,
present enough to find — but findable means legible, so --text-dim (7.12:1 on
--surface-1) rather than --text-faint, which at 11px was 3.36:1. The rule the viewer
follows: --text-faint is kept where the mark is decoration rather than a statement,
and never where it is the only thing saying something.
One line — a wrapped @@ header is not more informative. */
.df-hunk-header {
padding: 5px 12px;
background: var(--surface-1);
border-bottom: 1px solid var(--border);
font-family: var(--mono-font);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* THE containment point. This is the one box whose content is unboundedly wide, so
this is where the scroll lives: a 300-column line scrolls inside its own hunk and
the page never moves. Without it .proj-panel scrolls sideways — it declares
overflow-y only, and per spec a visible axis paired with a non-visible one
computes to auto. `* { scrollbar-width: thin }` already themes the bar.
Per-hunk and NOT hoisted to .df-viewer .df-file, though that would give one
scrollbar per file instead of one per hunk: .df-file-header is sized to the
container, not to the scroll width, so a file-level scroller drags the file path
out of view the moment you scroll right and you lose the label for the code you
are reading. Deliberate — do not "fix" it. */
.df-lines {
overflow-x: auto;
padding: 4px 0;
}
/* white-space:pre and the mono face are correctness, not polish: with the marker
stripped server-side the leading whitespace IS the indentation and nothing else
encodes it. width:max-content + min-width:100% is what makes a row's tint span the
whole line rather than stopping at the visible edge of the scroller. */
.df-line {
display: block;
width: max-content;
min-width: 100%;
box-sizing: border-box;
padding: 0 12px 0 6px;
font-family: var(--mono-font);
font-size: 12px;
line-height: 1.5;
white-space: pre;
tab-size: 4;
color: var(--text);
}
/* The sign the server threw away, put back as a real character in a two-column mono
gutter (1ch glyph + 1ch space, so the code below stays column-aligned). Colour
must not be the only encoding of added-vs-removed, and this is the only way to
restore the shape without touching diff.ts. user-select:none keeps a copied diff
clean, sign-free code — Firefox does put ::before content on the clipboard.
Assumed: every face in --mono-font has a weight-invariant advance. `ch` resolves
against the pseudo-element's OWN font, and .df-added/.df-removed make it bold while
context lines stay regular, so a mono face whose bold digit-zero is wider would shift
changed lines horizontally against unchanged ones and break the column alignment this
gutter exists to keep. The named faces in the stack (ui-monospace, SFMono-Regular,
Menlo, Consolas) hold the advance across weights; the last entry, the generic
`monospace`, resolves to whatever the platform picks and is not required to. Keep
the units in ch regardless — that is what keeps the gutter on the mono grid; if a
fallback face ever does drift, change the FONT, not the unit. */
.df-line::before {
content: ' ';
display: inline-block;
width: 1ch;
margin-right: 1ch;
text-align: center;
color: var(--text-faint);
-webkit-user-select: none;
user-select: none;
}
/* Kind modifiers — after .df-line, same specificity, later wins.
The row wash follows the house "this row is in the marked set" idiom from
.proj-commit-unpushed (a low-alpha tint plus a bar), flat rather than that rule's
90deg fade because a diff line is arbitrarily long and a fade to transparent at
55% would drop the tint off the end of exactly the lines you most need marked.
The bar is box-shadow so ::before stays free for the sign.
The two washes are deliberately NOT the same hue at two alphas: --dirty and --warn
are both warm oranges, and at wash strength over --sunk they compute within four
points of each other — indistinguishable, so the tint would be doing no work.
Insertions take a neutral white lift instead (the same rgba(255,255,255,…) idiom
as --border), deletions take the warm --warn wash. Neutral-lift vs warm-wash reads
at a glance, and it spends no new hue — green is not available here, per :root
L23-29. Full-strength colour stays on the bar and the +/- glyph.
The insertion lift is .085 and not .045: at .045 it composited to #171614, ΔL* 4.3
against --sunk (1.09:1) — invisible — while the deletion wash lands at ΔL* 13.2 plus
a hue shift. Deletions read instantly and insertions read as context, which is
backwards for the half you are about to commit. At .085 the lift is ΔL* 9.1, still
the quieter of the two, which is the intended order.
The deletion wash is 24% and not 16%. At 16% the two composited to within 1.0 L* of
each other, so added-vs-removed rested almost entirely on a low-chroma hue shift —
and the two full-strength cues both live in the first 6px of the line, so scrolling a
long line right inside .df-lines carries the inset bar and the ::before glyph out of
the scrollport and leaves the wash alone to do the job. 24% separates in the browser
and is still warm rather than loud. The insertion lift stays at .085: neutral vs warm
is the axis, and lifting both would just re-close the gap.
The bar is 3px and the glyphs are bold for the same reason from the other end: on a
greyscale or dimmed phone screen the wash is all but gone, so the sign and the rule
down the side have to carry the distinction by shape alone. */
.df-added {
background: rgba(255, 255, 255, 0.085);
box-shadow: inset 3px 0 0 var(--dirty);
}
.df-added::before {
content: '+';
font-weight: 700;
color: var(--dirty);
}
/* One declaration, one token, and the token derives from --warn in :root — so a
re-skin of --warn moves the wash together with the bar and the glyph below instead
of leaving the row two hues. The colour lives in :root because that is where
THEME.md expects every colour to live (§接入约定: 不要写死颜色). */
.df-removed {
background: var(--warn-wash);
box-shadow: inset 3px 0 0 var(--warn);
}
.df-removed::before {
content: '-';
font-weight: 700;
color: var(--warn);
}
/* Most of a diff is context. Stepping it down to --text-dim is what makes the
handful of tinted lines legible at a glance — the changed lines keep the base
--text and are the only full-strength text in the well. */
.df-context {
color: var(--text-dim);
}
/* "\ No newline at end of file" — informational, rare, and the server strips the
leading backslash too (src/http/diff.ts:148 does .slice(1).trim()), so the glyph
is restored here for the same reason as +/-.
--text-dim, not --text-faint, for the .tl-empty / .df-status reason: --text-faint at
12px is 3.66:1 on --sunk and this line is the ONLY place the missing newline is ever
stated — unlike .df-binary, whose fact the `binary` status chip repeats in --text-dim
right above it. That puts it level with .df-context rather than below it, and no rank
is drawn: the italic only marks it as not-code. Deliberate — the fact is stated
once, so legibility outranks hierarchy here. */
.df-meta {
color: var(--text-dim);
font-style: italic;
}
.df-meta::before {
content: '\\';
}
/* Line kind 'hunk'. The current server never emits it (the @@ line is routed to
DiffHunk.header), so this is reachable only through a hand-crafted API response —
styled defensively so it cannot render raw, with no design spent on it. Note this
is the LINE class: .df-hunk-header and .df-hunk-block above are different elements
that merely share a prefix. */
.df-hunk {
background: var(--surface-1);
color: var(--text-faint);
}
.df-hunk::before {
content: '@';
}
/* .df-empty and .df-truncated-warning can both be on screen at once (a truncated
result with zero files), so neither may assume it is alone.
Own text-align because .df-result (its parent) sets left.
A bare centred sentence in a 90px void, geometrically identical to .df-error and
differing only in colour, sitting directly above a full commit form, reads as
"something failed to load" rather than "clean working tree". A mark and a tighter
void is the whole fix — it stays CSS-only, so diff.ts keeps its zero-innerHTML
guarantee. An em-dash and NOT a check mark: --ok and ✓ are spoken for by the sync
band's verified-in-sync state (:root L23-29), and "nothing to commit" is not that
claim. The dash says "nothing here", which is exactly what is true. */
.df-empty {
padding: 20px 0;
text-align: center;
font-size: 13px;
color: var(--text-dim);
}
.df-empty::before {
content: '—';
display: block;
font-family: var(--mono-font);
font-size: 18px;
line-height: 1;
color: var(--text-faint);
margin-bottom: 8px;
}
/* Cautionary, not fatal — you are looking at part of the truth, not a failure. A
--warn rule down the side rather than a --warn sentence, which is what keeps it
distinct from .df-error. */
.df-truncated-warning {
padding: 7px 10px;
background: var(--sunk);
border: 1px solid var(--border);
border-left: 2px solid var(--warn);
border-radius: 7px;
font-size: 12px;
color: var(--text-dim);
}
/* Replaces the whole content area (diff.ts:559-560), so it is a full-viewer state
and is drawn in the same register as .df-empty, which occupies the same slot.
--warn is the panel's failure colour (.proj-wt-error, .proj-fanout-error); --red
stays reserved for connection state and for .df-op-error in the bar below, and two
different reds would claim the two failures are the same kind. */
.df-error {
padding: 24px 0;
text-align: center;
font-size: 13px;
color: var(--warn);
}
/* Must sit AFTER the rules above — a media query adds no specificity, so an earlier
block at equal specificity simply loses. The viewer's edge-to-edge bleed at this
width lives with .proj-diff-panel, beside the panel padding it cancels. */
@media (max-width: 720px) {
/* A renamed path is two paths. Give it the whole row, so the counts, the status
chip and Stage keep a row of their own instead of the path squeezing to ~200px
and stranding Stage on a line by itself. The row is all it takes: the desktop
nowrap + end-ellipsis is inherited as-is now that `flex: 1 1 0` makes it fire.
It used to also switch to overflow-wrap:anywhere, which broke ".../module.ts →"
mid-word at 380px and orphaned the extension you scan for — and made this the
page's THIRD truncation idiom for paths. One idiom now. */
.df-path {
flex: 1 1 100%;
}
/* The basis is what decides where the rail breaks: below it the picker drops to a
line of its own and the tabs hold line 1 alone (which is the void the ≤410 block
at the bottom of the file fills), above it the picker joins them. That seam is not
a number worth writing down — it moves with the UI font and with whether the
panel's scrollbar takes width — so resize the panel and watch it rather than trust
a figure here.
The cap is raised from the desktop 200px rather than removed: with `max-width:
none` the picker grew to most of the rail at tablet widths, which is exactly what
the .df-toolbar comment rules out at desktop. */
.df-base-select {
flex: 1 1 210px;
max-width: 280px;
}
/* The message takes its own row so Commit and Push share the next one. At 390px the
220px basis plus Commit (81) plus Push (63) plus gaps overflowed the 360px content
box and broke the line before Push, orphaning it. Scoped to .df-viewer so the W4
rule stays byte-for-byte untouched — the same technique as .df-viewer
.df-file-stage. */
.df-viewer .df-commit-msg {
flex: 1 1 100%;
}
.df-content {
padding: 8px 6px;
}
/* Half a point of density. The gutter is in ch, so it tracks the smaller type and
the sign stays exactly two characters wide. */
.df-line {
font-size: 11.5px;
line-height: 1.45;
}
}
/* The stretch is scoped to the one band where the tabs are alone on their line, and
nowhere wider. Alone, they are two small pills with the rest of the line dead beside
them; stretched, that dead rail becomes tap target.
It must NOT extend to the whole ≤720px band, which is where it used to live. Once
the picker joins the tabs on line 1, a zero basis makes them absorb every pixel the
picker's cap frees — and those pixels are the auto margin that pins ✕ Close to the
right edge, so the dismiss control drifted back into the group.
410px and not 720px because the seam is the width at which the picker's basis can
first share the tab line, and that width is not one number: it moves with the UI
font, and the panel is narrower by a classic scrollbar on desktop than by an overlay
one on a phone. 410 covers the wider of the two models. Erring that way leaves the
rule live but idle across the few pixels where the phone model has already put the
picker on the tab line — which costs nothing — while erring the other way would
leave the void unfixed on the devices that have it. Not pinned tighter than the
nearest ten, for the same reason.
`flex: 1 1 0` and not `1 1 auto`: a zero basis still has min-width:auto, so each
tab's hypothetical size stays at its min-content word width and the line-breaking
above is computed from the same numbers either way — the wrap survives, only the
distribution of the leftover changes. */
@media (max-width: 410px) {
.df-tab {
flex: 1 1 0;
}
}