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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
<p align="center">
<img src="./docs/assets/spotuify-logo.png" alt="spotuify logo" width="180" />
<img src="./docs/assets/spotuify-lockup.png" alt="spotuify" width="460" />
</p>

<h1 align="center">spotuify</h1>

<p align="center">🕺 spotify in ur terminal</p>

<p align="center">
Expand Down
Binary file added docs/assets/spotuify-lockup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion scripts/homebrew-formula.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
artifactName,
PRODUCT_DESCRIPTION,
RELEASE_TARGETS,
REPOSITORY_URL,
} from "./release-config.ts";
Expand All @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion scripts/release-config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 2 additions & 0 deletions src/branding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Product tagline. Shared by the TUI splash, the CLI banner, and release packaging. */
export const TAGLINE = "spotify in ur terminal";
3 changes: 2 additions & 1 deletion src/cli/presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/cli/program.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
85 changes: 65 additions & 20 deletions src/ui/Brand.tsx
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
austin-smith marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -52,6 +53,8 @@ export interface BrandSplashLayout {
innerWidth: number;
messageLines: string[];
brandHeight: number;
taglineHeight: number;
taglineGapHeight: number;
gapHeight: number;
totalHeight: number;
top: number;
Expand All @@ -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,
};
}

Expand All @@ -109,16 +131,24 @@ export function BrandLockup({
flexShrink={0}
overflow="hidden"
>
{mode === "block" ? (
<ascii-font
text="SPOTUIFY"
font="block"
color={theme.brand}
selectable={false}
/>
{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.
<box flexDirection="row">
<ascii-font text="SPO" font="tiny" color={theme.brand} selectable={false} />
<box width={1} />
<ascii-font text="TUI" font="tiny" color={theme.brandCream} selectable={false} />
<box width={1} />
<ascii-font text="FY" font="tiny" color={theme.brand} selectable={false} />
</box>
) : (
<text fg={theme.brand}>
<strong>SPOTUIFY</strong>
<strong>SPO</strong>
<span fg={theme.brandCream}>
<strong>TUI</strong>
</span>
<strong>FY</strong>
</text>
)}
</box>
Expand Down Expand Up @@ -152,6 +182,21 @@ export function BrandSplash({
{layout.brandHeight > 0 ? (
<BrandLockup width={layout.innerWidth} maxHeight={layout.brandHeight} />
) : null}
{layout.taglineHeight > 0 ? (
<box
width={layout.innerWidth}
height={layout.taglineHeight}
marginTop={layout.taglineGapHeight}
alignItems="center"
justifyContent="center"
flexShrink={0}
overflow="hidden"
>
<text fg={theme.label} selectable={false}>
{TAGLINE}
</text>
</box>
) : null}
{layout.messageLines.length > 0 ? (
<box
width={layout.innerWidth}
Expand Down
2 changes: 2 additions & 0 deletions src/ui/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const theme = {
accent: ACCENT,
/** Slightly greener chartreuse derived from the supplied mark; reserved for brand art. */
brand: "#c1c21c",
/** The mark's cream, sampled from the logo. Picks the TUI out of spo·tui·fy in the wordmark. */
brandCream: "#f3e3c1",
/** Dimmed accent for filled-but-inactive elements (e.g. the played part of a bar). */
accentDim: "#12622f",
/** Panel borders and separators — present but quiet. */
Expand Down
91 changes: 87 additions & 4 deletions test/brand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
brandLockupMode,
brandSplashLayout,
} from "../src/ui/Brand.tsx";
import { TAGLINE } from "../src/branding.ts";

/** Leading cells of the `tiny` wordmark art, which spells nothing a substring search can find. */
const WORDMARK_ART = "█▀▀ █▀█ █▀█";

let setup: Awaited<ReturnType<typeof createTestRenderer>> | undefined;

Expand All @@ -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");
});

Expand All @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
<box width={width} height={height} position="relative">
<BrandSplash message="Connecting to Spotify…" width={width} height={height} />
</box>,
);
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(<BrandLockup width={width} maxHeight={2} />);
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(<BrandLockup width={24} maxHeight={1} />);
Expand Down
1 change: 0 additions & 1 deletion test/homebrew-tap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion test/playback-empty-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
7 changes: 5 additions & 2 deletions test/setup-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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.");
Expand All @@ -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");
Expand Down
Loading