diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 6e4ffc2..4ec14f1 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,28 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🎨 [x] Project 详情页 diff 面板对齐设计(2026-07-30,worktree `diff-panel-design`) + +- **起因**: 用户给了张 project 详情页截图,只说"这里的 ui 还没有对齐设计"。截图里可见的是 `Working / Staged / Working tree ▾ / ✕ Close` 四个原生白按钮和一行裸体 `No changes`。 +- **真实范围比截图大得多**: 写了个脚本把 `public/*.ts` 里所有 class(**含模板字面量**,`el('div', \`df-status df-status-${'$'}{...}\`) 这类)与 `style.css` 的选择器对拍 —— **diff 面板整套 52 个 class 在 style.css 里一条规则都没有**。不只是按钮:`.df-line/.df-added/.df-removed` 也缺,所以 diff 正文既没有等宽字体也**没有任何 +/- 着色**。第一版范围清单漏了模板字面量那批(`df-status-*` 六个、`df-line` 的 kind 类、`tl-icon-*` 五个),发现后**停掉已启动的 workflow、改正 SCOPE 再重跑** —— 否则产出的 CSS 会完全不管 diff 正文,即最重要的那部分。 +- **编排**: 5 轮 workflow / 21 个 agent。第 1 轮是**评委制**:recon → 3 份不同设计取向的 CSS 提案(贴合现有面板 / sunk 数据井 / code-review 可读性)→ 3 个正交视角(token 合规、DOM 正确性、手机可读性)各自给三份打分 → 合成。`code-review` 以 22.5 : 19 : 16 胜出。第 2–5 轮是"对抗复核 → orchestrator 分诊 → 修 → 再复核"。 +- **验证台(关键)**: 光靠 agent 算对比度会跑偏,所以搭了个可截图的台子 —— 临时 `public/_vis.ts` 用 esbuild 打包、stub `fetch` 喂 6 文件 fixture(六种 `FileStatus` + 空态 + 错误态 + 超长行 + 时间轴),html 放 scratchpad、`style.css` 做符号链接,`python3 -m http.server` + chrome-devtools MCP 截图。**这是真实 DOM,不是手写的仿制品**。900px 与 390×844 两档都过了一遍;删除行底色 16% vs 24% 是在浏览器里 A/B 出来的。收工前 `_vis.ts` 和所有 `.vis-*.png` 已删除(`git status` 只剩两个改动文件)。 +- **对抗复核抓到的、光看 CSS 看不出来的**(全部已修): + - `.df-viewer` 没写 `font-size`,而 W4 那三个控件都是 `font: inherit` → **Commit / Push 以 UA 默认 16px 渲染**,是整个面板最大的字,390px 下还把 Push 挤到单独一行。 + - **新增行底色几乎不可见**(1.088:1),删除行却一眼可辨 —— 恰好反了,你要提交的是新增那一半。 + - `.proj-tl-container` 的高度上限:`overflow-y:auto` 被 `mountTimeline` 每 5s 的 wipe-and-re-append 清掉滚动位置;换 `mask-image` 后 `mask-clip: border-box` **把井自己的底边和圆角一起吃掉**;换 `::after` 渐变后又**无条件触发**,把没被裁掉的行和空态那句话一起蒙掉(3.35:1,比它本来要修的 3.66:1 还差)。 + - `.df-file > .df-file-header` 没限定作用域,**漏到终端页**:`tabs.ts:492` 的 `.approval-preview` 也调 `renderDiffFile`,自带 padding,叠成双层。 + - base 对比模式下 `updateTabState` 只加 `df-tab-disabled`、**不摘 `df-tab-active`**,金色 pill 半透明留着 → 工具栏在撒谎;第一次修过头,`--text-faint × opacity .45` 把标签压到 1.64:1,截图确认是两个空白方块。 +- **orchestrator 的三个裁定**(都推翻了复核的建议): + 1. **时间轴高度上限整个去掉**。四种封顶机制(auto 滚动 / mask / ::after / `:has()` 行数门)每一种都比它省下的高度代价更大;行数门在这里其实**能**测准(`.tl-label` 是 nowrap),但仍然拒绝 —— 它还是个 cap。这条 CSS 出现之前 `.proj-tl-container` 根本没有规则、就是自然生长,`maxEvents = 50` 已经封了顶。注释里写清楚了理由,因为一定会有人想加回来。 + 2. **横向滚动条留在 `.df-lines`(每 hunk 一个),不上提到 `.df-file`** —— `.df-file-header` 按容器宽度排版,一提上去,右滚时文件路径就滑出视野,读代码时丢标签。 + 3. **删除行底色做成派生 token**。第 4 轮把 `color-mix` 换成 `rgba` 字面量,复核当场证明:改 `--warn` 时同一条规则里的竖条和字形跟着变、底色冻住,一行两种色且无任何报错。最终 `--warn-wash: color-mix(in srgb, var(--warn) 24%, transparent)`,已写进 THEME.md。 +- **顺带修的既有缺陷**: `.df-commit-btn` 硬编码 `#1a1712`(THEME.md 规定金底必须 `var(--on-accent)`);`.proj-pr-hidden` 是个没有规则的"隐藏"class;`.proj-dot-stuck` 缺规则(`ClaudeStatus` 有 `stuck`,dot 落回 `--text-faint`,与"没有 hook 信号"同色不可分),补 `--warn`,与 `.tl-icon-stuck` 一致。 +- **一个反复出现的教训**: 这个文件的注释是承重的,而连续三轮里**每一轮修注释的那轮自己又写进了新的假注释** —— 数着的绝对句("就这两处"、"另外五个按钮")和像素推导("content box = viewport − 20",漏了 11px 细滚动条)。最后一轮的规矩改成:**只写定性理由和代码真正用到的那一个阈值,其余像素数字和计数全删**。写不错的注释比今天精确的注释值钱。 +- **验证**: `npm run typecheck` exit 0;`npm test` **2216 单测 + 27 e2e 全绿**(CSS-only 改动,测试不读 style.css,列在这里只为证明没碰别的);esbuild 解析通过;52 个 class 逐一 grep 复核。 +- **诚实的未闭合项**: 全部验证走的是验证台,**没有在真机/真手机上开过这个面板**;`.df-truncated-warning` 需要超大 diff、`.df-status-binary` 需要二进制文件,fixture 造得出来但真实数据没跑过。另:project **网格**页的 `.proj-group-flat / -namespace / -other`(`projects.ts:1476`)同样无规则 —— 不在本次范围(不是详情页),留给下一轮;注意 `proj-group-active` 是个已有的**状态**修饰符,同前缀,若 `group.kind` 哪天出现 `active` 会撞车。 +- **commit**: (见本次提交) + ### 📱 [x] iOS 收尾波 — 6 项整改 + P2 全波 + 两端令牌(2026-07-30,worktree `ios-completion`) - **起因**: 先做了一次 16-agent 的 iOS 完成度审计(2026-07-29),结论是"代码层面基本完工、交付层面卡在真机门口":43 个计划任务 27 DONE / 11 PARTIAL / 5 MISSING,**从未在任何真机上跑过一次**,且已落后服务端两个月的新功能。用户要求"全部实现",并定了三个范围问题:P2 全 5 项在内、Apple 账号是**免费个人 team**、Android 令牌一起补。 diff --git a/docs/THEME.md b/docs/THEME.md index 0b79df7..efbb705 100644 --- a/docs/THEME.md +++ b/docs/THEME.md @@ -22,6 +22,7 @@ | `--accent-2` | `#c9892f` | 深金(渐变 / hover) | | `--accent-soft` | `rgba(227,166,74,.15)` | 强调色半透明(选中底、chip、聚焦光晕) | | `--on-accent` | `#1a1305` | **金色底上的文字色**(深墨,保证对比度——金底配白字会糊) | +| `--warn-wash` | `color-mix(in srgb, var(--warn) 24%, transparent)` | `--warn` 的 24% 半透明:diff 删除行底色。**由 `--warn` 派生**,换肤自动跟随,勿改写成 rgba 字面量(`--warn` 见 `:root` 的 Git 面板语义) | ## 状态色(语义独立,勿与强调色混用) @@ -33,7 +34,7 @@ | `--amber` | `#f5b14c` | 连接中 connecting | | `--red` | `#ff6b6b` | 断开 / 出错 disconnected / error | -> Project 面板里的 session 状态语义(v0.6):`working = --accent 金` · `waiting = --amber 琥珀(闪)` · `idle = 中性灰 #8b8a86` · `dirty = 暖琥珀`。 +> Project 面板里的 session 状态语义(v0.6):`working = --accent 金` · `waiting = --amber 琥珀(闪)` · `stuck = --warn` · `idle = 中性灰 #8b8a86` · `dirty = 暖琥珀`。 ## 接入约定(改主题只改这里) diff --git a/public/style.css b/public/style.css index 7b3ba29..da8979a 100644 --- a/public/style.css +++ b/public/style.css @@ -25,6 +25,12 @@ * 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 */ @@ -1869,12 +1875,40 @@ body { 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. - Every outline button in the panel is listed here, and nowhere else, so a new one - cannot quietly pick its own size again. */ + + 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 { +.proj-diff-toggle, +.df-tab, +.df-close { font: inherit; font-size: 12.5px; cursor: pointer; @@ -1888,15 +1922,26 @@ body { .proj-wt-open:hover, .proj-wt-remove:hover, .proj-wt-prune:hover, -.proj-diff-toggle: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 { +.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; } @@ -2247,6 +2292,13 @@ body { .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; } @@ -2517,7 +2569,8 @@ body { .proj-fanout-prompt:focus, .proj-fanout-lanes:focus, .proj-fanout-branch:focus, -.proj-fanout-mode:focus { +.proj-fanout-mode:focus, +.df-base-select:focus { border-color: var(--accent); } @@ -2533,10 +2586,13 @@ body { font-variant-numeric: tabular-nums; } -/* Without appearance:none WebKit keeps its own light control, so this was the one - widget in the panel that stayed a native grey dropdown. The arrow has to be - redrawn by hand once the native one is gone. */ -.proj-fanout-mode { +/* 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; @@ -2770,6 +2826,232 @@ body { 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 */ @@ -3119,7 +3401,7 @@ body.home-open #term { border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); - color: #1a1712; + color: var(--on-accent); cursor: pointer; } .df-push-btn { @@ -3146,3 +3428,647 @@ body.home-open #term { .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 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