Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/next-steps-gradient-animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Render the "Next steps" header in a dusty mauve and sweep a white reflex highlight across it once after `clerk deploy`, `clerk link`, and `clerk auth login`, then settle on the flat color. The animation only runs on an interactive color terminal and falls back to plain styling when piped, in CI, or when `NO_COLOR` is set.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
log.success(`Logged in as ${existingSession.email}`);
const claimResult = await handleAutoclaim(process.cwd());
if (showNextSteps) {
outro(await loginNextSteps(claimResult));
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}
Expand Down Expand Up @@ -129,7 +129,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
const claimResult = await handleAutoclaim(process.cwd());

if (showNextSteps) {
outro(await loginNextSteps(claimResult));
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
}
Expand Down
5 changes: 4 additions & 1 deletion packages/cli-core/src/commands/deploy/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,11 @@ export function productionSummary(
}

export function nextStepsBlock(appId: string, productionInstanceId: string): string {
return `${bold("Next steps")}
return `${bold("Next steps")}\n${nextStepsBody(appId, productionInstanceId)}`;
}

export function nextStepsBody(appId: string, productionInstanceId: string): string {
return `
1. Pull production keys into your environment
clerk env pull --instance prod

Expand Down
11 changes: 9 additions & 2 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { isAgent } from "../../mode.ts";
import { isInsideGutter, log } from "../../lib/log.ts";
import { bold, dim } from "../../lib/color.ts";
import { animateHeader } from "../../lib/gradient.ts";
import { bar, intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts";
import {
CliError,
Expand Down Expand Up @@ -27,7 +29,7 @@ import {
dnsDashboardHandoff,
dnsIntro,
dnsRecords,
nextStepsBlock,
nextStepsBody,
pendingDnsRecords,
pausedOperationNotice,
printPlan,
Expand Down Expand Up @@ -622,6 +624,11 @@ async function finishDeploy(
"Cannot print deploy next steps because the production instance could not be resolved. Run `clerk deploy` after confirming the production instance in the Clerk Dashboard.",
);
}
log.info(nextStepsBlock(ctx.appId, productionInstanceId));
await animateHeader({
prefix: isInsideGutter() ? `${dim("│")} ` : "",
label: "Next steps",
fallback: bold,
});
log.info(nextStepsBody(ctx.appId, productionInstanceId));
outro("Success");
}
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/link/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export async function link(options: LinkOptions = {}): Promise<void> {
const label = app.name || app.application_id;
log.success(`Linked to ${cyan(label)} in ${dim(displayPath)}`);

outro(NEXT_STEPS.LINK);
await outro(NEXT_STEPS.LINK);
}

async function ensureAuth() {
Expand Down
172 changes: 172 additions & 0 deletions packages/cli-core/src/lib/gradient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { bold } from "./color.ts";
import { animateHeader, hslToRgb, rgbTo256, shineText } from "./gradient.ts";
import { useCaptureLog } from "../test/lib/stubs.ts";

const count = (haystack: string, needle: string) => haystack.split(needle).length - 1;

function firstRgb(s: string): [number, number, number] {
const m = s.match(/38;2;(\d+);(\d+);(\d+)/);
if (!m) throw new Error("no truecolor escape found");
return [Number(m[1]), Number(m[2]), Number(m[3])];
}

describe("hslToRgb", () => {
test.each([
{ hue: 0, rgb: [255, 0, 0], name: "red" },
{ hue: 120, rgb: [0, 255, 0], name: "green" },
{ hue: 240, rgb: [0, 0, 255], name: "blue" },
])("hue $hue maps to $name", ({ hue, rgb }) => {
expect(hslToRgb(hue, 1, 0.5)).toEqual(rgb as [number, number, number]);
});

test("clamps channels to the 0-255 byte range", () => {
const [r, g, b] = hslToRgb(300, 1, 0.6);
for (const channel of [r, g, b]) {
expect(channel).toBeGreaterThanOrEqual(0);
expect(channel).toBeLessThanOrEqual(255);
}
});
});

describe("rgbTo256", () => {
test.each([
{ rgb: [0, 0, 0], idx: 16, name: "black to cube origin" },
{ rgb: [255, 255, 255], idx: 231, name: "white to cube apex" },
{ rgb: [255, 0, 0], idx: 196, name: "red to cube red" },
])("$name", ({ rgb, idx }) => {
expect(rgbTo256(rgb[0]!, rgb[1]!, rgb[2]!)).toBe(idx);
});
});

describe("shineText", () => {
test("emits one truecolor escape per visible character and resets fg at the end", () => {
const out = shineText("Hi", { truecolor: true });
expect(count(out, "\x1b[38;2;")).toBe(2);
expect(out.endsWith("\x1b[39m")).toBe(true);
expect(out).toContain("H");
expect(out).toContain("i");
});

test("flat base color (no center) paints every character the same color", () => {
const out = shineText("abcd", { truecolor: true });
const triples = [...out.matchAll(/38;2;(\d+;\d+;\d+)/g)].map((m) => m[1]);
expect(triples).toHaveLength(4);
expect(new Set(triples).size).toBe(1);
});

test("the reflex brightens characters near its center", () => {
const flat = firstRgb(shineText("Next", { truecolor: true }));
const lit = firstRgb(shineText("Next", { truecolor: true, center: 0 }));
const sum = (c: [number, number, number]) => c[0] + c[1] + c[2];
expect(sum(lit)).toBeGreaterThan(sum(flat));
});

test("the reflex is local, a far character is unaffected", () => {
const flatFirst = firstRgb(shineText("Next", { truecolor: true }));
const litLastFirst = firstRgb(shineText("Next", { truecolor: true, center: 1 }));
expect(litLastFirst).toEqual(flatFirst);
});

test("uses the 256-color palette when truecolor is unavailable", () => {
const out = shineText("Hi", { truecolor: false });
expect(out).toContain("\x1b[38;5;");
expect(out).not.toContain("\x1b[38;2;");
});

test("leaves spaces uncolored so the gutter stays clean", () => {
const out = shineText("a b", { truecolor: true });
expect(count(out, "\x1b[38;2;")).toBe(2);
expect(out).toContain(" ");
});

test("a single character renders without a divide-by-zero blowup", () => {
const out = shineText("X", { truecolor: true, center: 0 });
expect(count(out, "\x1b[38;2;")).toBe(1);
expect(out).toContain("X");
});

test.each([
{ label: "", name: "empty string" },
{ label: " ", name: "whitespace only" },
])("$name emits no bare foreground reset that would clobber a surrounding color", ({ label }) => {
const out = shineText(label, { truecolor: true });
expect(out).toBe(label);
expect(out).not.toContain("\x1b[39m");
});
});

describe("animateHeader (non-interactive)", () => {
const captured = useCaptureLog();

test("emits the fallback-styled header once with no in-place redraw escapes", async () => {
await animateHeader({ prefix: "", label: "Next steps", fallback: bold });
expect(captured.stderr).toHaveLength(1);
const line = captured.stderr[0]!;
expect(line).toBe(`${bold("Next steps")}\n`);
expect(line).not.toContain("\r");
expect(line).not.toContain("\x1b[?25l");
});

test("preserves a caller-supplied gutter prefix", async () => {
await animateHeader({ prefix: "│ ", label: "Next steps", fallback: bold });
expect(captured.stderr[0]).toBe(`│ ${bold("Next steps")}\n`);
});
});

describe("animateHeader (interactive gating)", () => {
const captured = useCaptureLog();
const ENV_KEYS = ["CI", "NO_COLOR", "FORCE_COLOR", "COLORTERM"] as const;
let savedTTY: boolean | undefined;
let savedEnv: Record<string, string | undefined>;

beforeEach(() => {
savedTTY = process.stderr.isTTY;
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });
for (const k of ENV_KEYS) delete process.env[k];
process.env.COLORTERM = "truecolor";
});
afterEach(() => {
Object.defineProperty(process.stderr, "isTTY", { value: savedTTY, configurable: true });
for (const k of ENV_KEYS) {
if (savedEnv[k] == null) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});

const run = () =>
animateHeader({ prefix: "", label: "Hi", fallback: bold, frames: 2, intervalMs: 1 });

test("animates on an interactive TTY and balances cursor hide/restore", async () => {
await run();
expect(captured.err).toContain("\r");
expect(captured.err).toContain("\x1b[?25l");
expect(captured.err).toContain("\x1b[?25h");
});

test("NO_COLOR disables the animation (plain fallback, no redraw)", async () => {
process.env.NO_COLOR = "1";
await run();
expect(captured.err).not.toContain("\r");
expect(captured.err).not.toContain("\x1b[?25l");
});

test.each([
{ force: "0", name: "FORCE_COLOR=0" },
{ force: "false", name: "FORCE_COLOR=false" },
{ force: "", name: "FORCE_COLOR=''" },
])("$name does not override NO_COLOR", async ({ force }) => {
process.env.NO_COLOR = "1";
process.env.FORCE_COLOR = force;
await run();
expect(captured.err).not.toContain("\r");
});

test("FORCE_COLOR=1 forces the animation even with NO_COLOR set", async () => {
process.env.NO_COLOR = "1";
process.env.FORCE_COLOR = "1";
await run();
expect(captured.err).toContain("\r");
});
});
143 changes: 143 additions & 0 deletions packages/cli-core/src/lib/gradient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { log } from "./log.ts";

export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const c = (1 - Math.abs(2 * l - 1)) * s;
const hp = (((h % 360) + 360) % 360) / 60;
const x = c * (1 - Math.abs((hp % 2) - 1));
const m = l - c / 2;

let r = 0;
let g = 0;
let b = 0;
if (hp < 1) [r, g, b] = [c, x, 0];
else if (hp < 2) [r, g, b] = [x, c, 0];
else if (hp < 3) [r, g, b] = [0, c, x];
else if (hp < 4) [r, g, b] = [0, x, c];
else if (hp < 5) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];

const toByte = (v: number) => Math.round((v + m) * 255);
return [toByte(r), toByte(g), toByte(b)];
}

