Skip to content

Instantly share code, notes, and snippets.

@Kattoor
Created May 29, 2026 19:26
Show Gist options
  • Select an option

  • Save Kattoor/cfdb47d4479a53d4f2718e7f6db85ecb to your computer and use it in GitHub Desktop.

Select an option

Save Kattoor/cfdb47d4479a53d4f2718e7f6db85ecb to your computer and use it in GitHub Desktop.
g.js
#!/usr/bin/env node
/**
* Gradient text formatter (per line):
* - Each line = one sentence
* - Random gradient palette per sentence (configurable anchor colors)
* - Per-character coloring based on sentence length
* - Outputs tags like <#f0a> before each character
*
* Non-repeating “faster” gradients:
* - --curve <n> : gamma curve on the gradient position (default 1.8). Higher = faster changes early.
* - --span <0..1> : finish the gradient earlier, then hold the final color (default 1.0).
*
* Modes:
* - Args provided: one-shot (args joined into one sentence)
* - Stdin piped: processes stdin block once
* - No args + TTY: interactive REPL (keeps terminal open; one sentence per line)
*
* Options:
* --anchors <2..6> Number of anchor colors (default 3)
* --curve <n> Gradient curve/gamma (default 1.8, must be > 0)
* --span <0..1> Portion of text used for the whole gradient (default 1.0, must be > 0)
* --no-spaces Don't color spaces
* --no-short Disable short hex compression (<#rgb>), force 6-digit hex (<#rrggbb>)
* --no-copy Don't copy output to clipboard
*/
const fs = require("fs");
const readline = require("readline");
const { spawnSync } = require("child_process");
// -------------------- helpers --------------------
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function rgbToHex({ r, g, b }) {
const toByte = (x) => {
const v = Math.round(clamp01(x) * 255);
return v.toString(16).padStart(2, "0");
};
return `${toByte(r)}${toByte(g)}${toByte(b)}`.toLowerCase();
}
// Compress rrggbb -> rgb when possible (e.g., ff00aa -> f0a)
function compressHex6To3(hex6) {
if (!/^[0-9a-f]{6}$/i.test(hex6)) return hex6;
const h = hex6.toLowerCase();
if (h[0] === h[1] && h[2] === h[3] && h[4] === h[5]) {
return `${h[0]}${h[2]}${h[4]}`;
}
return h;
}
// HSV -> RGB (all in [0,1])
function hsvToRgb(h, s, v) {
h = ((h % 1) + 1) % 1;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: return { r: v, g: t, b: p };
case 1: return { r: q, g: v, b: p };
case 2: return { r: p, g: v, b: t };
case 3: return { r: p, g: q, b: v };
case 4: return { r: t, g: p, b: v };
case 5: return { r: v, g: p, b: q };
}
}
// Random “nice” color: high saturation/value so it pops
function randomNiceRgb() {
const h = Math.random();
const s = 0.75 + Math.random() * 0.25; // 0.75..1.0
const v = 0.85 + Math.random() * 0.15; // 0.85..1.0
return hsvToRgb(h, s, v);
}
function pickAnchors(n) {
const anchors = [];
for (let tries = 0; anchors.length < n && tries < 300; tries++) {
anchors.push(randomNiceRgb());
}
return anchors;
}
// Piecewise gradient across anchors for totalSteps colors
// curve: gamma curve ( >1 faster early, <1 faster late)
// span: 0..1 portion used to complete gradient, rest holds at end
function gradientColors(anchors, totalSteps, curve = 1.8, span = 1.0) {
if (totalSteps <= 0) return [];
if (anchors.length === 1) return Array.from({ length: totalSteps }, () => anchors[0]);
const segments = anchors.length - 1;
const colors = [];
const safeCurve = Number.isFinite(curve) && curve > 0 ? curve : 1.0;
const safeSpan = Number.isFinite(span) && span > 0 ? Math.min(1, span) : 1.0;
for (let i = 0; i < totalSteps; i++) {
let u = totalSteps === 1 ? 0 : i / (totalSteps - 1);
// span: finish earlier then clamp at 1 (no repetition)
if (safeSpan < 1) {
u = Math.min(1, u / safeSpan);
}
// curve: reshape for “faster” feeling without repeating
u = Math.pow(u, safeCurve);
const segFloat = u * segments;
const segIndex = Math.min(segments - 1, Math.floor(segFloat));
const t = segFloat - segIndex;
const a = anchors[segIndex];
const b = anchors[segIndex + 1];
colors.push({
r: lerp(a.r, b.r, t),
g: lerp(a.g, b.g, t),
b: lerp(a.b, b.b, t),
});
}
return colors;
}
function formatGradient(sentence, opts) {
const chars = Array.from(sentence);
const indexable = opts.colorSpaces ? chars : chars.filter((c) => c !== " ");
const steps = indexable.length;
if (steps === 0) return sentence;
const anchors = pickAnchors(opts.anchors);
const cols = gradientColors(anchors, steps, opts.curve, opts.span);
let ci = 0;
let out = "";
for (const ch of chars) {
if (!opts.colorSpaces && ch === " ") {
out += " ";
continue;
}
const hex6 = rgbToHex(cols[ci++]);
const hex = opts.shortHex ? compressHex6To3(hex6) : hex6;
out += `<#${hex}>${ch}`;
}
return out;
}
// -------------------- Tagged -> ANSI preview --------------------
function expandHex3To6(hex3) {
const h = hex3.toLowerCase();
return `${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`;
}
function hexToRgb(hex) {
let h = hex.trim().toLowerCase();
if (h.startsWith("#")) h = h.slice(1);
if (/^[0-9a-f]{3}$/.test(h)) h = expandHex3To6(h);
if (!/^[0-9a-f]{6}$/.test(h)) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return { r, g, b };
}
function ansiFg({ r, g, b }) {
return `\x1b[38;2;${r};${g};${b}m`;
}
const ANSI_RESET = "\x1b[0m";
/**
* Takes a string like "<#f00>a<#0f0>b<#00f>c"
* Returns a colored version for terminals (tags removed).
*/
function renderTaggedToAnsi(tagged) {
const re = /<\#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})>/g;
let out = "";
let lastIndex = 0;
let currentAnsi = "";
for (const match of tagged.matchAll(re)) {
const start = match.index;
const end = start + match[0].length;
const chunk = tagged.slice(lastIndex, start);
if (chunk) out += (currentAnsi ? currentAnsi : "") + chunk;
const rgb = hexToRgb(match[1]);
currentAnsi = rgb ? ansiFg(rgb) : "";
lastIndex = end;
}
const tail = tagged.slice(lastIndex);
if (tail) out += (currentAnsi ? currentAnsi : "") + tail;
return out + ANSI_RESET;
}
// -------------------- Clipboard (no external deps) --------------------
function copyToClipboard(text) {
const plat = process.platform;
// macOS
if (plat === "darwin") {
const p = spawnSync("pbcopy", [], { input: text });
if (p.error) throw p.error;
if (p.status !== 0) throw new Error("pbcopy failed");
return;
}
// Windows
if (plat === "win32") {
const p = spawnSync("clip", [], { input: text, shell: true });
if (p.error) throw p.error;
if (p.status !== 0) throw new Error("clip failed");
return;
}
// Linux / others
const attempts = [
{ cmd: "wl-copy", args: [] }, // Wayland
{ cmd: "xclip", args: ["-selection", "clipboard"] }, // X11
{ cmd: "xsel", args: ["--clipboard", "--input"] }, // X11 alt
];
for (const a of attempts) {
const p = spawnSync(a.cmd, a.args, { input: text });
if (!p.error && p.status === 0) return;
}
throw new Error("No clipboard tool found. Install wl-clipboard (wl-copy) or xclip/xsel.");
}
// -------------------- CLI parsing --------------------
const argv = process.argv.slice(2);
const opts = {
anchors: 3,
shortHex: true,
colorSpaces: true,
curve: 1.8,
span: 1.0,
copy: true,
};
function takeFlag(name) {
const idx = argv.indexOf(name);
if (idx === -1) return false;
argv.splice(idx, 1);
return true;
}
if (takeFlag("--no-short")) opts.shortHex = false;
if (takeFlag("--no-spaces")) opts.colorSpaces = false;
if (takeFlag("--no-copy")) opts.copy = false;
function takeNumberFlag(flag, onValue) {
const idx = argv.indexOf(flag);
if (idx === -1) return;
const val = Number(argv[idx + 1]);
onValue(val);
argv.splice(idx, 2);
}
takeNumberFlag("--anchors", (val) => {
if (Number.isFinite(val) && val >= 2 && val <= 6) opts.anchors = Math.floor(val);
});
takeNumberFlag("--curve", (val) => {
if (Number.isFinite(val) && val > 0) opts.curve = val;
});
takeNumberFlag("--span", (val) => {
if (Number.isFinite(val) && val > 0) opts.span = Math.min(1, val);
});
// -------------------- main modes --------------------
function processTextBlock(text) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const output = lines
.map((line) => (line.length ? formatGradient(line, opts) : ""))
.join("\n");
const preview = output
.split("\n")
.map(renderTaggedToAnsi)
.join("\n");
console.log();
console.log("========PREVIEW========");
console.log(preview);
console.log();
console.log("========OUTPUT" + (opts.copy ? " (COPIED TO CLIPBOARD)" : "") + "========");
console.log(output);
console.log();
if (opts.copy) {
try {
copyToClipboard(output);
} catch (e) {
console.error("[copy failed]", e.message);
}
}
}
const hasArgs = argv.length > 0;
const stdinIsTTY = process.stdin.isTTY;
if (hasArgs) {
// One-shot: sentence from args
processTextBlock(argv.join(" "));
} else if (!stdinIsTTY) {
// Piped stdin: process once
const inputText = fs.readFileSync(0, "utf8");
processTextBlock(inputText);
} else {
// Interactive REPL mode
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "> ",
});
console.log("Gradient REPL mode. Type a sentence and press Enter.");
console.log('Commands: "exit" / "quit" to leave. Ctrl+C also works.');
console.log(opts.copy ? "Clipboard: ON (use --no-copy to disable)" : "Clipboard: OFF");
rl.prompt();
rl.on("line", (line) => {
const trimmed = line.trim();
if (!trimmed) {
rl.prompt();
return;
}
const lower = trimmed.toLowerCase();
if (lower === "exit" || lower === "quit") {
rl.close();
return;
}
processTextBlock(line);
rl.prompt();
});
rl.on("close", () => {
console.log("\nbye 👋");
process.exit(0);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment