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..0a88de49 --- /dev/null +++ b/packages/cli-core/src/lib/gradient.ts @@ -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; + } + 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; + write?: (s: string) => void; +} + +export async function animateHeader(options: AnimateHeaderOptions): Promise { + 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 { + showCursor(); + } +} diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts index b5fc5ffc..b91351c1 100644 --- a/packages/cli-core/src/lib/spinner.test.ts +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -115,11 +115,9 @@ test("outro forwards the label to clack and pops the gutter prefix", () => { 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 b482eeb0..7457e3c0 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,28 @@ 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, + write: writeUi, + }); for (const step of messageOrSteps) { writeUi(` ${cyan("→")} ${step}\n`); } @@ -103,13 +118,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; }