package theme import renderer "github.com/bnema/vev-vt" const ( normalTextContrast = 4.5 borderContrast = 3.0 // warnHueDegrees is the fixed OKLCh hue target for the Warn border role. // Reference amber hues under this package's own rgbToOKLab: conventional // "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at // ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in // that band, so a warn border reads as amber/gold regardless of which // hue the user's single accent happens to occupy (blue ~256, red ~29, // green ~145 under the same conversion). warnHueDegrees = 75.0 ) // Ramp is the contrast-safe set of semantic terminal chrome colors. type Ramp struct { SurfaceBar renderer.Style SurfaceInactive renderer.Style SurfaceRecent renderer.Style SurfaceActive renderer.Style BorderMuted renderer.Style BorderActive renderer.Style BorderWarn renderer.Style background renderer.RGB foreground renderer.RGB rgb bool } // BuildRamp derives every RGB surface from the terminal background and one // resolved accent. It deliberately never uses indexed colors for backgrounds. func BuildRamp(t Theme, accent Accent) Ramp { if !accent.Known || accent.IndexedOnly || !usable(t) { return neutralRamp(t) } bar, ok := surfaceAtOrBelow(t, accent.RGB, 8) if !ok { return neutralRamp(t) } inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) if !ok { return neutralRamp(t) } recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) if !ok { return neutralRamp(t) } active, ok := surfaceAtOrBelow(t, accent.RGB, 100) if !ok { return neutralRamp(t) } return Ramp{ SurfaceBar: bar, SurfaceInactive: inactive, SurfaceRecent: recent, SurfaceActive: active, BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), background: t.Background, foreground: t.Foreground, rgb: true, } } // surfaceAtOrBelow searches from the requested intensity to neutral. The // integer sequence is intentional: it makes contrast adaptation stable. func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) { for weight := target; weight >= 0; weight-- { background := okLabLerp(t.Background, accent, float64(weight)/100) foreground, ok := primaryText(t, background) if !ok { continue } if _, ok := secondaryText(foreground, background); !ok { continue } return rgbSurface(foreground, background), true } return renderer.Style{}, false } func rgbSurface(foreground, background renderer.RGB) renderer.Style { style := renderer.DefaultStyle() style.HasForegroundRGB = true style.ForegroundRGB = foreground style.HasBackgroundRGB = true style.BackgroundRGB = background return style } // primaryText chooses only the reported default foreground or background. func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) { best := t.Foreground bestContrast := ContrastRatio(t.Foreground, surface) backgroundContrast := ContrastRatio(t.Background, surface) if backgroundContrast > bestContrast { best, bestContrast = t.Background, backgroundContrast } return best, bestContrast >= normalTextContrast } // secondaryText mutes toward the actual surface while retaining normal-text // contrast. Searching down from 40 selects the greatest permitted mute. func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) { for weight := 40; weight >= 0; weight-- { candidate := okLabLerp(primary, surface, float64(weight)/100) if ContrastRatio(candidate, surface) >= normalTextContrast { return candidate, true } } return renderer.RGB{}, false } // mutedBorder starts at 60 percent and only moves toward the accent, choosing // the lowest weight that separates from its adjacent surface. func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { for weight := 60; weight <= 100; weight++ { candidate := okLabLerp(t.Background, accent, float64(weight)/100) if ContrastRatio(candidate, adjacent) >= borderContrast { return foregroundStyle(candidate) } } return neutralBorder(t) } // activeBorder starts at the accent endpoint and reduces only when needed. func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { for weight := 100; weight >= 0; weight-- { candidate := okLabLerp(t.Background, accent, float64(weight)/100) if ContrastRatio(candidate, adjacent) >= borderContrast { return foregroundStyle(candidate) } } return neutralBorder(t) } // warnBorder searches the same accent-derived weight scale activeBorder // uses, but replaces each candidate's hue with the fixed amber target before // checking contrast: the result keeps the accent's lightness/chroma band // (so it belongs to the ramp) while always reading as amber, regardless of // the user's accent hue. func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { for weight := 100; weight >= 0; weight-- { candidate := okLabLerp(t.Background, accent, float64(weight)/100) amber := shiftHue(candidate, warnHueDegrees) if ContrastRatio(amber, adjacent) >= borderContrast { return foregroundStyle(amber) } } return neutralBorder(t) } func foregroundStyle(color renderer.RGB) renderer.Style { style := renderer.DefaultStyle() style.HasForegroundRGB = true style.ForegroundRGB = color return style } func neutralRamp(t Theme) Ramp { styles := neutralStyles(t) return Ramp{ SurfaceBar: styles.SurfaceBar, SurfaceInactive: styles.SurfaceInactive, SurfaceRecent: styles.SurfaceRecent, SurfaceActive: styles.SurfaceActive, BorderMuted: styles.BorderMuted, BorderActive: styles.BorderActive, BorderWarn: styles.BorderWarn, } } // mruWeight is the share of the way from the bar to the active surface for a // history entry. The newest entry always sits halfway; the remaining entries // split that lower half into equal steps, so the oldest stays one step above // the bar: 1 entry → 50; 2 → 50, 25; 3 → 50, 33, 17. func mruWeight(index, count int) float64 { return 0.5 * (1 - float64(index)/float64(count)) } // MRUStyle places history entries by mruWeight between the bar and the active // surface. The background is never moved for contrast; only the text picks the // better of the theme foreground and background. func MRUStyle(ramp Ramp, index, count int) renderer.Style { if !ramp.rgb || count <= 0 { return ramp.SurfaceRecent } index = max(0, min(index, count-1)) weight := mruWeight(index, count) background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight) foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background) return rgbSurface(foreground, background) }