refactor(ui-tui): DRY the chrome primitives — shared scrollbarColors + hintRgb

scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme
for both scrollbars (was duplicated formulas); textInput's hex parsing
collapses into one hintRgb helper feeding colorizeHint and hintCursorCell.
Comment bloat trimmed. No behavior change — 1243 tests byte-green.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:57:58 -05:00
parent 505b2de484
commit a0a9aaf8b8
5 changed files with 44 additions and 52 deletions

View file

@ -11,13 +11,14 @@ import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { mix } from '../lib/color.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { fmtK } from '../lib/text.js'
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
import { scrollbarColors } from './overlayPrimitives.js'
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
@ -754,14 +755,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
// Thumb rides in the theme's BASE color (primary), brightening to accent
// while hovered/dragged. The track recedes via an EXPLICIT blend toward the
// theme surface — never the SGR dim attribute: dim is terminal-interpreted,
// and on transparent profiles (terminal.background #00000000) xterm renders
// dim cells as half-bright glyphs on an opaque BLACK cell background. That
// was the "black bar / black thumb" on the right edge.
const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary
const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -793,15 +787,10 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
}}
width={1}
>
{!scrollable ? null : ( // Nothing to scroll → draw nothing. The outer
// width={1} Box still reserves the column so the transcript width
// doesn't jump when content grows scrollable. Previously this drew a
// full-height column of SPACES; on a transparent terminal
// (terminal.background #00000000) those drawn-blank cells composite to
// a black bar (the "big black area" by the scrollbar) — same class as
// the banner's removed opaque fill. A `│` track only appears when
// there's actually something to scroll, which is what reads as "makes
// sense."
{/* Nothing to scroll draw nothing (the width={1} Box still reserves
the column). Drawn-blank cells composite to a black bar on
transparent terminals same class as the removed opaque fills. */}
{!scrollable ? null : (
<>
{thumbTop > 0 ? (
<Text color={trackColor}>{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}</Text>

View file

@ -232,12 +232,10 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const lineBudget = Math.max(12, w - 2)
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
// Hierarchy: category labels lead in the theme's label tone; member lists
// recede in the midpoint of muted and text. Anchoring on MUTED (a
// mid-luminance family tone) keeps the fade readable on both poles even
// when polarity detection is wrong — blending toward the SURFACE instead
// made the fade invisible whenever text was already pale (light-rendered
// default: cream toward white = nothing).
// Hierarchy: labels lead in the label tone; member lists recede in the
// muted/text midpoint. Anchoring on MUTED (mid-luminance by construction)
// keeps the fade readable on both poles even when polarity detection is
// wrong — surface-relative blends go invisible when text is already pale.
const listFade = mix(t.color.muted, t.color.text, 0.5)
// ── Local collapse state for each section ──

View file

@ -3,9 +3,22 @@ import { Text, useInput } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { UsageModelData } from '../gatewayTypes.js'
import { liftForContrast } from '../lib/color.js'
import { liftForContrast, mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
/**
* THE scrollbar treatment (transcript + overlays): thumb rides the theme
* base, accent while interacting; track recedes via an explicit blend toward
* the surface. Never SGR dim terminal-interpreted, it renders as a black
* slab on transparent profiles (terminal.background #00000000).
*/
export function scrollbarColors(t: Theme, hover: boolean, grabbed: boolean): { thumb: string; track: string } {
return {
thumb: grabbed || hover ? t.color.accent : t.color.primary,
track: mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
}
}
export interface MenuRowSpec {
color?: string
label: string

View file

@ -1,9 +1,10 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type RefObject, useState } from 'react'
import { mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
import { scrollbarColors } from './overlayPrimitives.js'
/**
* Mouse-draggable scrollbar bound to a `ScrollBox` ref. Re-renders off the
* parent `tick` so accordions / async content can resize the thumb without a
@ -40,11 +41,7 @@ export function OverlayScrollbar({
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
// Same scheme as TranscriptScrollbar: base-color thumb, accent on interact,
// track receded via explicit blend (SGR dim renders as a black slab on
// transparent terminals — see TranscriptScrollbar).
const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary
const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
const { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {

View file

@ -40,33 +40,28 @@ type MinimalEnv = Record<string, string | undefined>
const invert = (s: string) => INV + s + INV_OFF
/** Truecolor foreground wrap. Always emits an EXPLICIT color never SGR dim
* and never inverse: both are terminal-interpreted relative to the default
* fg/bg, and on transparent profiles (terminal.background #00000000) they
* composite against a black RGB the user never sees elsewhere, rendering the
* hint as a black slab. A fixed mid-gray fallback is deterministic on every
* host when no theme color is provided. */
// Placeholder styling is EXPLICIT truecolor only — never SGR dim/inverse:
// both are terminal-interpreted relative to the default fg/bg, and on
// transparent profiles (terminal.background #00000000) they composite
// against a black RGB the user never sees — the hint rendered as a slab.
const HINT_FALLBACK = '#808080'
const colorizeHint = (s: string, hex?: string) => {
const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)!
const n = parseInt(m[1]!, 16)
const hintRgb = (hex?: string): [number, number, number] => {
const n = parseInt((/^#([0-9a-f]{6})$/i.exec(hex ?? '')?.[1] ?? HINT_FALLBACK.slice(1)) as string, 16)
return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m`
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
/** Synthetic placeholder cursor: an explicit-truecolor chip (hint-colored
* block, luminance-picked ink) instead of SGR inverse. Inverse swaps
* against the terminal's DEFAULT fg/bg, which on transparent profiles is a
* black RGB the user never sees the "cursor" rendered as a black slab. */
const colorizeHint = (s: string, hex?: string) => {
const [r, g, b] = hintRgb(hex)
return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`
}
/** Synthetic placeholder cursor: a hint-colored chip with luminance-picked
* ink, standing in for the hidden hardware cursor (bubbles pattern). */
const hintCursorCell = (ch: string, hex?: string) => {
const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)!
const n = parseInt(m[1]!, 16)
const r = (n >> 16) & 0xff,
g = (n >> 8) & 0xff,
b = n & 0xff
const [r, g, b] = hintRgb(hex)
const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '0;0;0' : '255;255;255'
return `${ESC}[48;2;${r};${g};${b}m${ESC}[38;2;${ink}m${ch}${ESC}[39m${ESC}[49m`