diff --git a/README.md b/README.md index a20261a..cdd54e0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,7 @@

- spotuify logo + spotuify

-

spotuify

-

πŸ•Ί spotify in ur terminal

diff --git a/docs/assets/spotuify-lockup.png b/docs/assets/spotuify-lockup.png new file mode 100644 index 0000000..73d07b4 Binary files /dev/null and b/docs/assets/spotuify-lockup.png differ diff --git a/scripts/homebrew-formula.ts b/scripts/homebrew-formula.ts index b4094c7..0c814e5 100644 --- a/scripts/homebrew-formula.ts +++ b/scripts/homebrew-formula.ts @@ -1,5 +1,6 @@ import { artifactName, + PRODUCT_DESCRIPTION, RELEASE_TARGETS, REPOSITORY_URL, } from "./release-config.ts"; @@ -24,7 +25,7 @@ export function homebrewFormula( } return `class Spotuify < Formula - desc "Spotify in your terminal" + desc "${PRODUCT_DESCRIPTION}" homepage "${REPOSITORY_URL}" version "${version}" license "MIT" diff --git a/scripts/release-config.ts b/scripts/release-config.ts index 686ee60..f2e14d7 100644 --- a/scripts/release-config.ts +++ b/scripts/release-config.ts @@ -1,9 +1,10 @@ import { resolve } from "node:path"; +import { TAGLINE } from "../src/branding.ts"; import { isSemanticVersion, isStableVersion } from "../src/semver.ts"; export const REPOSITORY = "austin-smith/spotuify"; export const REPOSITORY_URL = `https://github.com/${REPOSITORY}`; -export const PRODUCT_DESCRIPTION = "spotify in ur terminal"; +export const PRODUCT_DESCRIPTION = TAGLINE; export const HOMEBREW_TAP_REPOSITORY = "austin-smith/homebrew-tap"; export const HOMEBREW_FORMULA_PATH = "Formula/spotuify.rb"; export const MACOS_DEPLOYMENT_TARGET = "13.0"; diff --git a/src/branding.ts b/src/branding.ts new file mode 100644 index 0000000..4115c33 --- /dev/null +++ b/src/branding.ts @@ -0,0 +1,2 @@ +/** Product tagline. Shared by the TUI splash, the CLI banner, and release packaging. */ +export const TAGLINE = "spotify in ur terminal"; diff --git a/src/cli/presenter.ts b/src/cli/presenter.ts index 9ae8c92..04997f1 100644 --- a/src/cli/presenter.ts +++ b/src/cli/presenter.ts @@ -12,6 +12,7 @@ import terminalHyperlinks from "supports-hyperlinks"; import type { Me } from "../api/types.ts"; import type { AuthenticationEvent } from "../auth/flow.ts"; import type { ClientIdSetupEvent } from "../auth/setup.ts"; +import { TAGLINE } from "../branding.ts"; import type { EngineAuthenticationEvent, EngineAuthenticationResult, @@ -292,7 +293,7 @@ export class CliPresenter { const output = new PresentationBuffer(columns); helper.helpWidth = columns - 6; const root = command.parent === null; - this.intro(root ? "spotify in ur terminal" : command.name(), output); + this.intro(root ? TAGLINE : command.name(), output); const usage = helper.commandUsage(command); const description = helper.commandDescription(command); diff --git a/src/cli/program.ts b/src/cli/program.ts index 8c8cc97..6b9a551 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,4 +1,5 @@ import { Command, CommanderError, Help, Option } from "commander"; +import { TAGLINE } from "../branding.ts"; import { VERSION } from "../version.ts"; import { registerDiscovery } from "./commands/discovery.ts"; import { registerFollow } from "./commands/follow.ts"; @@ -203,7 +204,7 @@ export function createCliProgram(dependencies: CliDependencies = {}): { const usageHint = "Run 'spotuify --help' for usage."; program .name("spotuify") - .description("Spotify in your terminal") + .description(TAGLINE) .optionsGroup(HELP_GROUP.general) .version(`spotuify ${VERSION}`, "-v, --version", "Show the product version") .addHelpOption( diff --git a/src/ui/Brand.tsx b/src/ui/Brand.tsx index 112bad2..6fa93c8 100644 --- a/src/ui/Brand.tsx +++ b/src/ui/Brand.tsx @@ -1,24 +1,25 @@ +import { TAGLINE } from "../branding.ts"; import { theme } from "./theme.ts"; import { wrap } from "./text.ts"; -const WORDMARK_WIDTH = 70; -const WORDMARK_HEIGHT = 6; +const WORDMARK_WIDTH = 29; +const WORDMARK_HEIGHT = 2; -export type BrandLockupMode = "block" | "plain"; +export type BrandLockupMode = "art" | "plain"; -/** Render the OpenTUI block wordmark only when it fits without clipping. */ +/** Render the OpenTUI wordmark art only when it fits without clipping. */ export function brandLockupMode( width: number, maxHeight = Number.POSITIVE_INFINITY, ): BrandLockupMode { - return width >= WORDMARK_WIDTH && maxHeight >= WORDMARK_HEIGHT ? "block" : "plain"; + return width >= WORDMARK_WIDTH && maxHeight >= WORDMARK_HEIGHT ? "art" : "plain"; } export function brandLockupHeight( width: number, maxHeight = Number.POSITIVE_INFINITY, ): number { - return brandLockupMode(width, maxHeight) === "block" ? WORDMARK_HEIGHT : 1; + return brandLockupMode(width, maxHeight) === "art" ? WORDMARK_HEIGHT : 1; } export interface BrandedScreenLayout { @@ -52,6 +53,8 @@ export interface BrandSplashLayout { innerWidth: number; messageLines: string[]; brandHeight: number; + taglineHeight: number; + taglineGapHeight: number; gapHeight: number; totalHeight: number; top: number; @@ -73,20 +76,39 @@ export function brandSplashLayout( const brandHeight = brandBudget > 0 ? brandLockupHeight(innerWidth, brandBudget) : 0; const gapHeight = brandHeight > 0 && remainingHeight > brandHeight ? 1 : 0; - const totalHeight = brandHeight + gapHeight + messageLines.length; + const untaggedHeight = brandHeight + gapHeight + messageLines.length; - // Anchor on the wordmark's own centered row, not the block's. Centering the whole block moved - // the brand whenever the message wrapped to a different number of rows. Only a terminal too - // short to fit the message below the anchored brand pushes the block up. + // Anchor on the wordmark's own centered row, not the splash region's. Centering the whole + // region moved the brand whenever the message wrapped to a different number of rows. Only a + // terminal too short to fit the message below the anchored brand pushes the wordmark up. const brandTop = Math.floor((availableHeight - brandHeight) / 2); + const anchor = (total: number) => + Math.max(0, Math.min(brandTop, availableHeight - total)); + const top = anchor(untaggedHeight); + + // The tagline is decoration and yields to everything else. It appears only under a visible + // wordmark, only when it fits on one row, and only when the extra row leaves the wordmark on + // the row it already occupies β€” messages come and go, and the brand must not drift with them. + // A blank row separates the tagline from the wordmark art, so the tagline costs two rows, not + // one, and is dropped whole rather than rendered flush against the lockup. + const taglineCost = 2; + const taglineHeight = + brandHeight > 0 && + wrap(TAGLINE, innerWidth).length === 1 && + untaggedHeight + taglineCost <= availableHeight && + anchor(untaggedHeight + taglineCost) === top + ? 1 + : 0; return { innerWidth, messageLines, brandHeight, + taglineHeight, + taglineGapHeight: taglineHeight, gapHeight, - totalHeight, - top: Math.max(0, Math.min(brandTop, availableHeight - totalHeight)), + totalHeight: untaggedHeight + taglineHeight * taglineCost, + top, }; } @@ -109,16 +131,24 @@ export function BrandLockup({ flexShrink={0} overflow="hidden" > - {mode === "block" ? ( - + {mode === "art" ? ( + // Rendered in three segments so the TUI in spoΒ·tuiΒ·fy carries the mark's second colour. + // The single-column boxes replace the inter-letter gaps the font adds internally, so the + // segments total exactly the width one "SPOTUIFY" call would have produced. + + + + + + + ) : ( - SPOTUIFY + SPO + + TUI + + FY )} @@ -152,6 +182,21 @@ export function BrandSplash({ {layout.brandHeight > 0 ? ( ) : null} + {layout.taglineHeight > 0 ? ( + + + {TAGLINE} + + + ) : null} {layout.messageLines.length > 0 ? ( > | undefined; @@ -22,9 +26,9 @@ afterEach(() => { describe("responsive brand lockup", () => { test("selects a lockup from measured width and height", () => { - expect(brandLockupMode(70, 6)).toBe("block"); - expect(brandLockupHeight(70, 6)).toBe(6); - expect(brandLockupMode(69, 6)).toBe("plain"); + expect(brandLockupMode(29, 2)).toBe("art"); + expect(brandLockupHeight(29, 2)).toBe(2); + expect(brandLockupMode(28, 2)).toBe("plain"); expect(brandLockupMode(100, 1)).toBe("plain"); }); @@ -34,6 +38,8 @@ describe("responsive brand lockup", () => { innerWidth: 20, messageLines: ["NOTHING PLAYING β€”", "press / to find", "something"], brandHeight: 1, + taglineHeight: 0, + taglineGapHeight: 0, gapHeight: 1, totalHeight: 5, top: 0, @@ -66,7 +72,7 @@ describe("responsive brand lockup", () => { const lines = setup.captureCharFrame().split("\n"); const screen = lines.join("\n"); expect(screen).toContain("Connecting to Spotify…"); - expect(screen).toContain(width >= 80 ? "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—" : "SPOTUIFY"); + expect(screen).toContain(WORDMARK_ART); for (const line of lines.slice(0, height)) { expect(Bun.stringWidth(line)).toBeLessThanOrEqual(width); } @@ -117,6 +123,83 @@ describe("responsive brand lockup", () => { expect(await wordmarkRow(playbackEmptyStateText(true, true, true))).toBe(blankRow); }); + test("yields the tagline row rather than moving the wordmark or clipping the message", () => { + const message = playbackEmptyStateText(true, true, true); + const rows = (height: number) => brandSplashLayout(message, 60, height); + + // Heights where the tagline and its gap would shift the wordmark or overflow the region. + for (let height = 3; height <= 8; height++) { + const layout = rows(height); + expect(layout.brandHeight).toBeGreaterThan(0); + expect(layout.taglineHeight).toBe(0); + expect(layout.totalHeight).toBeLessThanOrEqual(height); + } + + // The first height with both rows genuinely to spare takes them. + const taken = rows(9); + expect(taken.taglineHeight).toBe(1); + expect(taken.totalHeight).toBeLessThanOrEqual(9); + expect(taken.top).toBe(rows(8).top); + }); + + test("never lets the tagline drift the wordmark between messages", () => { + const width = 60; + const height = 19; + const layouts = [ + "", + STARTUP_MESSAGE, + playbackEmptyStateText(true, true, true), + playbackEmptyStateText(true, false, true), + ].map((message) => brandSplashLayout(message, width, height)); + + expect(new Set(layouts.map((layout) => layout.top)).size).toBe(1); + expect(new Set(layouts.map((layout) => layout.brandHeight)).size).toBe(1); + expect(layouts.every((layout) => layout.taglineHeight === 1)).toBe(true); + }); + + test("renders the tagline between the wordmark and the message", async () => { + const width = 60; + const height = 20; + setup = await createTestRenderer({ width, height }); + createRoot(setup.renderer).render( + + + , + ); + await Bun.sleep(20); + await setup.renderOnce(); + + const layout = brandSplashLayout("Connecting to Spotify…", width, height); + const lines = setup.captureCharFrame().split("\n"); + const wordmarkRow = lines.findIndex((line) => line.includes(WORDMARK_ART)); + const taglineRow = lines.findIndex((line) => line.includes(TAGLINE)); + const messageRow = lines.findIndex((line) => line.includes("Connecting to Spotify…")); + + // A blank row separates the tagline from the wordmark art, and another from the message. + expect(layout.taglineHeight).toBe(1); + expect(wordmarkRow).toBeGreaterThanOrEqual(0); + expect(taglineRow).toBe(wordmarkRow + layout.brandHeight + 1); + expect(messageRow).toBe(taglineRow + 2); + for (const line of lines.slice(0, height)) { + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(width); + } + }); + + test("paints the segmented wordmark unclipped at its exact breakpoint", async () => { + // The segments are sized against gaps the font inserts internally, so a re-split or a font + // swap could satisfy the mode check while the lockup box clips the right edge. + const width = 29; + setup = await createTestRenderer({ width, height: 3 }); + createRoot(setup.renderer).render(); + await Bun.sleep(20); + await setup.renderOnce(); + + const rows = setup.captureCharFrame().split("\n").filter((row) => row.trim()); + expect(brandLockupMode(width, 2)).toBe("art"); + expect(rows[0]?.trimEnd()).toBe("β–ˆβ–€β–€ β–ˆβ–€β–ˆ β–ˆβ–€β–ˆ β–€β–ˆβ–€ β–ˆ β–ˆ β–ˆ β–ˆβ–€β–€ β–ˆβ–„β–ˆ"); + expect(rows[1]?.trimEnd()).toBe("β–„β–„β–ˆ β–ˆβ–€β–€ β–ˆβ–„β–ˆ β–ˆ β–ˆβ–„β–ˆ β–ˆ β–ˆβ–€ β–ˆ"); + }); + test("falls back to readable plain text in a tiny region", async () => { setup = await createTestRenderer({ width: 24, height: 5 }); createRoot(setup.renderer).render(); diff --git a/test/homebrew-tap.test.ts b/test/homebrew-tap.test.ts index 0c29abb..b86ad6c 100644 --- a/test/homebrew-tap.test.ts +++ b/test/homebrew-tap.test.ts @@ -27,7 +27,6 @@ describe("Homebrew tap publisher", () => { expect(rendered).toContain("on_macos do"); expect(rendered).toContain("on_linux do"); expect(rendered).toContain("if Hardware::CPU.arm?"); - expect(rendered).toContain('desc "Spotify in your terminal"'); expect(rendered).toContain("spotuify-v1.2.3-darwin-arm64.tar.gz"); expect(rendered).toContain("spotuify-v1.2.3-linux-arm64.tar.gz"); expect(rendered).toContain("spotuify-v1.2.3-linux-x64.tar.gz"); diff --git a/test/playback-empty-state.test.tsx b/test/playback-empty-state.test.tsx index ee0071b..ab2fcf4 100644 --- a/test/playback-empty-state.test.tsx +++ b/test/playback-empty-state.test.tsx @@ -49,7 +49,7 @@ describe("empty playback guidance", () => { test("shows the wordmark alone while startup is inside the grace window", async () => { const screen = await renderEmptyState(false, false, 90, false); - expect(screen).toContain("β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—"); + expect(screen).toContain("β–ˆβ–€β–€ β–ˆβ–€β–ˆ β–ˆβ–€β–ˆ"); expect(screen).not.toContain(STARTUP_MESSAGE); expect(screen).not.toContain("NOTHING PLAYING"); }); diff --git a/test/setup-screen.test.tsx b/test/setup-screen.test.tsx index 2520d03..c87f060 100644 --- a/test/setup-screen.test.tsx +++ b/test/setup-screen.test.tsx @@ -3,6 +3,9 @@ import { createRoot } from "@opentui/react"; import { afterEach, describe, expect, test } from "bun:test"; import { setupScreenLayout, SetupScreen } from "../src/ui/SetupScreen.tsx"; +/** Leading cells of the `tiny` wordmark art, which spells nothing a substring search can find. */ +const WORDMARK_ART = "β–ˆβ–€β–€ β–ˆβ–€β–ˆ β–ˆβ–€β–ˆ"; + const SUPPORTED_SIZES = [ [60, 20], [80, 24], @@ -78,7 +81,7 @@ describe("setup screen layout", () => { const screen = lines.join("\n"); expect(screen).toContain("SPOTUIFY"); - expect(screen).not.toContain("β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—"); + expect(screen).not.toContain(WORDMARK_ART); expect(screen).toContain("ENDOFHANDOFF"); expect(screen).toContain("Update available β€” run: spotuify update"); expect(lines[16]).toContain("q to quit."); @@ -90,7 +93,7 @@ describe("setup screen layout", () => { const lines = await render(width, height, true); const screen = lines.join("\n"); - expect(screen).toContain(width >= 80 ? "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—" : "SPOTUIFY"); + expect(screen).toContain(WORDMARK_ART); expect(screen).toContain("Setup required."); expect(screen).toContain("Run spotuify auth to get started."); expect(screen).not.toContain("developer.spotify.com"); diff --git a/test/startup-error.test.tsx b/test/startup-error.test.tsx index 90cf42a..5a9125a 100644 --- a/test/startup-error.test.tsx +++ b/test/startup-error.test.tsx @@ -10,6 +10,9 @@ import { StartupErrorScreen, } from "../src/ui/StartupErrorScreen.tsx"; +/** Leading cells of the `tiny` wordmark art, which spells nothing a substring search can find. */ +const WORDMARK_ART = "β–ˆβ–€β–€ β–ˆβ–€β–ˆ β–ˆβ–€β–ˆ"; + const SUPPORTED_SIZES = [ [60, 20], [80, 24], @@ -89,7 +92,8 @@ describe("startup error screen layout", () => { expect(layout.messageLines).toHaveLength(7); expect(layout.messageLines.at(-1)).toBe("END-OF-DIAGNOSTIC"); - expect(layout.brandHeight).toBe(1); + // Seven rows of diagnostic still leave room for the two-row wordmark art. + expect(layout.brandHeight).toBe(2); }); test("keeps a seven-row diagnostic intact at 80x18", async () => { @@ -103,8 +107,7 @@ describe("startup error screen layout", () => { const lines = setup.captureCharFrame().split("\n"); const screen = lines.join("\n"); - expect(screen).toContain("SPOTUIFY"); - expect(screen).not.toContain("β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—"); + expect(screen).toContain(WORDMARK_ART); expect(screen).toContain("END-OF-DIAGNOSTIC"); expect(lines[15]).toContain("r to retry."); expect(lines[16]).toContain("q to quit.");