From 2b5978a592ac2a5051b01a2b018d1810f318c07c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 8 Jun 2026 18:51:19 -0300 Subject: [PATCH 1/3] feat(cli): animate the "Next steps" header with a mauve reflex sweep 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 TTY; piped output, CI, and NO_COLOR fall back to plain styling. A process exit guard restores the cursor if Ctrl-C lands mid-sweep, and FORCE_COLOR is parsed so 0/false/"" don't override NO_COLOR. --- .changeset/next-steps-gradient-animation.md | 5 + packages/cli-core/src/commands/auth/login.ts | 4 +- packages/cli-core/src/commands/deploy/copy.ts | 5 +- .../cli-core/src/commands/deploy/index.ts | 11 +- packages/cli-core/src/commands/link/index.ts | 2 +- packages/cli-core/src/lib/gradient.test.ts | 172 ++++++++++++++++++ packages/cli-core/src/lib/gradient.ts | 138 ++++++++++++++ packages/cli-core/src/lib/spinner.ts | 18 +- 8 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 .changeset/next-steps-gradient-animation.md create mode 100644 packages/cli-core/src/lib/gradient.test.ts create mode 100644 packages/cli-core/src/lib/gradient.ts diff --git a/.changeset/next-steps-gradient-animation.md b/.changeset/next-steps-gradient-animation.md new file mode 100644 index 00000000..d5236292 --- /dev/null +++ b/.changeset/next-steps-gradient-animation.md @@ -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. diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index e7bbb900..3268195d 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -99,7 +99,7 @@ export async function login(options: LoginOptions = {}): Promise { 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"); } @@ -129,7 +129,7 @@ export async function login(options: LoginOptions = {}): Promise { const claimResult = await handleAutoclaim(process.cwd()); if (showNextSteps) { - outro(await loginNextSteps(claimResult)); + await outro(await loginNextSteps(claimResult)); } else { outro("Done"); } diff --git a/packages/cli-core/src/commands/deploy/copy.ts b/packages/cli-core/src/commands/deploy/copy.ts index a4260fa4..bd65f36a 100644 --- a/packages/cli-core/src/commands/deploy/copy.ts +++ b/packages/cli-core/src/commands/deploy/copy.ts @@ -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 diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index eb8fdbcb..b1115a02 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -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, @@ -27,7 +29,7 @@ import { dnsDashboardHandoff, dnsIntro, dnsRecords, - nextStepsBlock, + nextStepsBody, pendingDnsRecords, pausedOperationNotice, printPlan, @@ -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"); } diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 154b0536..c5e74a8f 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -104,7 +104,7 @@ export async function link(options: LinkOptions = {}): Promise { 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() { diff --git a/packages/cli-core/src/lib/gradient.test.ts b/packages/cli-core/src/lib/gradient.test.ts new file mode 100644 index 00000000..1f3653e2 --- /dev/null +++ b/packages/cli-core/src/lib/gradient.test.ts @@ -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; + + 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"); + }); +}); diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts new file mode 100644 index 00000000..25bd4d59 --- /dev/null +++ b/packages/cli-core/src/lib/gradient.ts @@ -0,0 +1,138 @@ +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 === " ") { + out += ch; + return; + } + 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((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; +} + +export async function animateHeader(options: AnimateHeaderOptions): Promise { + const { prefix, label, fallback, frames = 18, intervalMs = 25 } = options; + + if (!isInteractive() || colorDisabled()) { + log.ui(`${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; + log.ui(`\r\x1b[K${prefix}${shineText(label, { center, truecolor })}`); + await sleep(intervalMs); + } + log.ui(`\r\x1b[K${prefix}${shineText(label, { truecolor })}\n`); + } finally { + showCursor(); + } +} diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index b482eeb0..6be20a1d 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -2,6 +2,7 @@ import { Writable } from "node:stream"; import { intro as clackIntro, outro as clackOutro, spinner as clackSpinner } from "@clack/prompts"; import { isHuman } from "../mode.ts"; import { dim, cyan } from "./color.ts"; +import { animateHeader } from "./gradient.ts"; import { UserAbortError, isPromptExitError } from "./errors.ts"; import { log, pushPrefix, popPrefix } from "./log.ts"; import { getUiOutput } from "./ui.ts"; @@ -37,14 +38,23 @@ export function intro(title?: string) { pushPrefix(); } -/** Print outro bracket; restores normal `log.*` output. Pass a string[] to render next steps. */ -export function outro(messageOrSteps?: string | readonly string[]) { +/** + * Print outro bracket: + * + * ``` + * │ + * └ $message + * ``` + * + * Then restores normal log output. Pass a string[] to render as next steps + * after the bracket. + **/ +export async function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; popPrefix(); if (Array.isArray(messageOrSteps)) { - writeUi(`${dim(S_BAR)}\n`); - writeUi(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); + await animateHeader({ prefix: `${dim(S_BAR_END)} `, label: "Next steps", fallback: dim }); for (const step of messageOrSteps) { writeUi(` ${cyan("→")} ${step}\n`); } From 01cf1394c82c036ac8651d01d7b9155c20d278dd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 09:53:51 -0300 Subject: [PATCH 2/3] fix(gradient): reset foreground color before appending spaces in shineText Spaces were inheriting the previous character's foreground color because the space branch appended the character while the ANSI color escape was still active. Now resets to the default foreground color (\x1b[39m) before the space is output, and also awaits outro() in withGutter since outro is now async. Addresses CodeRabbit review comment on PR #322. --- packages/cli-core/src/lib/gradient.ts | 4 ++++ packages/cli-core/src/lib/spinner.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts index 25bd4d59..9d2e770c 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -56,6 +56,10 @@ export function shineText(text: string, options: ShineOptions = {}): string { let opened = false; chars.forEach((ch, i) => { if (ch === " ") { + if (opened) { + out += "\x1b[39m"; + opened = false; + } out += ch; return; } diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 6be20a1d..d684fcae 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -113,13 +113,13 @@ export async function withGutter( intro(title); try { const result = await fn(controls); - outro(nextSteps); + await outro(nextSteps); return result; } catch (error) { if (error instanceof UserAbortError || isPromptExitError(error)) { pausedOutro(); } else { - outro("Failed"); + await outro("Failed"); } throw error; } From 9d98b899fd6d670d2534a4d795781be0fbf6a59d Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 10 Jun 2026 11:06:01 -0300 Subject: [PATCH 3/3] fix(gradient): route animateHeader output through caller-supplied writer Add a `write` option to `AnimateHeaderOptions` (defaults to `log.ui`) so callers can direct header output to the same stream as the surrounding UI. Pass `writeUi` from `spinner.ts` so the "Next steps" header lands on the active UI output stream instead of bypassing it via `log.ui` directly. Fix the spinner test to properly await the now-async `outro` call inside the stderr capture helper. --- packages/cli-core/src/lib/gradient.ts | 9 +++++---- packages/cli-core/src/lib/spinner.test.ts | 6 ++---- packages/cli-core/src/lib/spinner.ts | 7 ++++++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/lib/gradient.ts b/packages/cli-core/src/lib/gradient.ts index 9d2e770c..0a88de49 100644 --- a/packages/cli-core/src/lib/gradient.ts +++ b/packages/cli-core/src/lib/gradient.ts @@ -116,13 +116,14 @@ interface AnimateHeaderOptions { fallback: (s: string) => string; frames?: number; intervalMs?: number; + write?: (s: string) => void; } export async function animateHeader(options: AnimateHeaderOptions): Promise { - const { prefix, label, fallback, frames = 18, intervalMs = 25 } = options; + const { prefix, label, fallback, frames = 18, intervalMs = 25, write = log.ui } = options; if (!isInteractive() || colorDisabled()) { - log.ui(`${prefix}${fallback(label)}\n`); + write(`${prefix}${fallback(label)}\n`); return; } @@ -132,10 +133,10 @@ export async function animateHeader(options: AnimateHeaderOptions): Promise { expect(isInsideGutter()).toBe(false); }); -test("outro with string[] renders custom Next steps block and does not call clack outro", () => { +test("outro with string[] renders custom Next steps block and does not call clack outro", async () => { intro("Hello"); - captureStderr(() => { - outro(["Run `clerk dev`", "Open the dashboard"]); - }); + await captureStderrAsync(() => outro(["Run `clerk dev`", "Open the dashboard"])); // Custom block replaces clack's outro, so clack outro is not invoked. expect(outroCalls).toBe(0); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index d684fcae..7457e3c0 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -54,7 +54,12 @@ export async function outro(messageOrSteps?: string | readonly string[]) { popPrefix(); if (Array.isArray(messageOrSteps)) { - await animateHeader({ prefix: `${dim(S_BAR_END)} `, label: "Next steps", fallback: dim }); + await animateHeader({ + prefix: `${dim(S_BAR_END)} `, + label: "Next steps", + fallback: dim, + write: writeUi, + }); for (const step of messageOrSteps) { writeUi(` ${cyan("→")} ${step}\n`); }