diff --git a/CLAUDE.md b/CLAUDE.md index e6792be6..24bb3669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,7 +254,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Phone overview home screen** (`mobile-overview.js`, phones only, per-device `mobileOverviewEnabled`, default ON): under 430px the "C" logo shows a session overview (NEEDS YOU / CURRENT SESSIONS / PAST SESSIONS) instead of the welcome overlay; tablet and desktop are unchanged. The branch lives in `showWelcome()`/`hideWelcome()` (terminal-ui.js) behind `shouldUseMobileOverview()`, which is **width-driven** (`getDeviceType() === 'mobile'`) because this is a layout decision, unlike the settings namespace which stays handheld-based. ⚠️ The container ships with the `hidden` attribute and only this module removes it: never give `.mobile-overview` a bare `display` rule, since desktop does not load `mobile.css` (`media="(max-width: 1023px)"`) and would then render it unstyled. Live re-renders ride on the tail of `_renderSessionTabsImmediate()` (every state change it needs already funnels there); PAST rows come from one `_fetchUnifiedSessions(60)` per home-screen visit and resume through the shared `resumeHistorySession()`, so they behave exactly like the welcome screen's Resume list. ⚠️ Two things must stay in lockstep with surfaces outside this module, because divergence reads as a bug rather than a style: the split Run button carries the **toolbar's own classes** (`btn-toolbar btn-run mode-` / `btn-run-gear`) so the per-backend gradient and the light-skin overrides apply unchanged (mobile.css must therefore set no `background`/`color` on it), and row status uses the **session-tab language** (green dot when fine, `pulse` while working, yellow blinking row when waiting for input, red blinking row when a question is pending, mirroring `tab-alert-idle`/`tab-alert-action`). The picker mirrors the toolbar run-mode menu (`setRunMode()` + `run()`, `openWebviewFromMenu()` for saved dashboards) and deliberately omits its Recent-Sessions block, since PAST SESSIONS is that. Status pills carry `data-i18n-skip` (generic words like "idle" collide with state strings elsewhere). -**Desktop home tab column** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it now carries the open tabs as a vertical list. Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The column is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a column overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. +**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay centers ~560px of content in a ~1400px window, so its left gutter is dead space; it carries the open tabs as a rail **docked flush to the left edge, full height** (a vertically centered card floating mid-gutter read as debris). Rows are in **tab order**, not sorted by urgency like the phone overview, because the row badges are the Alt+1..9 indices, and each carries **created / last-active** stamps. State classification is REUSED from mobile-overview.js (`_mobileOverviewState`/`_mobileOverviewCaseFor`), which is why the module loads after it. ⚠️ The rail is `position: absolute` so the centered content never moves, which is exactly why it needs a **width gate in two places** — `HOME_SESSIONS_MIN_WIDTH` (1180) in the JS plus a `max-width: 1179px` media query as the backstop for a resize that outruns the matchMedia listener; drift between them means a rail overlapping the search panel, and `test/home-sessions.test.ts` pins them equal. ⚠️ `.home-sessions` is `display: flex`, so `[hidden]` must be re-asserted as `display: none` or the module's only visibility lever does nothing. ⚠️ Size scales with the viewport off **one knob**: `width: clamp(250px, 19vw, 430px)` plus a fluid `font-size` on `.home-sessions`, with every child sized in `em` — reintroducing `rem`/px type inside the block silently breaks the scaling, and widening the clamp past the gutter reintroduces the overlap the gate exists to prevent. The age stamps are refreshed **in place** by a 20s clock (`_tickHomeSessionsTimes()`, disarmed in `hideHomeSessions()`), never by re-rendering, which would restart every row's blink and working ring. Working state is deliberately byte-identical to the phone's: pulsing green dot + the `tab-load-spin` ring reused from the tab strip + the same green halo (added to `.mobile-overview-dot--working` at the same time), so "working" reads the same on every surface; **idle** is deliberately NOT that green — dot and pill mix toward `--text-muted` so a glance separates running from sitting. Live re-renders ride the tail of `_renderSessionTabsImmediate()` alongside the phone overview. **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. ⚠️ **Smart copy (`Ctrl+C`)** lives in that same handler: with a selection it copies, with none it must `return true` **without** `preventDefault()` or the interrupt is lost. `copyTerminalSelection` is deliberately absent from `SHORTCUT_ACTIONS` because the generic capture loop preventDefaults every match it dispatches. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 032596da..38473f3f 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -1,6 +1,6 @@ /** - * @fileoverview Desktop home screen session list: the open tabs as a vertical - * column down the left of the welcome overlay. + * @fileoverview Desktop home screen session list: the open tabs as a rail docked + * down the left edge of the welcome overlay. * * The welcome screen centers ~560px of content in a window that is usually * 1400px+, so the two gutters are dead space. The left one now carries the same @@ -8,11 +8,19 @@ * one row per live tab, in TAB ORDER (not sorted by state) so it reads as the * tab strip rotated, and so Alt+1..9 still matches what you see. * - * DESKTOP ONLY, and only in a wide enough window: the column is absolutely + * DESKTOP ONLY, and only in a wide enough window: the rail is absolutely * positioned so the centered welcome content never moves, which means it can - * only exist where the gutter is genuinely wider than the column. Below + * only exist where the gutter is genuinely wider than the rail. Below * `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns - * the home screen entirely and this surface stays out of its way. + * the home screen entirely and this surface stays out of its way. Width and type + * both scale with the viewport (see the `.home-sessions` block in styles.css) — + * a fixed 256px card looks abandoned on a 2560px display. + * + * Each row carries when the session was FIRST CREATED and when it was LAST + * ACTIVE, both relative. Those two stamps go stale on their own (a sitting + * session emits no event), so a slow clock refreshes them IN PLACE from the + * epoch-ms values parked on the elements, rather than re-rendering: a re-render + * would restart every row's blink animation and its working ring. * * The working state is deliberately identical to the phone's: a pulsing green * dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused @@ -27,19 +35,23 @@ * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession) * @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview) + * @dependency ralph-panel.js (formatRelativeTime — the app's one relative-time formatter) * @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview) * @dependency mobile-handlers.js (MobileDetection) * @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js */ /** - * Narrowest window that gets the column. The welcome content is 560px wide and - * centered, so at 1180px each gutter is 310px — enough for the 256px column plus - * its 20px offset and still a visible gap. Anything narrower would overlap the + * Narrowest window that gets the rail. The welcome content is 560px wide and + * centered, so at 1180px each gutter is 310px — enough for the rail at its + * 250px floor and still a visible gap. Anything narrower would overlap the * search panel, which is why this is a width gate and not a device-type gate. */ const HOME_SESSIONS_MIN_WIDTH = 1180; +/** How often the relative stamps are rewritten while the home screen is up. */ +const HOME_SESSIONS_CLOCK_MS = 20000; + /** Pill copy per state. Same words as the phone overview, same reasons. */ const HOME_SESSIONS_PILL_LABEL = { needs: 'needs you', @@ -87,6 +99,7 @@ Object.assign(CodemanApp.prototype, { this._wireHomeSessions(el); if (!this.shouldShowHomeSessions()) { el.hidden = true; + this._stopHomeSessionsClock(); return; } el.hidden = false; @@ -96,6 +109,7 @@ Object.assign(CodemanApp.prototype, { hideHomeSessions() { const el = document.getElementById('homeSessions'); if (el) el.hidden = true; + this._stopHomeSessionsClock(); }, /** Re-render only when showing (called from the tab renderer's tail). */ @@ -173,6 +187,10 @@ Object.assign(CodemanApp.prototype, { dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', state, pill: HOME_SESSIONS_PILL_LABEL[state] || state, + // Epoch ms, straight off the session payload; formatting happens at + // render time so the clock below can redo it without a re-render. + createdAt: Number(session.createdAt) || 0, + lastActivityAt: Number(session.lastActivityAt) || 0, }; }); }, @@ -193,6 +211,7 @@ Object.assign(CodemanApp.prototype, { if (!rows.length && !webviews.length) { el.hidden = true; el.replaceChildren(); + this._stopHomeSessionsClock(); return; } el.hidden = false; @@ -205,6 +224,95 @@ Object.assign(CodemanApp.prototype, { for (const row of rows) list.appendChild(this._buildHomeSessionRow(row)); for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview)); el.appendChild(list); + + this._startHomeSessionsClock(); + }, + + // ═══════════════════════════════════════════════════════════════ + // Age stamps: created / last active + // ═══════════════════════════════════════════════════════════════ + + /** + * The "created 2h ago · active 3m ago" footer line. Both stamps keep their raw + * epoch-ms on the element (`data-hs-ts`) so `_tickHomeSessionsTimes()` can + * rewrite the text without rebuilding the row. + */ + _buildHomeSessionsMeta(row) { + const meta = document.createElement('span'); + meta.className = 'home-sessions-row-meta'; + // Relative times are generated text, and "created"/"active" here are the + // same generic words that mean something else on other surfaces. + meta.setAttribute('data-i18n-skip', ''); + + meta.appendChild(this._buildHomeSessionsStamp('created', row.createdAt, 'home-sessions-meta-created')); + + const sep = document.createElement('span'); + sep.className = 'home-sessions-meta-sep'; + sep.setAttribute('aria-hidden', 'true'); + sep.textContent = '·'; + meta.appendChild(sep); + + meta.appendChild(this._buildHomeSessionsStamp('active', row.lastActivityAt, 'home-sessions-meta-active')); + + return meta; + }, + + /** One labelled stamp: a dim key, the relative value, full date in the title. */ + _buildHomeSessionsStamp(key, timestamp, className) { + const wrap = document.createElement('span'); + wrap.className = `home-sessions-meta-item ${className}`; + + const label = document.createElement('span'); + label.className = 'home-sessions-meta-key'; + label.textContent = key; + wrap.appendChild(label); + + const value = document.createElement('span'); + value.dataset.hsTs = String(timestamp || 0); + value.textContent = this._homeSessionsAgo(timestamp); + wrap.appendChild(value); + + if (timestamp) + wrap.title = `${key === 'created' ? 'First created' : 'Last active'}: ${new Date(timestamp).toLocaleString()}`; + return wrap; + }, + + /** Relative label for a stamp. `formatRelativeTime` is the app's one formatter. */ + _homeSessionsAgo(timestamp) { + if (!timestamp) return '—'; + return this.formatRelativeTime(timestamp) || '—'; + }, + + /** + * Rewrites the stamps in place every `HOME_SESSIONS_CLOCK_MS`. In place, not a + * re-render: replacing the rows would restart the blink animation on every + * waiting row and the ring on every working one, twice a minute, for nothing. + */ + _startHomeSessionsClock() { + if (this._homeSessionsClock) return; + this._homeSessionsClock = setInterval(() => { + if (!this.isHomeSessionsVisible()) { + this._stopHomeSessionsClock(); + return; + } + this._tickHomeSessionsTimes(); + }, HOME_SESSIONS_CLOCK_MS); + }, + + _stopHomeSessionsClock() { + if (!this._homeSessionsClock) return; + clearInterval(this._homeSessionsClock); + this._homeSessionsClock = null; + }, + + _tickHomeSessionsTimes() { + const el = document.getElementById('homeSessions'); + if (!el) return; + for (const node of el.querySelectorAll('[data-hs-ts]')) { + const ts = Number(node.dataset.hsTs) || 0; + const text = this._homeSessionsAgo(ts); + if (node.textContent !== text) node.textContent = text; + } }, _buildHomeSessionsHeader(count) { @@ -287,6 +395,9 @@ Object.assign(CodemanApp.prototype, { pill.textContent = row.pill; item.appendChild(pill); + // Wraps onto its own line (the row is flex-wrap) so it gets the full width. + item.appendChild(this._buildHomeSessionsMeta(row)); + return item; }, diff --git a/src/web/public/index.html b/src/web/public/index.html index 6ec66057..a7bde739 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -323,9 +323,9 @@
-
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 46f890e9..20bff7c7 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14014,15 +14014,24 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { } /* ══════════════════════════════════════════════════════════════════════════ - Home screen: open tabs in the left gutter (home-sessions.js) + Home screen: open tabs docked down the left edge (home-sessions.js) - The welcome content is 560px wide and centered, so this column lives in dead + The welcome content is 560px wide and centered, so this rail lives in dead space. It is `position: absolute` precisely so that stays true: the centered - content does not move by a pixel whether the column renders or not. That in + content does not move by a pixel whether the rail renders or not. That in turn is why the width gate below has to exist — in a narrow window there is no gutter to sit in, and an absolute box would simply overlap the search panel. JS gates on the same 1180px so the two can never disagree. + It is FLUSH to the left edge and FULL HEIGHT on purpose: a floating, vertically + centered card in the middle of the gutter reads as debris, a docked rail with + an edge to hang off reads as part of the chrome. + + Everything inside scales off ONE knob — the `font-size` below — because every + child sizes in `em`. Widen the window and the whole rail grows with it instead + of leaving 12px type stranded on a 2560px display. Do not reintroduce `rem` + or fixed px sizes for type/spacing here; that is what breaks the scaling. + The working dot is the phone's, exactly: pulsing green ringed by the very same `tab-load-spin` a tab shows while it loads (reused from above, never re-declared), plus a green halo. One signal, one motion, both home screens. @@ -14030,14 +14039,20 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions { position: absolute; - left: 20px; - top: 50%; - transform: translateY(-50%); + left: 0; + top: 0; + bottom: 0; display: flex; flex-direction: column; - gap: 8px; - width: 256px; - max-height: calc(100% - 3rem); + gap: 0.9em; + /* Ramps with the viewport, floored so it still clears the centered content at + the 1180px gate and capped so it never becomes the main event. */ + width: clamp(250px, 19vw, 430px); + padding: 1.5em 1.1em; + background: linear-gradient(90deg, color-mix(in srgb, var(--bg-card) 65%, transparent) 0%, transparent 100%); + border-right: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + /* The single scale knob: ~12px at the gate, ~17px on a 2560px display. */ + font-size: clamp(12.2px, calc(8px + 0.35vw), 17px); text-align: left; z-index: 1; } @@ -14059,12 +14074,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-header { display: flex; align-items: center; - gap: 8px; - padding: 0 6px; + gap: 0.66em; + flex-shrink: 0; + padding: 0 0.5em; } .home-sessions-title { - font-size: 0.66rem; + font-size: 0.87em; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; @@ -14075,25 +14091,29 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { display: inline-flex; align-items: center; justify-content: center; - min-width: 18px; - height: 16px; - padding: 0 5px; + min-width: 1.5em; + height: 1.35em; + padding: 0 0.42em; border-radius: 999px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-dim); - font-size: 0.6rem; + font-size: 0.79em; font-weight: 700; font-family: monospace; } +/* `min-height: 0` is what lets this scroll inside the full-height rail: without + it the flex item's auto minimum keeps growing and the overflow never engages. */ .home-sessions-list { display: flex; flex-direction: column; - gap: 4px; + gap: 0.35em; + flex: 1 1 auto; + min-height: 0; overflow-y: auto; overflow-x: hidden; - padding: 2px 2px 6px; + padding: 0.16em 0.16em 0.5em; } .home-sessions-list::-webkit-scrollbar { @@ -14108,15 +14128,16 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-row { display: flex; align-items: center; - gap: 8px; + flex-wrap: wrap; + gap: 0.66em; width: 100%; - padding: 7px 9px; - border-radius: 9px; + padding: 0.6em 0.75em; + border-radius: 0.75em; background: var(--bg-card); border: 1px solid var(--border); color: var(--text-dim); font-family: inherit; - font-size: 0.76rem; + font-size: 1em; text-align: left; cursor: pointer; transition: background var(--transition-smooth), border-color var(--transition-smooth), color var(--transition-smooth); @@ -14136,14 +14157,14 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { display: inline-flex; align-items: center; justify-content: center; - width: 15px; - height: 15px; + width: 1.3em; + height: 1.3em; flex-shrink: 0; - border-radius: 3px; + border-radius: 0.25em; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); - font-size: 0.58rem; + font-size: 0.76em; font-weight: 700; font-family: monospace; } @@ -14151,8 +14172,8 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-dot { position: relative; flex-shrink: 0; - width: 9px; - height: 9px; + width: 0.75em; + height: 0.75em; border-radius: 50%; background: var(--text-muted); } @@ -14166,8 +14187,13 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { background: var(--yellow); } +/* Idle is green, but a MUTED green: still unmistakably "fine", visibly greyer + and darker than the vivid green a working session gets, so a glance down the + rail separates "running right now" from "sitting there" without reading a + word. Mixed toward --text-muted rather than hardcoded, so every skin keeps + its own green. */ .home-sessions-dot--idle { - background: var(--green); + background: color-mix(in srgb, var(--green) 42%, var(--text-muted)); } .home-sessions-dot--done { @@ -14189,7 +14215,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-dot--working::after { content: ''; position: absolute; - inset: -4px; + inset: -0.34em; border: 2px solid color-mix(in srgb, var(--green) 25%, transparent); border-top-color: var(--green); border-radius: 50%; @@ -14199,7 +14225,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-row-body { display: flex; flex-direction: column; - gap: 1px; + gap: 0.12em; min-width: 0; flex: 1; } @@ -14207,7 +14233,7 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-row-title { display: flex; align-items: center; - gap: 5px; + gap: 0.42em; min-width: 0; color: var(--text); font-weight: 600; @@ -14220,21 +14246,66 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { } .home-sessions-row-sub { - font-size: 0.66rem; + font-size: 0.87em; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Age line: when the session was first created and when it last did anything. + Both are relative and refreshed in place on a timer (home-sessions.js), never + by a re-render — a re-render would restart the row's blink animation. */ +.home-sessions-row-meta { + display: flex; + align-items: center; + gap: 0.4em; + /* Wraps to its own full-width line under the row (the row is `flex-wrap`), + so the stamps get the whole width instead of the sliver left beside the + status pill and ellipsize away on the narrow end of the rail. */ + flex: 0 0 100%; + min-width: 0; + margin-top: -0.2em; + font-size: 0.78em; + font-family: monospace; + color: var(--text-muted); + opacity: 0.75; + white-space: nowrap; + overflow: hidden; +} + +.home-sessions-meta-item { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.home-sessions-meta-key { + margin-right: 0.35em; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.home-sessions-meta-sep { + opacity: 0.45; +} + +/* The freshest signal on the row: while a session is actually doing something, + its "active" stamp is the one the eye should land on. */ +.home-sessions-row--working .home-sessions-meta-active { + color: var(--green); + opacity: 0.95; +} + .home-sessions-mode { flex-shrink: 0; - padding: 0 4px; - border-radius: 3px; + padding: 0 0.35em; + border-radius: 0.25em; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); - font-size: 0.55rem; + font-size: 0.72em; font-weight: 700; font-family: monospace; text-transform: uppercase; @@ -14242,12 +14313,12 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { .home-sessions-pill { flex-shrink: 0; - padding: 2px 6px; + padding: 0.2em 0.55em; border-radius: 999px; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); - font-size: 0.58rem; + font-size: 0.76em; font-weight: 700; letter-spacing: 0.02em; white-space: nowrap; @@ -14266,13 +14337,20 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover { color: var(--yellow); } -.home-sessions-pill--working, -.home-sessions-pill--idle { +.home-sessions-pill--working { background: color-mix(in srgb, var(--green) 15%, transparent); border-color: color-mix(in srgb, var(--green) 40%, transparent); color: var(--green); } +/* Same muting as the idle dot, one step further: the pill is a block of color, + so it reads louder than a 9px dot at the same mix. */ +.home-sessions-pill--idle { + background: color-mix(in srgb, var(--green) 7%, transparent); + border-color: color-mix(in srgb, var(--green) 18%, var(--border)); + color: color-mix(in srgb, var(--green) 45%, var(--text-muted)); +} + /* Row accents: same language as the session tabs and the phone overview — red means a question is pending, yellow means it wants input, green means work is happening. Nothing else on this screen may reuse these colors. */