export function rgbTo256(r: number, g: number, b: number): number {
const channel = (v: number) => Math.round((v / 255) * 5);
return 16 + 36 * channel(r) + 6 * channel(g) + channel(b);
}

const BASE = { hue: 314, sat: 0.3, light: 0.63 };
const SHINE = { peakLight: 0.95, peakSat: 0.06, halfWidth: 0.3 };

interface ShineOptions {
center?: number;
truecolor?: boolean;
hue?: number;
sat?: number;
light?: number;
}

function fgEscape(hue: number, sat: number, light: number, truecolor: boolean): string {
const [r, g, b] = hslToRgb(hue, sat, light);
return truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
}

export function shineText(text: string, options: ShineOptions = {}): string {
const {
center,
truecolor = supportsTruecolor(),
hue = BASE.hue,
sat = BASE.sat,
light = BASE.light,
} = options;

const chars = [...text];
const denom = Math.max(1, chars.length - 1);
let out = "";
let opened = false;
chars.forEach((ch, i) => {
if (ch === " ") {
if (opened) {
out += "\x1b[39m";
opened = false;
}
out += ch;
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let s = sat;
let l = light;
if (center != null) {
const distance = Math.abs(i / denom - center);
const falloff = Math.max(0, 1 - distance / SHINE.halfWidth);
const intensity = falloff * falloff;
l = light + (SHINE.peakLight - light) * intensity;
s = sat + (SHINE.peakSat - sat) * intensity;
}
out += fgEscape(hue, s, l, truecolor) + ch;
opened = true;
});
return opened ? out + "\x1b[39m" : out;
}

const isInteractive = () => !!process.stderr.isTTY && !process.env.CI;

const forceColor = () => {
const v = process.env.FORCE_COLOR;
return v != null && v !== "0" && v !== "false" && v !== "";
};

const colorDisabled = () => "NO_COLOR" in process.env && !forceColor();

const supportsTruecolor = () => /truecolor|24bit/i.test(process.env.COLORTERM ?? "");

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

let cursorHidden = false;
let exitGuardRegistered = false;

function hideCursor(): void {
if (!exitGuardRegistered) {
process.on("exit", () => {
if (cursorHidden) log.ui("\x1b[?25h");
});
exitGuardRegistered = true;
}
cursorHidden = true;
log.ui("\x1b[?25l");
}

function showCursor(): void {
cursorHidden = false;
log.ui("\x1b[?25h");
}

interface AnimateHeaderOptions {
prefix: string;
label: string;
fallback: (s: string) => string;
frames?: number;
intervalMs?: number;
write?: (s: string) => void;
}

export async function animateHeader(options: AnimateHeaderOptions): Promise<void> {
const { prefix, label, fallback, frames = 18, intervalMs = 25, write = log.ui } = options;

if (!isInteractive() || colorDisabled()) {
write(`${prefix}${fallback(label)}\n`);
return;
}

const truecolor = supportsTruecolor();
const span = Math.max(1, frames - 1);
hideCursor();
try {
for (let frame = 0; frame < frames; frame++) {
const center = -0.3 + (frame / span) * 1.6;
write(`\r\x1b[K${prefix}${shineText(label, { center, truecolor })}`);
await sleep(intervalMs);
}
write(`\r\x1b[K${prefix}${shineText(label, { truecolor })}\n`);
} finally {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
showCursor();
}
}
Loading