diff --git a/README.md b/README.md
index a20261a..cdd54e0 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,7 @@
-
+
-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.");