sugarcraft / sugar-colorprofile
PHP port of charmbracelet/colorprofile — magical terminal color profile detection and color degradation (TrueColor → ANSI256 → ANSI → ASCII).
Requires
- php: ^8.3
- react/child-process: ^0.6
- react/event-loop: ^0.5 || ^1.2
- react/promise: ^3.3
- sugarcraft/sugar-core: dev-master
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:01:44 UTC
README
SugarColorprofile
Formerly published as
sugarcraft/candy-palette(renamed 2026-10). Namespace changed:SugarCraft\Palette\→SugarCraft\ColorProfile\.
sugar-colorprofile — terminal color profile detection and color degradation for PHP 8.3+.
Features
- Detect terminal color profile from environment variables and TTY info
- ColorProfile enum: the single shared color-depth vocabulary (8 levels,
TrueColor24-bit →Ansi256256-color →Ansi240fixed slots 16-255 →Ansi16-color →Ansi8basic 8 →Fgbgtwo-color fg/bg →Asciiblack/white →NoTTY); the former renderer-facingProfileenum is now a deprecatedclass_aliasfaçade over it — every oldProfile::Xspelling still resolves - Color conversion: downsample RGBA colors to any target profile
- ProfileWriter: wrap a stream and automatically degrade color codes to match the terminal
- ANSI stripping:
NoTTYstrips all ANSI sequences from output - Environment-aware: reads
CLICOLOR_FORCE,FORCE_COLOR,NO_COLOR,CLICOLOR,TERM,COLORTERM,TERM_PROGRAM,WT_SESSION,GOOGLE_CLOUD_SHELL,TMUX,STY,REDUCE_MOTION,PREFERS_REDUCED_MOTION - Probe class: static convenience layer over the shared detection chain + infocmp Phase 2 upgrade
- Perceptual color distance: CIELAB conversion + ΔE₇₆ / ΔE₉₄ / ΔE*₀₀ (CIEDE2000) metrics with an opt-in nearest-palette matcher (
NearestColor), palette Lab values memoized for hot loops, a PCA-bucketed exact lookup index for repeated queries (PaletteIndex), and error-compensated quantization for dither chains (NearestColor::withError())
Install
composer require sugarcraft/sugar-colorprofile
Quick Start
use SugarCraft\ColorProfile\Color; use SugarCraft\ColorProfile\Palette; use SugarCraft\ColorProfile\ProfileWriter; // Detect the terminal's color profile (pass a stream to gate on its TTY-ness; // a null stream is env-only detection, mirroring upstream) $palette = new Palette(STDOUT); $profile = $palette->profile(); echo "Your terminal supports: {$profile->label()}\n"; // Downsample a TrueColor color to the detected profile $color = new Color(0x6b, 0x50, 0xff); // #6b50ff $converted = $palette->convert($color); echo "Converted: {$converted->toHex()}\n"; // Wrap a stream for automatic color degradation on write $writer = ProfileWriter::wrap(STDOUT); $writer->write("\x1b[38;2;107;80;255mFancy text\x1b[0m\n");
Profiles
| ColorProfile | Colors | Description |
|---|---|---|
| TrueColor | 16.7M | Full 24-bit RGB (24-bit ANSI) |
| Ansi256 | 256 | 216 cube + 24 gray + 16 standard |
| Ansi240 | 240 | Fixed palette only — slots 16-255 (chafa INDEXED_240) |
| Ansi | 16 | Standard terminal colors |
| Ansi8 | 8 | Basic ANSI, no bright variants |
| Fgbg | 2 | Two-color fg/bg by brightness (chafa FGBG) |
| Ascii | 2 | Black & white |
| NoTTY | 0 | No color (ANSI stripped) |
Profile is the deprecated alias of ColorProfile; Profile::ANSI256
and Profile::ANSI keep resolving through deprecated constant bridges.
Color Degradation
use SugarCraft\ColorProfile\Color; use SugarCraft\ColorProfile\ColorProfile; use SugarCraft\ColorProfile\Palette; $color = new Color(100, 50, 255, 255); // Downsample to an explicit profile (static one-off shortcut) $ansi256 = Palette::toProfile($color, ColorProfile::Ansi256); // nearest cube/gray index $ansi = Palette::toProfile($color, ColorProfile::Ansi); // nearest of the 16 slots echo $ansi256->toAnsi256Foreground(); // "\x1b[38;5;…m" echo $ansi->toAnsi16Foreground(); // "\x1b[…m" — 4-bit SGR // Or convert against the detected terminal $auto = (new Palette())->convert($color);
Perceptual Color Distance (opt-in)
Default nearest-color matching stays Euclidean (RGB) for byte-for-byte back-compat. For perceptual matching — where blues, greens and near-grays rank correctly — opt in:
use SugarCraft\ColorProfile\Color; use SugarCraft\ColorProfile\ColorDistance; use SugarCraft\ColorProfile\ColorMath; use SugarCraft\ColorProfile\DeltaE; use SugarCraft\ColorProfile\NearestColor; use SugarCraft\ColorProfile\PaletteIndex; // sRGB -> linear -> XYZ (D65) -> CIELAB $lab = ColorMath::toLab(64, 96, 128); // ['l' => …, 'a' => …, 'b' => …] $lab2 = (new Color(200, 30, 90))->toLab(); // same chain, from a Color // ΔE metrics (CIE 15:2004; CIEDE2000 per Sharma/Wu/Dalal 2005) $d76 = DeltaE::cie76($lab, $lab2); $d94 = DeltaE::cie94($lab, $lab2); // graphic-arts kL = 1 $d00 = DeltaE::cie2000($lab, $lab2); // Nearest ANSI-256 palette index under a chosen strategy (default: EUCLIDEAN) $matcher = new NearestColor(ColorDistance::Cie2000); $index = $matcher->ansi256(new Color(30, 120, 30)); // Arbitrary palette (list or map), returns the key of the winner $brand = ['logo' => new Color(10, 20, 30), 'accent' => new Color(240, 250, 255)]; $key = $matcher->closest(new Color(12, 34, 56), $brand); // Repeated lookups over one fixed palette: the PCA-bucketed index. // Answers EXACTLY what the brute-force scan answers (lowest index on ties) — // it only trades construction cost for a sublinear lookup. Euclidean and Cie76 // only; those are the metrics that admit a distance-preserving rotation. $idx = PaletteIndex::fromPalette(NearestColor::palette256(), ColorDistance::Cie76); $near = $idx->nearest(new Color(30, 120, 30)); // === a Cie76-metric NearestColor->ansi256() answer // (the index only claims equality with the brute-force matcher under the SAME metric — // the $matcher above is Cie2000, so compare against `new NearestColor(ColorDistance::Cie76)`) // Error-compensated lookup, chafa chafa-palette.c:1160-1197: pass the // per-channel accumulator by reference; the query is pushed by error * 0.9/16 // before matching, and the new residual is written back for the next pixel. $acc = [0.0, 0.0, 0.0]; // start of the scanline $d1 = $matcher->withError(new Color(90, 90, 90), $acc); // dither chain step 1 $d2 = $matcher->withError(new Color(91, 90, 89), $acc); // ... inherits the push $none = null; $plain = $matcher->withError(new Color(12, 34, 56), $none); // null === plain ansi256()
The 256-entry and 16-entry palette Lab tables are memoized statically (fingerprint-guarded
against StandardColors reassignments, so the cache can never freeze a stale table), so a warm
CIEDE2000 search costs ~1 ms on a stock laptop; recomputing the 256 sRGB→Lab
conversions per search would add ≈40 % on top (see NearestColorTest).
The matcher's 256-entry table is the canonical xterm palette: slots 0-15 are the
ANSI-16 set, 16-231 the 6×6×6 cube at channel levels 0/95/135/175/215/255, and
232-255 the 8+10n gray ramp — derived from Color::fromAnsi256Index() and
cross-pinned to sugar-core's decoder (Ansi256TableParityTest). Earlier
versions shipped a private even-51-step cube; that table never matched a real
terminal and is gone.
The detection ladder
The color-depth answer comes from one canonical 13-step chain (DetectionChain).
Probe, Palette and Probe\TerminalProbe all consult it, so the same
environment can never answer differently on two surfaces; README, the
DetectionChain docblock and the tests follow this one numbering:
CLICOLOR_FORCE=1→ TrueColor (overrides everything)FORCE_COLOR(non-empty) → level:true|yes|on|>=3TrueColor,2Ansi256,1Ansi; any other spelling (0/false/off/…) →Ascii(disable color, keep decoration)NO_COLOR(set and non-empty;""= unset per no-color.org) → noneCLICOLOR=0→ noneTERM=dumb(case-insensitive) → noneCOLORTERM=24bit|truecolor|yes→ TrueColorTERM_PROGRAM=iTerm.app→ TrueColor (inline image + 24-bit history)WT_SESSION(set, non-empty) → TrueColor (Windows Terminal)GOOGLE_CLOUD_SHELL(set, non-empty) → TrueColorTMUXorSTYset + base TERM beginsscreen/tmux→ Ansi256- TERM contains
-256color, or equalsxterm-kitty/xterm-ghostty(case-insensitive) → Ansi256 - TERM begins
xterm/screen/tmux(case-insensitive) → Ansi - Default → Ansi
Steps 1-2 outrank step 3 deliberately: FORCE_COLOR/CLICOLOR_FORCE are an
active "give me color anyway" override (the chalk/support-colors convention),
while NO_COLOR is a passive preference. The asymmetry is pinned by tests.
Interpolated env values that ride in source strings are sanitized via
Env::sanitize() — TERM and friends are attacker-influenced over SSH and a
source string gets printed by downstream diagnostics.
Phase 2 (infocmp) — Probe/AsyncProbe/TerminalProbe surfaces only: when
the ladder answers Ansi or Ansi256, infocmp -1 is consulted and Tc/RGB
capability names (name-position match via Probe\TerminfoParser, never a
whole-output regex) upgrade the answer to TrueColor. A bare Palette::detect()
runs the ladder without this phase.
Probe — Static Environment Detection
The Probe class provides the static convenience layer over the ladder above —
color profile, explicit no-color/force-color flags, and reduced-motion
preference. Use it directly when you need raw detection values without
constructing a Palette instance.
use SugarCraft\ColorProfile\Probe; use SugarCraft\ColorProfile\ColorProfile; // Detect the negotiated color profile $profile = Probe::colorProfile(); // ColorProfile::TrueColor|Ansi256|Ansi|Ascii|NoTTY echo $profile->label(); // "TrueColor" // Check for explicit disable/enable flags if (Probe::isNoColor()) { // NO_COLOR is set to a non-empty value — disable all color output } if (Probe::isForceColor()) { // CLICOLOR_FORCE=1 — force full color regardless of terminal } // Reduced-motion preference (REDUCE_MOTION or PREFERS_REDUCED_MOTION, // non-empty and not 0/false/no/off) if (Probe::reducedMotion()) { // Skip animations, spinners, and other motion }
ColorProfile Enum
ColorProfile is the single shared color-depth vocabulary: the detection chain speaks it, Palette/ProfileWriter degrade against it, and libs that need the raw profile value consume it directly (live roster, derived from use SugarCraft\ColorProfile\ imports: sugar-log, sugar-image, sugar-spring, sugar-video, candy-mdview). Profile remains only as a deprecated class_alias façade over it. Five of the arms are detection-emittable (TrueColor/Ansi256/Ansi/Ascii/NoTTY — Ascii lands from a falsy FORCE_COLOR spelling, step 2 of the ladder); Ansi240, Ansi8 and Fgbg are request-side vocabulary only.
use SugarCraft\ColorProfile\ColorProfile; use SugarCraft\ColorProfile\Probe; use SugarCraft\ColorProfile\ProfileWriter; $profile = Probe::colorProfile(); // Human-readable label echo $profile->label(); // "TrueColor" // Request-side arms (Ansi240/Ansi8/Fgbg — detection never emits these): $writer = ProfileWriter::wrap(STDOUT)->withProfile(ColorProfile::Ansi240); // never touch slots 0-15
| Case | Value | Label |
|---|---|---|
TrueColor |
'truecolor' |
TrueColor |
Ansi256 |
'ansi256' |
ANSI 256 |
Ansi240 |
'ansi240' |
ANSI 240 |
Ansi |
'ansi' |
ANSI |
Ansi8 |
'ansi8' |
ANSI 8 |
Fgbg |
'fgbg' |
FGBG |
Ascii |
'ascii' |
ASCII |
NoTTY |
'notty' |
No TTY |
Architecture
SugarCraft\ColorProfile\
├── Color — RGBA color value object with conversion methods (Color::namedColors() lists standard names)
├── ColorProfile — THE color-depth enum (8 levels, richest→simplest)
├── Profile — deprecated class_alias façade over ColorProfile
├── DetectionChain — shared env ladder (the one precedence core; see the ladder above)
├── Env — env resolution (hermetic-replace rule), value read, source sanitize
├── Palette — instance detection over the chain + degrade/strip; adds the stream TTY gate
├── Probe — static env-probe layer (colorProfile/isNoColor/isForceColor/reducedMotion)
├── AsyncProbe — ReactPHP child-process infocmp probe (bounded children, settles from the chain)
├── Probe\TerminalProbe — phased capability report (env chain → terminfo → escape queries → fallbacks)
├── Probe\Capability / ProbeReport / Probe\InfocmpBinary / Probe\TerminfoParser — capability enum, sourced report, resolved infocmp path, name-position terminfo parser
├── ColorMath — sRGB→linear→XYZ (D65)→Lab conversion
├── DeltaE — ΔE*₇₆ / ΔE*₉₄ / ΔE*₀₀ metrics
├── ColorDistance — distance strategy enum (Euclidean/Cie76/Cie94/Cie2000)
├── NearestColor — nearest-palette matcher (key-guarded memoized Lab, error-compensation carrier)
├── PaletteIndex — PCA-bucketed exact accelerator for repeated lookups
├── StandardColors — ANSI/ANSI256 standard palette (public MUTABLE statics — read-only by convention)
├── ProfileWriter — stream wrapper for automatic color degradation
└── Lang — i18n strings
Terminal capability probes
Beyond the env ladder above, the library exposes a richer capability surface:
Probe\TerminalProbe— phased probe returning aProbe\ProbeReport: phase 1 runs the sharedDetectionChainfor the color-depth rung (so the ladder cannot drift between surfaces), phase 2 reads terminfo viainfocmp -1, phase 3 mapsTERM_PROGRAMadvertised capabilities (interactive streams only), phase 4 applies floors. Each detectedProbe\Capabilitycarries its source string (env:VAR,terminfo:caporfallback:reason). The live roster is the 11 enum cases —TrueColor,Color256,Color16,NoColor,Sixel,KittyKeyboard,ITerm2,Hyperlinks,BracketedPaste,FocusEvents,BasicAscii— withBracketedPasteandFocusEventsRESERVED: no probe producer sets them yet, sohas()stays false until one is wired. Terminfo capabilities are matched at the NAME position byProbe\TerminfoParser(ades=string mentioning "Tc" does not promote anything) and exec'd at theProbe\InfocmpBinary-resolved absolute path (the same path the availability gate checked — the binary is never looked up on PATH).TERM_PROGRAMmaps only terminals that actually implement what they advertise — notablyApple_Terminaldoes NOT setITerm2(Terminal.app has no OSC 1337 images). Reports answer throughallWithSource()/has()/source()plus thecolorDescription()prose, and bridge into the shared vocabulary viatoColorProfile()(TrueColor→TrueColor, Color256→Ansi256, Color16→Ansi, NoColor→NoTTY, empty report→Ansiladder-default parity).AsyncProbe::colorProfile()— the same infocmp check in a ReactPHP child process; returns a promise. Every child is bounded (5 s ceiling → TERM, 1 s grace → KILL, the KILL timer cancelled when the child exits first, stderr drained under an 8 KiB cap so a chatty child cannot wedge on a full pipe) and slot reclamation kills children whose loop was abandoned, so the publicAsyncProbe::MAX_CONCURRENT_PROBES = 4cap can never wedge. Every async settle answers from the detection chain — no blocking exec ever runs on the loop thread. The syncProbe::colorProfile()runs only when no event loop is present at all. Env suppression, a missing infocmp binary, an unset ordumbTERM, the cap being reached, or a timeout/kill all settle to the chain's answer. Results are advisory only.
| Env var | Effect |
|---|---|
NO_COLOR |
set to a non-empty value ("" = unset per no-color.org) → no color (NoTTY rung / NoColor capability) |
CLICOLOR=0 |
suppress color |
TERM=dumb |
case-insensitive; suppresses color — outranks COLORTERM (dumb is a promise of minimal capabilities) |
CLICOLOR_FORCE=1 |
force TrueColor past every suppression leg, NO_COLOR included (step 1) |
FORCE_COLOR |
non-empty level override honored by all surfaces through the chain (step 2): true/yes/on/>=3 TrueColor, 2 Ansi256, 1 Ansi; falsy spellings (0/false/off/no) land on the Ascii floor — decoration kept, color dropped. Outranks NO_COLOR by design |
COLORTERM=24bit|truecolor|yes |
TrueColor (exact value match, case-insensitive) |
TERM |
ladder levels: *-256color/xterm-kitty/xterm-ghostty → Ansi256; xterm*/screen*/tmux* prefix → Ansi (all case-insensitive) |
TERM_PROGRAM |
iTerm.app → TrueColor rung (step 7); phase 3 additionally maps WezTerm/vscode/Ghostty → TrueColor, iTerm.app → ITerm2, Hyper → Hyperlinks |
WT_SESSION |
set, non-empty → Windows Terminal, TrueColor |
GOOGLE_CLOUD_SHELL |
set, non-empty (any truthy spelling) → TrueColor |
TMUX / STY |
set + base TERM already begins screen*/tmux* → Ansi256 (step 10). Under tmux/screen the inner TERM is the base — no re-anchoring to the outer terminal happens |
REDUCE_MOTION / PREFERS_REDUCED_MOTION |
non-empty and not 0/false/no/off → Probe::reducedMotion() true |
NoTTY labels the zero-color rung; suppression vars land there even on a real
TTY (Probe::isNoColor() and the NoColor capability name the same decision).
ProbeReport::colorDescription() is report prose; the enum's label() and
description() are the canonical vocabulary.
Overriding detection
CLICOLOR_FORCE=1forces TrueColor regardless of everything else.FORCE_COLORpicks the level (3/2/1, falsy →Asciifloor).- Program-side, pin the answer:
(new Palette())->withProfile(ColorProfile::Ansi)(immutable clone) orProfileWriter::wrap(STDOUT)->withProfile(...). - A
Palette/TerminalProbeconstructed with a non-empty injected$envmap is hermetic: the map completely replaces the ambient environment (Env::resolve()), it does not overlay it — pass every key you care about.
Deprecation: StandardColors mutable statics
Dated 2026-10-24 (owner ruling 2b).
The 16 public static Color properties on StandardColors ($black …
$brightWhite) are now deprecated as a writable surface. They are
process-global mutables: an assignment anywhere rewrites the value for every
reader, and nothing pins them against reassignment.
Nothing changes today. The properties keep working byte-for-byte — reads,
writes, and every current consumer behave exactly as before. They are removed
in favor of an immutable, constant-backed equivalent no earlier than 2.0.0
(each tagged REMOVED-IN: 2.0.0). Until then this is a deprecation window:
migrate now, no rush, no breakage.
Use the non-mutating read API instead (not deprecated):
| Instead of | Use |
|---|---|
StandardColors::$red |
StandardColors::fromIndex(1) or StandardColors::all()[1] |
StandardColors::$brightBlack |
StandardColors::fromIndex(8) |
| enumerating the palette | StandardColors::all() (indexed 0–15) · StandardColors::catalog() (names) |
| a private/global override table | build your own array from all() — the Color values are immutable, so a local array is already isolated |
| constructing fresh standard colors | new Color(0xcd, 0x00, 0x00), Color::fromHex('#cd0000') or Color::fromAnsi256Index(1) |
fromIndex(int) is bounds-checked and throws OutOfBoundsException outside
0..15. A structural test pins both halves of the window: every deprecated
property is still a live writable static (no accidental neuter), and the
16-property count here matches the code.
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.