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
16 changes: 16 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to `create-start-kit-dev` will be documented in this file.

## [0.1.8] - 2026-02-22

### Added

- Theme customization flags for `create` command: `--theme`, `--base-color`, `--radius`, `--font`
- 21 built-in color themes (neutral, stone, zinc, gray, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow)
- 4 base colors, 5 radius presets, 3 font options (Inter, Geist Sans, System)
- Generated `app.css` includes full CSS variable blocks, `@theme inline`, `@layer base`, and marquee utilities
- Graceful fallback to defaults for invalid flag values with warning messages
- Unit tests for theme parsing and CSS generation (24 tests)

### Changed

- `create` command now skips `--` prefixed args when detecting project name
- Updated usage help to document theme options

## [0.1.6] - 2026-02-19

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-start-kit-dev",
"version": "0.1.7",
"version": "0.1.8",
"description": "CLI for scaffolding and configuring Start Kit projects",
"type": "module",
"license": "MIT",
Expand Down
19 changes: 16 additions & 3 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { runEnv } from "./phases/env";
import { runFeatures } from "./phases/features";
import { runInfra } from "./phases/infra";
import { runScaffold } from "./phases/scaffold";
import { applyTheme, parseThemeArgs } from "./theme/theme-apply";

const PHASES: {
key: Phase;
Expand Down Expand Up @@ -126,22 +127,34 @@ async function runWizard(state: SetupState): Promise<void> {
function showUsage(): void {
console.log("Usage:");
console.log(
" bunx create-start-kit-dev create [project-name] Create a new project"
" bunx create-start-kit-dev create [project-name] [options] Create a new project"
);
console.log(
" bunx create-start-kit-dev init [--step <phase>] Setup existing project"
" bunx create-start-kit-dev init [--step <phase>] Setup existing project"
);
console.log("");
console.log("Theme options:");
console.log(" --theme <name> Theme color (blue, red, green, purple, ...)");
console.log(" --base-color <name> Base color (neutral, stone, zinc, gray)");
console.log(" --radius <preset> Border radius (none, sm, md, lg, xl)");
console.log(" --font <name> Font family (inter, geist, system)");
console.log("");
console.log("Phases: branding, features, database, env, infra");
}

async function handleCreate(args: string[]): Promise<void> {
const projectName = args.at(1);
const firstArg = args.at(1);
const projectName = firstArg && !firstArg.startsWith("--") ? firstArg : undefined;

intro("Start Kit — Create New Project");

const targetDir = await runScaffold(projectName);

const themeConfig = parseThemeArgs(args);
if (themeConfig) {
applyTheme(targetDir, themeConfig);
}

// Change working directory to the new project
process.chdir(targetDir);

Expand Down
80 changes: 80 additions & 0 deletions packages/cli/src/theme/__tests__/theme-apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from "bun:test";
import { parseThemeArgs } from "../theme-apply";

describe("parseThemeArgs", () => {
it("returns null when no theme flags are present", () => {
expect(parseThemeArgs(["create", "my-app"])).toBeNull();
});

it("parses all four flags", () => {
const result = parseThemeArgs([
"create",
"my-app",
"--theme",
"blue",
"--base-color",
"zinc",
"--radius",
"md",
"--font",
"geist",
]);

expect(result).toEqual({
theme: "blue",
baseColor: "zinc",
radius: "md",
font: "geist",
});
});

it("uses defaults for missing flags when at least one flag is present", () => {
const result = parseThemeArgs(["create", "my-app", "--theme", "red"]);

expect(result).toEqual({
theme: "red",
baseColor: "neutral",
radius: "lg",
font: "inter",
});
});

it("falls back to defaults for invalid theme value", () => {
const result = parseThemeArgs(["create", "--theme", "nonexistent"]);

expect(result).not.toBeNull();
expect(result!.theme).toBe("neutral");
});

it("falls back to defaults for invalid base-color value", () => {
const result = parseThemeArgs(["create", "--base-color", "nope"]);

expect(result).not.toBeNull();
expect(result!.baseColor).toBe("neutral");
});

it("falls back to defaults for invalid radius value", () => {
const result = parseThemeArgs(["create", "--radius", "huge"]);

expect(result).not.toBeNull();
expect(result!.radius).toBe("lg");
});

it("falls back to defaults for invalid font value", () => {
const result = parseThemeArgs(["create", "--font", "comic-sans"]);

expect(result).not.toBeNull();
expect(result!.font).toBe("inter");
});

it("handles flags without project name", () => {
const result = parseThemeArgs(["create", "--theme", "blue"]);

expect(result).toEqual({
theme: "blue",
baseColor: "neutral",
radius: "lg",
font: "inter",
});
});
});
102 changes: 102 additions & 0 deletions packages/cli/src/theme/__tests__/theme-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "bun:test";
import { buildTheme, generateFullAppCss, hexToOklch } from "../theme-utils";

describe("buildTheme", () => {
it("returns base-only vars when theme is a base color", () => {
const result = buildTheme("neutral", "neutral");
expect(result.light.background).toBe("oklch(1 0 0)");
expect(result.light.primary).toBe("oklch(0.205 0 0)");
});

it("merges base + accent when theme is an accent color", () => {
const result = buildTheme("neutral", "blue");
// base background from neutral
expect(result.light.background).toBe("oklch(1 0 0)");
// primary overridden by blue accent
expect(result.light.primary).toBe("oklch(0.488 0.243 264.376)");
});

it("falls back to first theme when base color is invalid", () => {
const result = buildTheme("invalid", "blue");
expect(result.light.background).toBe("oklch(1 0 0)");
});

it("returns base-only when accent is not found", () => {
const result = buildTheme("zinc", "nonexistent");
expect(result.light.primary).toBe("oklch(0.21 0.006 285.885)");
});
});

describe("hexToOklch", () => {
it("converts black", () => {
const result = hexToOklch("#000000");
expect(result).toBe("oklch(0.000 0.000 0.0)");
});

it("converts white", () => {
const result = hexToOklch("#ffffff");
expect(result).toMatch(/^oklch\(1\.000 0\.000/);
});

it("returns null for invalid hex", () => {
expect(hexToOklch("not-a-color")).toBeNull();
});

it("handles hex without hash", () => {
expect(hexToOklch("ff0000")).not.toBeNull();
});
});

describe("generateFullAppCss", () => {
const defaultConfig = {
baseColor: "neutral" as const,
theme: "blue" as const,
radius: "md" as const,
font: "inter" as const,
};

it("contains tailwind imports", () => {
const css = generateFullAppCss(defaultConfig);
expect(css).toContain('@import "tailwindcss"');
expect(css).toContain('@import "tw-animate-css"');
});

it("contains @theme inline block", () => {
const css = generateFullAppCss(defaultConfig);
expect(css).toContain("@theme inline");
});

it("contains :root and .dark blocks", () => {
const css = generateFullAppCss(defaultConfig);
expect(css).toContain(":root {");
expect(css).toContain(".dark {");
});

it("contains @layer base block", () => {
const css = generateFullAppCss(defaultConfig);
expect(css).toContain("@layer base");
});

it("contains marquee utilities block", () => {
const css = generateFullAppCss(defaultConfig);
expect(css).toContain("@layer utilities");
expect(css).toContain("tech-marquee-scroll");
expect(css).toContain("prefers-reduced-motion");
});

it("includes geist font import when font is geist", () => {
const css = generateFullAppCss({ ...defaultConfig, font: "geist" });
expect(css).toContain('@import "@fontsource-variable/geist"');
expect(css).toContain('"Geist Variable", sans-serif');
});

it("does not include geist import for inter font", () => {
const css = generateFullAppCss({ ...defaultConfig, font: "inter" });
expect(css).not.toContain("fontsource-variable/geist");
});

it("sets correct radius value", () => {
const css = generateFullAppCss({ ...defaultConfig, radius: "md" });
expect(css).toContain("--radius: 0.5rem;");
});
});
90 changes: 90 additions & 0 deletions packages/cli/src/theme/theme-apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { writeFileSync } from "node:fs";
import { log, spinner } from "@clack/prompts";
import type {
BaseColorName,
FontOption,
RadiusPreset,
ThemeConfig,
ThemeName,
} from "./theme-types";
import { DEFAULT_CONFIG, FONT_OPTIONS, RADIUS_VALUES, THEMES } from "./theme-registry";
import { generateFullAppCss } from "./theme-utils";

const THEME_NAMES = THEMES.map((t) => t.name);
const RADIUS_KEYS = Object.keys(RADIUS_VALUES) as RadiusPreset[];
const FONT_VALUES = FONT_OPTIONS.map((f) => f.value);

function extractFlag(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
if (idx < 0 || idx + 1 >= args.length) return undefined;
return args[idx + 1];
}

export function parseThemeArgs(args: string[]): ThemeConfig | null {
const rawTheme = extractFlag(args, "--theme");
const rawBase = extractFlag(args, "--base-color");
const rawRadius = extractFlag(args, "--radius");
const rawFont = extractFlag(args, "--font");

if (!rawTheme && !rawBase && !rawRadius && !rawFont) {
return null;
}

let theme: ThemeName = DEFAULT_CONFIG.theme;
if (rawTheme) {
if (THEME_NAMES.includes(rawTheme)) {
theme = rawTheme as ThemeName;
} else {
log.warn(
`Unknown theme "${rawTheme}". Available: ${THEME_NAMES.join(", ")}. Using default "${DEFAULT_CONFIG.theme}".`
);
}
}

let baseColor: BaseColorName = DEFAULT_CONFIG.baseColor;
if (rawBase) {
if (["neutral", "stone", "zinc", "gray"].includes(rawBase)) {
baseColor = rawBase as BaseColorName;
} else {
log.warn(
`Unknown base color "${rawBase}". Available: neutral, stone, zinc, gray. Using default "${DEFAULT_CONFIG.baseColor}".`
);
}
}

let radius: RadiusPreset = DEFAULT_CONFIG.radius;
if (rawRadius) {
if (RADIUS_KEYS.includes(rawRadius as RadiusPreset)) {
radius = rawRadius as RadiusPreset;
} else {
log.warn(
`Unknown radius "${rawRadius}". Available: ${RADIUS_KEYS.join(", ")}. Using default "${DEFAULT_CONFIG.radius}".`
);
}
}

let font: FontOption = DEFAULT_CONFIG.font;
if (rawFont) {
if (FONT_VALUES.includes(rawFont as FontOption)) {
font = rawFont as FontOption;
} else {
log.warn(
`Unknown font "${rawFont}". Available: ${FONT_VALUES.join(", ")}. Using default "${DEFAULT_CONFIG.font}".`
);
}
}

return { theme, baseColor, radius, font };
}

export function applyTheme(targetDir: string, config: ThemeConfig): void {
const s = spinner();
s.start("Applying theme...");

const css = generateFullAppCss(config);
writeFileSync(`${targetDir}/src/app.css`, css, "utf-8");

s.stop(
`Theme applied: ${config.theme} (base: ${config.baseColor}, radius: ${config.radius}, font: ${config.font})`
);
}
Loading
Loading