Skip to content

feat: line-spanning foreground gradients via overrideForegroundColor#406

Open
cameronsjo wants to merge 3 commits into
sirmalloc:mainfrom
cameronsjo:feat/foreground-gradient
Open

feat: line-spanning foreground gradients via overrideForegroundColor#406
cameronsjo wants to merge 3 commits into
sirmalloc:mainfrom
cameronsjo:feat/foreground-gradient

Conversation

@cameronsjo
Copy link
Copy Markdown

@cameronsjo cameronsjo commented May 30, 2026

CleanShot 2026-05-30 at 11 00 16@2x

What

Adds foreground gradients to ccstatusline at two scopes, on one shared OKLab engine:

  • Line-spanningoverrideForegroundColor: "gradient:…" paints the whole status line with one continuous sweep; each visible character is colored by its column across the line.
  • Per-widgetwidget.color: "gradient:…" gives a single widget its own self-contained sweep, alongside the existing solid colors. Configurable in the TUI (g in the color menu).
// whole line:
"overrideForegroundColor": "gradient:hex:dbbb6f,hex:c4808a,hex:9070d0,hex:b8cad4,hex:4a8a5e"
// one widget:
{ "type": "model", "color": "gradient:atlas" }

Three spec forms: a named preset (gradient:atlas, gradient:rainbow, …), dash stops (gradient:RRGGBB-RRGGBB), or comma stops (gradient:hex:…,#…,…). Two or more stops.

Why one core

This converges two same-day PRs. #404 by @akkaz (opened first) added gradient as a per-widget color with named presets (reproduced from gradient-string, MIT) and a ColorMenu picker; this PR added the line-spanning form. Both create src/utils/gradient.ts, so they'd conflict. Rather than compete, they're meshed onto a single engine that offers both scopes.

Colors interpolate in OKLab for perceptually even blends — no muddy mid-tones, no tinygradient/HSV dependency. rainbow/pastel (originally HSV hue-spins) are re-expressed as explicit multi-stop hue wheels so OKLab reproduces them. Zero new dependencies.

How

  • gradient.tsparseGradientSpec (presets + dash + comma → Rgb[]), OKLab sampleGradient, rgbToAnsi256, gradientCodeAt, applyGradientToText (per-widget), plus GRADIENT_PRESETS.
  • colors.ts — per-widget gradient in applyColors; getColorAnsiCode collapses a gradient to its first stop as a solid (what the powerline renderer and the ansi16 path see).
  • ansi.tsapplyLineGradient reuses the existing parseEscapeSequence / consumeDisplayCluster walkers, so OSC-8 hyperlinks pass through and visible width is unchanged (flexMode unaffected).
  • renderer.ts — line-span pass after assembly, before truncation; suppresses a widget's solid fg when a line-span gradient owns the line.
  • ColorMenu.tsxg opens a gradient picker (preset list + custom hex), foreground-only, colorLevel ≥ 2.

Precedence: line-span override > per-widget color: gradient: > solid.

Color levels & scope

  • truecolor38;2;r;g;b; ansi256 → nearest 6×6×6 / grayscale; ansi16 → per-widget degrades to a solid first stop, line-span is a no-op.
  • Gradients self-degrade at render time, so color-sanitize leaves them untouched at every level.
  • Powerline: separators derive fg from bg, so a line-span gradient is not applied there; per-widget gradients degrade to their first-stop solid. (Full powerline support is a follow-up.)

Tests

bun test green (1426 pass). Covers preset/dash/comma parsing, OKLab sampling, ansi256 mapping, per-widget applyGradientToText (whitespace-skipped, restart-per-call), line-span width-invariance, OSC-8 passthrough, the per-widget applyColors path at all levels, first-stop fallback, and the self-degrade-across-levels sanitize behavior. bun run lint + bun tsc --noEmit clean.

Credit

Per-widget gradients, the preset list, and the TUI picker come from #404 by @akkaz — this PR carries that design forward on the shared OKLab core. 🙏

Follow-ups (not in this PR)

  • TUI authoring for the line-span overrideForegroundColor gradient
  • Powerline support (gradient over text glyphs only, skipping separators)
  • Per-line scoping; direction/reverse modifier

🤖 Generated with Claude Code

Add a `gradient:<stop>,<stop>,...` form for `overrideForegroundColor` that
paints the whole status line with a continuous gradient — each visible
character is colored by its column position, so the gradient spans the line
rather than restarting per widget. Applies to standard (non-powerline) lines;
powerline separators derive their color from adjacent backgrounds, so a
foreground gradient is intentionally not applied there.

- src/utils/gradient.ts: parse hex stops (`hex:RRGGBB` / `#RRGGBB` / bare),
  interpolate in OKLab for perceptually even, non-muddy blends, and map to
  truecolor or the nearest ansi256 index.
- src/utils/ansi.ts: applyLineGradient walks the assembled line with the
  existing escape/cluster tokenizer, so SGR styling and OSC-8 hyperlinks pass
  through untouched and visible width is unchanged (flex layout unaffected).
- src/utils/renderer.ts: applied after assembly and before truncation in the
  standard path. `overrideForegroundColor` accepts the new `gradient:` form
  alongside the existing `hex:` / `ansi256:` / named tagged-string forms; a
  gradient spec is not treated as a per-widget solid color and degrades to a
  no-op at ansi16 (keeping widgets' own colors).

Tests cover spec parsing, OKLab sampling, ansi256 quantization,
width-invariance, OSC-8 passthrough, and a renderer integration case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cameronsjo cameronsjo force-pushed the feat/foreground-gradient branch from cdc537d to d95516b Compare May 30, 2026 15:50
cameronsjo added a commit to cameronsjo/ccstatusline that referenced this pull request May 30, 2026
…n core

Converges the two same-day gradient PRs into one coherent feature. sirmalloc#406
added line-spanning gradients via overrideForegroundColor (OKLab, zero-dep);
sirmalloc#404 (@akkaz) added gradient as a per-widget color with named presets and a
ColorMenu picker. Both created src/utils/gradient.ts and would conflict, so
this meshes them onto a single shared OKLab engine:

- gradient.ts: GRADIENT_PRESETS (akkaz's stops, gradient-string MIT;
  rainbow/pastel re-expressed as multi-stop hue wheels for OKLab); unified
  parseGradientSpec accepting presets, dash (RRGGBB-RRGGBB), and comma
  (hex:..,..) forms; applyGradientToText for the per-widget sweep.
- colors.ts: per-widget gradient hook in applyColors; getColorAnsiCode
  collapses a gradient to its first stop (powerline / ansi16 degrade).
- ColorMenu.tsx: 'g' opens a gradient picker (preset list + custom hex),
  foreground-only, colorLevel >= 2.
- No new dependency (tinygradient dropped in favor of OKLab).

Precedence: overrideForegroundColor gradient (line-span) > widget.color
gradient (per-widget) > solid. Gradients self-degrade at render time, so
color-sanitize leaves them untouched at every level.

Builds on the per-widget design from sirmalloc#404 by @akkaz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@akkaz
Copy link
Copy Markdown

akkaz commented May 30, 2026

Nice work converging both scopes onto a single dependency-free OKLab core — the line-span + per-widget split reads really cleanly. 👏

Replied on #404 re: how we land the attribution. tl;dr: I'd love to stay a real contributor on the merged history — ideally by preserving my original #404 commit, or at minimum a Co-authored-by: akkaz <giomarco@cleversoft.it> trailer on the per-widget commit. Other than that I'm happy to converge here and review. 🙌

cameronsjo and others added 2 commits May 30, 2026 18:26
…n core

Converges the two same-day gradient PRs into one coherent feature. sirmalloc#406
added line-spanning gradients via overrideForegroundColor (OKLab, zero-dep);
sirmalloc#404 (@akkaz) added gradient as a per-widget color with named presets and a
ColorMenu picker. Both created src/utils/gradient.ts and would conflict, so
this meshes them onto a single shared OKLab engine:

- gradient.ts: GRADIENT_PRESETS (akkaz's stops, gradient-string MIT;
  rainbow/pastel re-expressed as multi-stop hue wheels for OKLab); unified
  parseGradientSpec accepting presets, dash (RRGGBB-RRGGBB), and comma
  (hex:..,..) forms; applyGradientToText for the per-widget sweep.
- colors.ts: per-widget gradient hook in applyColors; getColorAnsiCode
  collapses a gradient to its first stop (powerline / ansi16 degrade).
- ColorMenu.tsx: 'g' opens a gradient picker (preset list + custom hex),
  foreground-only, colorLevel >= 2.
- No new dependency (tinygradient dropped in favor of OKLab).

Precedence: overrideForegroundColor gradient (line-span) > widget.color
gradient (per-widget) > solid. Gradients self-degrade at render time, so
color-sanitize leaves them untouched at every level.

Builds on the per-widget design from sirmalloc#404 by @akkaz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: akkaz <giomarco@cleversoft.it>
…n, document

Pre-PR polish pass over the gradient feature.

Fix (correctness):
- renderStatusLine now applies the line-span gradient AFTER truncation, not
  before. truncateStyledText cuts from the right and appends a raw "..." with no
  trailing reset, so a gradient applied earlier had its closing \x1b[39m sliced
  off, leaking the last color past the status line. Gradient codes are zero-width,
  so the truncation measurement is unaffected by deferring. Regression test added.

Simplify:
- Add exported isGradientSpec(); reuse it across colors.ts and renderer.ts instead
  of duplicating the 'gradient:' startsWith check. Drop the redundant prefix guard
  in applyColors (parseGradientSpec already self-guards).

Docs:
- Document applyGradientToText's code-point (vs grapheme-cluster) iteration as a
  known limitation, with the ZWJ/variation-selector consequence and the
  circular-import reason it isn't unified with applyLineGradient yet.
- Note that hex:/# /bare stops are valid in both comma and dash parse forms.
- Note getColorAnsiCode's ansi16 branch intentionally emits a truecolor first-stop
  escape (caller-degraded; never reached at a true ansi16 terminal).
- README + docs/USAGE.md: gradient color options (both scopes, presets, forms).

bun test 1433 pass / 0 fail; lint + tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cameronsjo cameronsjo force-pushed the feat/foreground-gradient branch from 803267c to 24edb57 Compare May 30, 2026 23:30
@cameronsjo
Copy link
Copy Markdown
Author

Thanks for the steer, @sirmalloc. Just force-pushed: the per-widget mesh commit (98f384f) now carries Co-authored-by: akkaz <giomarco@cleversoft.it>, so the squash should credit us both on the Contributors graph. Ready for review when you have a moment.

@akkaz
Copy link
Copy Markdown

akkaz commented Jun 4, 2026

Heads up on a possible follow-up. I have rebuilt the value-driven dynamic color feature from my fork on top of this gradient engine. Instead of a separate dynamic: color prefix, it is now an orthogonal per-widget toggle: when enabled, a widget picks its color by its current fill ratio. If the widget color is a gradient it samples those stops directly; if it is a solid color it derives an in-hue intensity ramp in OKLab: a pale, light tint at low fill through to a deep, saturated shade at high fill, holding the hue constant. So an orange widget runs from light orange to deep orange, a blue one from light blue to deep blue, and so on.

It covers the context, session and weekly usage bars plus the reset timers, with a (d)ynamic toggle in the color menu and an automatic settings migration for existing configs. I kept it separate rather than folding it in here so this PR stays focused on the gradient core. Happy to open it as a follow-up once this lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants