create-skaff 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/skaff.js +2 -0
- package/package.json +4 -3
- package/src/components/progress-timeline.tsx +5 -3
- package/src/components/radio-prompt.tsx +50 -0
- package/src/components/wizard-app.tsx +26 -6
- package/src/index.tsx +0 -1
- package/src/lib/features.ts +26 -2
- package/src/lib/scaffold-steps.ts +143 -63
- package/src/lib/shadcn-presets.ts +12 -0
- package/src/lib/utils/add-query-provider.ts +38 -0
- package/src/lib/utils/extend-gitignore.ts +15 -0
- package/src/lib/utils/write-agent-config.ts +25 -12
- package/src/templates/agent-rules.md +155 -0
- package/src/components/package-manager-prompt.tsx +0 -38
package/bin/skaff.js
ADDED
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-skaff",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Interactive Next.js project scaffolder: Tailwind, shadcn/ui, Motion, Lucide, Oxlint, Oxfmt, Ultracite, Claude and Codex config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"create-skaff": "
|
|
8
|
-
"skaff": "
|
|
7
|
+
"create-skaff": "bin/skaff.js",
|
|
8
|
+
"skaff": "bin/skaff.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
+
"bin",
|
|
11
12
|
"src"
|
|
12
13
|
],
|
|
13
14
|
"scripts": {
|
|
@@ -14,9 +14,11 @@ export function ProgressTimeline({ config, onExit }: ProgressTimelineProps) {
|
|
|
14
14
|
});
|
|
15
15
|
return (
|
|
16
16
|
<box flexDirection="column">
|
|
17
|
-
{steps
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
{steps
|
|
18
|
+
.filter((step) => step.status !== "pending")
|
|
19
|
+
.map((step) => (
|
|
20
|
+
<StepRow key={step.id} step={step} />
|
|
21
|
+
))}
|
|
20
22
|
{error ? (
|
|
21
23
|
<box flexDirection="column">
|
|
22
24
|
<text>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
export type RadioOption<T extends string> = { value: T; label: string; description?: string };
|
|
5
|
+
|
|
6
|
+
type RadioPromptProps<T extends string> = {
|
|
7
|
+
title: string;
|
|
8
|
+
options: RadioOption<T>[];
|
|
9
|
+
initialValue: T;
|
|
10
|
+
onSelect: (value: T) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function RadioPrompt<T extends string>({ title, options, initialValue, onSelect }: RadioPromptProps<T>) {
|
|
14
|
+
const [index, setIndex] = useState(
|
|
15
|
+
Math.max(
|
|
16
|
+
0,
|
|
17
|
+
options.findIndex((option) => option.value === initialValue),
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
useKeyboard((key) => {
|
|
22
|
+
if (key.name === "up" || key.name === "k") {
|
|
23
|
+
setIndex((current) => (current + options.length - 1) % options.length);
|
|
24
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
25
|
+
setIndex((current) => (current + 1) % options.length);
|
|
26
|
+
} else if (key.name === "return") {
|
|
27
|
+
onSelect(options[index].value);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<box flexDirection="column">
|
|
33
|
+
<text>
|
|
34
|
+
<span fg="#a78bfa">◆</span> <strong>{title}</strong>
|
|
35
|
+
</text>
|
|
36
|
+
{options.map((option, i) => (
|
|
37
|
+
<text key={option.value}>
|
|
38
|
+
<span fg="#a78bfa">│ </span>
|
|
39
|
+
{i === index ? <span fg="#4ade80">◉ </span> : <span fg="#555555">○ </span>}
|
|
40
|
+
<span fg={i === index ? "#ffffff" : "#888888"}>{option.label}</span>
|
|
41
|
+
{option.description ? <span fg="#666666">{` ${option.description}`}</span> : null}
|
|
42
|
+
</text>
|
|
43
|
+
))}
|
|
44
|
+
<text>
|
|
45
|
+
<span fg="#a78bfa">│ </span>
|
|
46
|
+
<span fg="#666666">↑↓ to move · Enter to continue · Esc to go back</span>
|
|
47
|
+
</text>
|
|
48
|
+
</box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { allFeatureIds, allFeatures, type FeatureId } from "../lib/features";
|
|
4
|
-
import type
|
|
4
|
+
import { type PackageManager, packageManagers } from "../lib/package-manager";
|
|
5
|
+
import { type ShadcnPreset, shadcnPresets } from "../lib/shadcn-presets";
|
|
5
6
|
import type { ScaffoldConfig } from "../lib/scaffold-steps";
|
|
6
7
|
import { AnsweredRow } from "./answered-row";
|
|
7
8
|
import { ConfirmPrompt } from "./confirm-prompt";
|
|
8
9
|
import { FeaturesPrompt } from "./features-prompt";
|
|
9
10
|
import { NamePrompt } from "./name-prompt";
|
|
10
|
-
import { PackageManagerPrompt } from "./package-manager-prompt";
|
|
11
11
|
import { ProgressTimeline } from "./progress-timeline";
|
|
12
|
+
import { RadioPrompt } from "./radio-prompt";
|
|
12
13
|
|
|
13
|
-
const screens = ["name", "packageManager", "features", "confirm", "progress"] as const;
|
|
14
|
+
const screens = ["name", "packageManager", "features", "shadcnPreset", "confirm", "progress"] as const;
|
|
14
15
|
|
|
15
16
|
type Screen = (typeof screens)[number];
|
|
16
17
|
|
|
@@ -22,6 +23,7 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
|
|
|
22
23
|
const [name, setName] = useState(initialName);
|
|
23
24
|
const [packageManager, setPackageManager] = useState<PackageManager>("pnpm");
|
|
24
25
|
const [features, setFeatures] = useState<FeatureId[]>(allFeatureIds);
|
|
26
|
+
const [shadcnPreset, setShadcnPreset] = useState<ShadcnPreset>("nova");
|
|
25
27
|
const [config, setConfig] = useState<ScaffoldConfig | null>(null);
|
|
26
28
|
|
|
27
29
|
const exit = () => {
|
|
@@ -32,17 +34,21 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
|
|
|
32
34
|
const step = screens.indexOf(screen);
|
|
33
35
|
const answered = (target: Screen) => step > screens.indexOf(target);
|
|
34
36
|
|
|
37
|
+
const usesShadcn = features.includes("shadcn");
|
|
38
|
+
|
|
35
39
|
useKeyboard((key) => {
|
|
36
40
|
if (key.name === "escape") {
|
|
37
41
|
if (screen === "name") {
|
|
38
42
|
exit();
|
|
43
|
+
} else if (screen === "confirm" && !usesShadcn) {
|
|
44
|
+
setScreen("features");
|
|
39
45
|
} else if (screen !== "progress") {
|
|
40
46
|
setScreen(screens[step - 1]);
|
|
41
47
|
}
|
|
42
48
|
return;
|
|
43
49
|
}
|
|
44
50
|
if (screen === "confirm" && key.name === "return") {
|
|
45
|
-
setConfig({ name, packageManager, cwd, features, dryRun });
|
|
51
|
+
setConfig({ name, packageManager, cwd, features, shadcnPreset, dryRun });
|
|
46
52
|
setScreen("progress");
|
|
47
53
|
}
|
|
48
54
|
});
|
|
@@ -64,6 +70,7 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
|
|
|
64
70
|
{answered("name") ? <AnsweredRow label="Project name" value={name} /> : null}
|
|
65
71
|
{answered("packageManager") ? <AnsweredRow label="Package manager" value={packageManager} /> : null}
|
|
66
72
|
{answered("features") ? <AnsweredRow label="What to set up" value={featureSummary} /> : null}
|
|
73
|
+
{answered("shadcnPreset") && usesShadcn ? <AnsweredRow label="shadcn/ui preset" value={shadcnPreset} /> : null}
|
|
67
74
|
{screen === "name" ? (
|
|
68
75
|
<NamePrompt
|
|
69
76
|
initialValue={name}
|
|
@@ -74,7 +81,9 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
|
|
|
74
81
|
/>
|
|
75
82
|
) : null}
|
|
76
83
|
{screen === "packageManager" ? (
|
|
77
|
-
<
|
|
84
|
+
<RadioPrompt
|
|
85
|
+
title="Package manager"
|
|
86
|
+
options={packageManagers.map((value) => ({ value, label: value }))}
|
|
78
87
|
initialValue={packageManager}
|
|
79
88
|
onSelect={(value) => {
|
|
80
89
|
setPackageManager(value);
|
|
@@ -87,11 +96,22 @@ export function WizardApp({ initialName, cwd, dryRun }: WizardAppProps) {
|
|
|
87
96
|
initialValue={features}
|
|
88
97
|
onSubmit={(value) => {
|
|
89
98
|
setFeatures(value);
|
|
99
|
+
setScreen(value.includes("shadcn") ? "shadcnPreset" : "confirm");
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
103
|
+
{screen === "shadcnPreset" ? (
|
|
104
|
+
<RadioPrompt
|
|
105
|
+
title="shadcn/ui preset"
|
|
106
|
+
options={shadcnPresets}
|
|
107
|
+
initialValue={shadcnPreset}
|
|
108
|
+
onSelect={(value) => {
|
|
109
|
+
setShadcnPreset(value);
|
|
90
110
|
setScreen("confirm");
|
|
91
111
|
}}
|
|
92
112
|
/>
|
|
93
113
|
) : null}
|
|
94
|
-
{screen === "confirm" ? <ConfirmPrompt config={{ name, packageManager, cwd, features, dryRun }} /> : null}
|
|
114
|
+
{screen === "confirm" ? <ConfirmPrompt config={{ name, packageManager, cwd, features, shadcnPreset, dryRun }} /> : null}
|
|
95
115
|
{screen === "progress" && config ? <ProgressTimeline config={config} onExit={exit} /> : null}
|
|
96
116
|
</box>
|
|
97
117
|
);
|
package/src/index.tsx
CHANGED
package/src/lib/features.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
export type FeatureId =
|
|
1
|
+
export type FeatureId =
|
|
2
|
+
| "typography"
|
|
3
|
+
| "shadcn"
|
|
4
|
+
| "motion"
|
|
5
|
+
| "lucide"
|
|
6
|
+
| "hugeicons"
|
|
7
|
+
| "tanstackQuery"
|
|
8
|
+
| "ultracite"
|
|
9
|
+
| "claude"
|
|
10
|
+
| "codex"
|
|
11
|
+
| "skillNextExperimental"
|
|
12
|
+
| "skillVercelComposition"
|
|
13
|
+
| "skillVercelReact"
|
|
14
|
+
| "skillShadcn";
|
|
2
15
|
|
|
3
16
|
export type Feature = { id: FeatureId; label: string };
|
|
4
17
|
|
|
@@ -9,7 +22,7 @@ export const featureGroups: FeatureGroup[] = [
|
|
|
9
22
|
title: "Styling",
|
|
10
23
|
features: [
|
|
11
24
|
{ id: "typography", label: "Tailwind Typography" },
|
|
12
|
-
{ id: "shadcn", label: "shadcn/ui (all components)" },
|
|
25
|
+
{ id: "shadcn", label: "shadcn/ui (base, all components)" },
|
|
13
26
|
],
|
|
14
27
|
},
|
|
15
28
|
{
|
|
@@ -17,6 +30,8 @@ export const featureGroups: FeatureGroup[] = [
|
|
|
17
30
|
features: [
|
|
18
31
|
{ id: "motion", label: "Motion (framer-motion)" },
|
|
19
32
|
{ id: "lucide", label: "Lucide icons" },
|
|
33
|
+
{ id: "hugeicons", label: "Hugeicons (free)" },
|
|
34
|
+
{ id: "tanstackQuery", label: "TanStack Query (provider + devtools)" },
|
|
20
35
|
],
|
|
21
36
|
},
|
|
22
37
|
{
|
|
@@ -30,6 +45,15 @@ export const featureGroups: FeatureGroup[] = [
|
|
|
30
45
|
{ id: "codex", label: "Codex config" },
|
|
31
46
|
],
|
|
32
47
|
},
|
|
48
|
+
{
|
|
49
|
+
title: "AI skills (skills.sh)",
|
|
50
|
+
features: [
|
|
51
|
+
{ id: "skillNextExperimental", label: "vercel-labs/next.js-experimental" },
|
|
52
|
+
{ id: "skillVercelComposition", label: "vercel-composition-patterns" },
|
|
53
|
+
{ id: "skillVercelReact", label: "vercel-react-best-practices" },
|
|
54
|
+
{ id: "skillShadcn", label: "shadcn" },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
33
57
|
];
|
|
34
58
|
|
|
35
59
|
export const allFeatures: Feature[] = featureGroups.flatMap((group) => group.features);
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { FeatureId } from "./features";
|
|
3
3
|
import { type PackageManager, packageManagerCommands } from "./package-manager";
|
|
4
|
+
import type { ShadcnPreset } from "./shadcn-presets";
|
|
5
|
+
import { addQueryProvider } from "./utils/add-query-provider";
|
|
4
6
|
import { addTypographyPlugin } from "./utils/add-typography-plugin";
|
|
7
|
+
import { extendGitignore } from "./utils/extend-gitignore";
|
|
5
8
|
import { type CommandResult, runCommand } from "./utils/run-command";
|
|
6
9
|
import { writeAgentConfig } from "./utils/write-agent-config";
|
|
7
10
|
|
|
@@ -10,6 +13,7 @@ export type ScaffoldConfig = {
|
|
|
10
13
|
packageManager: PackageManager;
|
|
11
14
|
cwd: string;
|
|
12
15
|
features: FeatureId[];
|
|
16
|
+
shadcnPreset: ShadcnPreset;
|
|
13
17
|
dryRun: boolean;
|
|
14
18
|
};
|
|
15
19
|
|
|
@@ -30,83 +34,119 @@ const command = (
|
|
|
30
34
|
run: (config, onOutput) => runCommand(build(config), inProject ? projectDir(config) : config.cwd, onOutput),
|
|
31
35
|
});
|
|
32
36
|
|
|
33
|
-
const
|
|
37
|
+
const createNextApp = command(
|
|
38
|
+
({ name, packageManager }) => [
|
|
39
|
+
...packageManagerCommands[packageManager].dlx,
|
|
40
|
+
"create-next-app@latest",
|
|
41
|
+
name,
|
|
42
|
+
"--ts",
|
|
43
|
+
"--tailwind",
|
|
44
|
+
"--app",
|
|
45
|
+
"--no-src-dir",
|
|
46
|
+
"--import-alias",
|
|
47
|
+
"@/*",
|
|
48
|
+
"--yes",
|
|
49
|
+
packageManagerCommands[packageManager].createNextFlag,
|
|
50
|
+
],
|
|
51
|
+
false,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const typographyInstall = command(({ packageManager }) => [
|
|
55
|
+
...packageManagerCommands[packageManager].addDev,
|
|
56
|
+
"@tailwindcss/typography",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const nextStep = (withTypography: boolean): ScaffoldStep => ({
|
|
34
60
|
id: "next",
|
|
35
|
-
label:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
label: `Setting up Next.js + Tailwind${withTypography ? " + Typography" : ""}`,
|
|
62
|
+
describe: (config) =>
|
|
63
|
+
[
|
|
64
|
+
createNextApp.describe(config),
|
|
65
|
+
"extend .gitignore",
|
|
66
|
+
...(withTypography ? [`${typographyInstall.describe(config)}, add @plugin to app/globals.css`] : []),
|
|
67
|
+
].join(", then "),
|
|
68
|
+
run: async (config, onOutput) => {
|
|
69
|
+
onOutput("create-next-app");
|
|
70
|
+
const created = await createNextApp.run(config, onOutput);
|
|
71
|
+
if (!created.ok) {
|
|
72
|
+
return created;
|
|
73
|
+
}
|
|
74
|
+
await extendGitignore(projectDir(config));
|
|
75
|
+
if (!withTypography) {
|
|
76
|
+
return created;
|
|
77
|
+
}
|
|
78
|
+
onOutput("adding @tailwindcss/typography");
|
|
79
|
+
const typography = await typographyInstall.run(config, onOutput);
|
|
80
|
+
if (typography.ok) {
|
|
81
|
+
await addTypographyPlugin(projectDir(config));
|
|
82
|
+
}
|
|
83
|
+
return typography;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const shadcnInit = command(({ packageManager, shadcnPreset }) => [
|
|
88
|
+
...packageManagerCommands[packageManager].dlx,
|
|
89
|
+
"shadcn@latest",
|
|
90
|
+
"init",
|
|
91
|
+
"-y",
|
|
92
|
+
"-b",
|
|
93
|
+
"base",
|
|
94
|
+
"-p",
|
|
95
|
+
shadcnPreset,
|
|
96
|
+
"--silent",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const shadcnAddAll = command(({ packageManager }) => [
|
|
100
|
+
...packageManagerCommands[packageManager].dlx,
|
|
101
|
+
"shadcn@latest",
|
|
102
|
+
"add",
|
|
103
|
+
"--all",
|
|
104
|
+
"-y",
|
|
105
|
+
"--silent",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const shadcnStep: ScaffoldStep = {
|
|
109
|
+
id: "shadcn",
|
|
110
|
+
label: "Setting up shadcn/ui",
|
|
111
|
+
describe: (config) => `${shadcnInit.describe(config)}, then ${shadcnAddAll.describe(config)}`,
|
|
112
|
+
run: async (config, onOutput) => {
|
|
113
|
+
onOutput("init");
|
|
114
|
+
const init = await shadcnInit.run(config, onOutput);
|
|
115
|
+
if (!init.ok) {
|
|
116
|
+
return init;
|
|
117
|
+
}
|
|
118
|
+
onOutput("adding all components");
|
|
119
|
+
return shadcnAddAll.run(config, onOutput);
|
|
120
|
+
},
|
|
52
121
|
};
|
|
53
122
|
|
|
54
|
-
const
|
|
55
|
-
id: "
|
|
56
|
-
label: "
|
|
123
|
+
const tanstackQueryStep: ScaffoldStep = {
|
|
124
|
+
id: "tanstack-query",
|
|
125
|
+
label: "TanStack Query",
|
|
57
126
|
describe: (config) =>
|
|
58
|
-
`$ ${[...packageManagerCommands[config.packageManager].
|
|
127
|
+
`$ ${[...packageManagerCommands[config.packageManager].add, "@tanstack/react-query", "@tanstack/react-query-devtools"].join(" ")}, then write components/query-provider.tsx and wrap app/layout.tsx`,
|
|
59
128
|
run: async (config, onOutput) => {
|
|
60
129
|
const result = await runCommand(
|
|
61
|
-
[...packageManagerCommands[config.packageManager].
|
|
130
|
+
[...packageManagerCommands[config.packageManager].add, "@tanstack/react-query", "@tanstack/react-query-devtools"],
|
|
62
131
|
projectDir(config),
|
|
63
132
|
onOutput,
|
|
64
133
|
);
|
|
65
134
|
if (result.ok) {
|
|
66
|
-
await
|
|
135
|
+
await addQueryProvider(projectDir(config));
|
|
67
136
|
}
|
|
68
137
|
return result;
|
|
69
138
|
},
|
|
70
139
|
};
|
|
71
140
|
|
|
72
|
-
const shadcnInitStep: ScaffoldStep = {
|
|
73
|
-
id: "shadcn-init",
|
|
74
|
-
label: "shadcn/ui init (radix, vega preset)",
|
|
75
|
-
...command(({ packageManager }) => [
|
|
76
|
-
...packageManagerCommands[packageManager].dlx,
|
|
77
|
-
"shadcn@latest",
|
|
78
|
-
"init",
|
|
79
|
-
"-y",
|
|
80
|
-
"-b",
|
|
81
|
-
"radix",
|
|
82
|
-
"-p",
|
|
83
|
-
"vega",
|
|
84
|
-
"--silent",
|
|
85
|
-
]),
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const shadcnAllStep: ScaffoldStep = {
|
|
89
|
-
id: "shadcn-all",
|
|
90
|
-
label: "shadcn/ui add --all",
|
|
91
|
-
...command(({ packageManager }) => [
|
|
92
|
-
...packageManagerCommands[packageManager].dlx,
|
|
93
|
-
"shadcn@latest",
|
|
94
|
-
"add",
|
|
95
|
-
"--all",
|
|
96
|
-
"-y",
|
|
97
|
-
"--silent",
|
|
98
|
-
]),
|
|
99
|
-
};
|
|
100
|
-
|
|
101
141
|
const librariesStep = (packages: string[]): ScaffoldStep => ({
|
|
102
142
|
id: "libraries",
|
|
103
143
|
label: packages.join(" + "),
|
|
104
144
|
...command(({ packageManager }) => [...packageManagerCommands[packageManager].add, ...packages]),
|
|
105
145
|
});
|
|
106
146
|
|
|
107
|
-
const ultraciteStep = (
|
|
147
|
+
const ultraciteStep = (withShadcn: boolean): ScaffoldStep => ({
|
|
108
148
|
id: "ultracite",
|
|
109
|
-
label:
|
|
149
|
+
label: `Ultracite (Oxlint${withShadcn ? " + shadcn rules" : ""}, Oxfmt)`,
|
|
110
150
|
...command(({ packageManager }) => [
|
|
111
151
|
...packageManagerCommands[packageManager].dlx,
|
|
112
152
|
"ultracite@latest",
|
|
@@ -115,9 +155,9 @@ const ultraciteStep = (agents: FeatureId[]): ScaffoldStep => ({
|
|
|
115
155
|
packageManager,
|
|
116
156
|
"--linter",
|
|
117
157
|
"oxlint",
|
|
118
|
-
...(agents.length > 0 ? ["--agents", ...agents] : []),
|
|
119
158
|
"--frameworks",
|
|
120
159
|
"next",
|
|
160
|
+
...(withShadcn ? ["shadcn", "--js-plugins", "@shadcn/lint"] : []),
|
|
121
161
|
"--editors",
|
|
122
162
|
"vscode",
|
|
123
163
|
"--quiet",
|
|
@@ -127,22 +167,62 @@ const ultraciteStep = (agents: FeatureId[]): ScaffoldStep => ({
|
|
|
127
167
|
const agentConfigStep = (agents: FeatureId[]): ScaffoldStep => ({
|
|
128
168
|
id: "agents",
|
|
129
169
|
label: `${agents.join(" + ")} config`,
|
|
130
|
-
describe: () => `
|
|
170
|
+
describe: () => `append project rules to AGENTS.md${agents.includes("claude") ? ", ensure CLAUDE.md points at it" : ""}`,
|
|
131
171
|
run: async (config) => {
|
|
132
|
-
await writeAgentConfig(projectDir(config),
|
|
172
|
+
await writeAgentConfig(projectDir(config), config);
|
|
133
173
|
return { ok: true, output: "" };
|
|
134
174
|
},
|
|
135
175
|
});
|
|
136
176
|
|
|
177
|
+
type SkillPackage = { id: string; repo: string; skills: string[] };
|
|
178
|
+
|
|
179
|
+
const skillsStep = (pkg: SkillPackage, agents: string[]): ScaffoldStep => ({
|
|
180
|
+
id: `skills-${pkg.id}`,
|
|
181
|
+
label: `AI skills: ${pkg.repo}${pkg.skills[0] === "*" ? "" : ` (${pkg.skills.join(", ")})`}`,
|
|
182
|
+
...command(({ packageManager }) => [
|
|
183
|
+
...packageManagerCommands[packageManager].dlx,
|
|
184
|
+
"skills@latest",
|
|
185
|
+
"add",
|
|
186
|
+
pkg.repo,
|
|
187
|
+
"--skill",
|
|
188
|
+
...pkg.skills,
|
|
189
|
+
"--agent",
|
|
190
|
+
...agents,
|
|
191
|
+
"-y",
|
|
192
|
+
]),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const skillPackages = (features: FeatureId[]): SkillPackage[] => {
|
|
196
|
+
const has = (id: FeatureId) => features.includes(id);
|
|
197
|
+
const vercel = [
|
|
198
|
+
...(has("skillVercelComposition") ? ["vercel-composition-patterns"] : []),
|
|
199
|
+
...(has("skillVercelReact") ? ["vercel-react-best-practices"] : []),
|
|
200
|
+
];
|
|
201
|
+
return [
|
|
202
|
+
...(has("skillNextExperimental")
|
|
203
|
+
? [{ id: "next-experimental", repo: "vercel-labs/next.js-experimental", skills: ["*"] }]
|
|
204
|
+
: []),
|
|
205
|
+
...(vercel.length > 0 ? [{ id: "vercel", repo: "vercel-labs/agent-skills", skills: vercel }] : []),
|
|
206
|
+
...(has("skillShadcn") ? [{ id: "shadcn", repo: "shadcn-ui/ui", skills: ["shadcn"] }] : []),
|
|
207
|
+
];
|
|
208
|
+
};
|
|
209
|
+
|
|
137
210
|
export function buildScaffoldSteps(features: FeatureId[]): ScaffoldStep[] {
|
|
138
211
|
const has = (id: FeatureId) => features.includes(id);
|
|
139
|
-
const libraries = [
|
|
212
|
+
const libraries = [
|
|
213
|
+
...(has("motion") ? ["motion"] : []),
|
|
214
|
+
...(has("lucide") ? ["lucide-react"] : []),
|
|
215
|
+
...(has("hugeicons") ? ["@hugeicons/react", "@hugeicons/core-free-icons"] : []),
|
|
216
|
+
];
|
|
140
217
|
const agents = features.filter((id): id is "claude" | "codex" => id === "claude" || id === "codex");
|
|
218
|
+
const skillAgents = agents.length > 0 ? agents.map((agent) => (agent === "claude" ? "claude-code" : "codex")) : ["claude-code", "codex"];
|
|
141
219
|
return [
|
|
142
|
-
nextStep,
|
|
143
|
-
...(has("
|
|
144
|
-
...(has("shadcn") ? [shadcnInitStep, shadcnAllStep] : []),
|
|
220
|
+
nextStep(has("typography")),
|
|
221
|
+
...(has("shadcn") ? [shadcnStep] : []),
|
|
145
222
|
...(libraries.length > 0 ? [librariesStep(libraries)] : []),
|
|
146
|
-
...(has("
|
|
223
|
+
...(has("tanstackQuery") ? [tanstackQueryStep] : []),
|
|
224
|
+
...(has("ultracite") ? [ultraciteStep(has("shadcn"))] : []),
|
|
225
|
+
...(agents.length > 0 ? [agentConfigStep(agents)] : []),
|
|
226
|
+
...skillPackages(features).map((pkg) => skillsStep(pkg, skillAgents)),
|
|
147
227
|
];
|
|
148
228
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ShadcnPreset = "nova" | "vega" | "maia" | "lyra" | "mira" | "luma" | "sera" | "rhea";
|
|
2
|
+
|
|
3
|
+
export const shadcnPresets: { value: ShadcnPreset; label: string; description: string }[] = [
|
|
4
|
+
{ value: "nova", label: "Nova", description: "Lucide / Geist" },
|
|
5
|
+
{ value: "vega", label: "Vega", description: "Lucide / Inter" },
|
|
6
|
+
{ value: "maia", label: "Maia", description: "Hugeicons / Figtree" },
|
|
7
|
+
{ value: "lyra", label: "Lyra", description: "Phosphor / JetBrains Mono" },
|
|
8
|
+
{ value: "mira", label: "Mira", description: "Hugeicons / Inter" },
|
|
9
|
+
{ value: "luma", label: "Luma", description: "Lucide / Inter" },
|
|
10
|
+
{ value: "sera", label: "Sera", description: "Lucide / Noto Sans + Playfair Display" },
|
|
11
|
+
{ value: "rhea", label: "Rhea", description: "Lucide / Inter" },
|
|
12
|
+
];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const providerSource = `"use client";
|
|
5
|
+
|
|
6
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
7
|
+
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
|
8
|
+
import { type ReactNode, useState } from "react";
|
|
9
|
+
|
|
10
|
+
type QueryProviderProps = { children: ReactNode };
|
|
11
|
+
|
|
12
|
+
export function QueryProvider({ children }: QueryProviderProps) {
|
|
13
|
+
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } }));
|
|
14
|
+
return (
|
|
15
|
+
<QueryClientProvider client={client}>
|
|
16
|
+
{children}
|
|
17
|
+
<ReactQueryDevtools initialIsOpen={false} />
|
|
18
|
+
</QueryClientProvider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const importLine = 'import { QueryProvider } from "@/components/query-provider";';
|
|
24
|
+
|
|
25
|
+
export async function addQueryProvider(projectDir: string): Promise<void> {
|
|
26
|
+
await mkdir(join(projectDir, "components"), { recursive: true });
|
|
27
|
+
await writeFile(join(projectDir, "components", "query-provider.tsx"), providerSource);
|
|
28
|
+
|
|
29
|
+
const layoutPath = join(projectDir, "app", "layout.tsx");
|
|
30
|
+
const layout = await readFile(layoutPath, "utf8");
|
|
31
|
+
if (layout.includes(importLine)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await writeFile(
|
|
35
|
+
layoutPath,
|
|
36
|
+
`${importLine}\n${layout.replace("{children}", "<QueryProvider>{children}</QueryProvider>")}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const extraSections = [["# playwright mcp", "/.playwright-mcp/"]];
|
|
5
|
+
|
|
6
|
+
export async function extendGitignore(projectDir: string): Promise<void> {
|
|
7
|
+
const path = join(projectDir, ".gitignore");
|
|
8
|
+
const current = await readFile(path, "utf8").catch(() => "");
|
|
9
|
+
const missing = extraSections.filter((section) => !current.includes(section[1]));
|
|
10
|
+
if (missing.length === 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const body = missing.map((section) => section.join("\n")).join("\n\n");
|
|
14
|
+
await writeFile(path, `${current.trimEnd()}\n\n${body}\n`);
|
|
15
|
+
}
|
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
import { writeFile } from "node:fs/promises";
|
|
1
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import
|
|
3
|
+
import { packageManagerCommands } from "../package-manager";
|
|
4
|
+
import type { ScaffoldConfig } from "../scaffold-steps";
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const rulesPath = new URL("../../templates/agent-rules.md", import.meta.url);
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const exists = (path: string) =>
|
|
9
|
+
access(path).then(
|
|
10
|
+
() => true,
|
|
11
|
+
() => false,
|
|
12
|
+
);
|
|
11
13
|
|
|
12
|
-
export async function writeAgentConfig(projectDir: string,
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
export async function writeAgentConfig(projectDir: string, config: ScaffoldConfig): Promise<void> {
|
|
15
|
+
const { features } = config;
|
|
16
|
+
const rules = (await readFile(rulesPath, "utf8"))
|
|
17
|
+
.replaceAll("{{dlx}}", packageManagerCommands[config.packageManager].dlx.join(" "))
|
|
18
|
+
.replaceAll("{{shadcnStyle}}", `base-${config.shadcnPreset}`);
|
|
19
|
+
const agentsPath = join(projectDir, "AGENTS.md");
|
|
20
|
+
const claudePath = join(projectDir, "CLAUDE.md");
|
|
21
|
+
const wantsAgents = features.includes("codex") || features.includes("claude");
|
|
22
|
+
|
|
23
|
+
if (wantsAgents) {
|
|
24
|
+
const current = (await exists(agentsPath)) ? await readFile(agentsPath, "utf8") : "";
|
|
25
|
+
if (!current.includes(rules.trim())) {
|
|
26
|
+
await writeFile(agentsPath, current ? `${current.trimEnd()}\n\n${rules}` : rules);
|
|
27
|
+
}
|
|
15
28
|
}
|
|
16
|
-
if (features.includes("claude")) {
|
|
17
|
-
await writeFile(
|
|
29
|
+
if (features.includes("claude") && !(await exists(claudePath))) {
|
|
30
|
+
await writeFile(claudePath, "@AGENTS.md\n");
|
|
18
31
|
}
|
|
19
32
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Project rules
|
|
2
|
+
|
|
3
|
+
## Working style
|
|
4
|
+
|
|
5
|
+
### Do only what was asked
|
|
6
|
+
|
|
7
|
+
Implement, confirm it works, report in a few lines, stop.
|
|
8
|
+
|
|
9
|
+
- No unrequested extras — no extra files, formats, variants, fallbacks. One ask = one deliverable.
|
|
10
|
+
- Verify privately (typecheck, lint, load the page). No comparison renders, previews, pixel-diffs, or scratch harnesses unless asked.
|
|
11
|
+
- Mechanical edits (find/replace, rename, move) get no ceremony: no pre-survey, no occurrence counting, no re-grepping to confirm. Edit tools error on failure — that's the confirmation. Investigate first only when the change depends on it, and say why.
|
|
12
|
+
- Don't narrate process, restate what you did, or list what you skipped.
|
|
13
|
+
- If the request looks wrong, say so in a sentence and ask. Never implement both options or one-while-pitching-the-other.
|
|
14
|
+
|
|
15
|
+
### Stop investigating and write the code
|
|
16
|
+
|
|
17
|
+
Look up only what the edit can't be written without, then write it.
|
|
18
|
+
|
|
19
|
+
- Never reverse-engineer a live system: no scraping the deployed site, grepping its bundles, fetching extra pages, or diffing responses. One call to the pointed-at endpoint to see its shape is the whole budget.
|
|
20
|
+
- Missing detail? Pick the obvious mapping or omit the field, note it in one line, let the user decide. Don't hunt.
|
|
21
|
+
- A one-line-of-UI detail (label, fallback, nice-to-have) is not worth a single extra request.
|
|
22
|
+
- No background/parallel commands for questions the task doesn't hinge on.
|
|
23
|
+
- Several turns of reading without editing = off track. Write the code and ask what's unclear.
|
|
24
|
+
|
|
25
|
+
### Check the API before building behaviour
|
|
26
|
+
|
|
27
|
+
`components/ui` wraps Base UI. Before adding/changing behaviour (open/close, hover/focus, positioning, delays, keyboard nav, typeahead, portals, animation state, form wiring), read `node_modules/@base-ui/react/docs/react/components/` and `.../handbook/` — it's almost always an existing prop, part, or data attribute. Same for Next.js: read `node_modules/next/dist/docs/` before hand-rolling navigation, caching, data loading, metadata, redirects, image/font handling. Don't trust memory of either API — read the file. Custom implementations only after docs prove the built-in can't, noted in one line.
|
|
28
|
+
|
|
29
|
+
### Verification never writes into the repo
|
|
30
|
+
|
|
31
|
+
All check by-products (screenshots, snapshots, logs, scratch scripts, sample payloads) go in a temp dir outside the tree — never repo root, `.playwright-mcp/`, or `screenshots/`. Always pass tools an absolute path outside the repo. Delete artifacts when done (`.gitignore` is not cleanup). Only requested files remain.
|
|
32
|
+
|
|
33
|
+
## Git
|
|
34
|
+
|
|
35
|
+
### Never discard unrelated working tree changes
|
|
36
|
+
|
|
37
|
+
The user edits files while agents run. Uncommitted changes are real work.
|
|
38
|
+
|
|
39
|
+
- Never `git checkout`/`restore`/`reset --hard`/`stash`/`clean` on changes you didn't make. Revert your own edits line-by-line only.
|
|
40
|
+
- Don't "tidy" others' modifications, even if they look stale or half-finished.
|
|
41
|
+
- Re-read files before editing — they may have changed. Never write from a stale copy.
|
|
42
|
+
- Collision with someone else's change: stop and ask.
|
|
43
|
+
- Stage specific paths only. Never `git add -A` / `git add .`.
|
|
44
|
+
- Never create a branch unless asked in that request. "Commit this" = commit on the checked-out branch, including `main`.
|
|
45
|
+
|
|
46
|
+
### Prove a reference is dead before deleting
|
|
47
|
+
|
|
48
|
+
`rg` / `rg --files` first, for any file, export, dependency, registry entry, test, or doc you're about to remove — this is the one deletion that earns a pre-survey. Remove every stale reference in the same pass.
|
|
49
|
+
|
|
50
|
+
## Files and structure
|
|
51
|
+
|
|
52
|
+
### One component per file
|
|
53
|
+
|
|
54
|
+
Each component gets its own file named after it (`BrandThumbnail` → `brand-thumbnail.tsx`). No second component in a file, even a small private one — split it out, export/import by name. Exception: `components/ui` keeps whatever the registry shipped.
|
|
55
|
+
|
|
56
|
+
### Filenames are `<domain>-<role>`
|
|
57
|
+
|
|
58
|
+
Component directories are flat, so the first token is the only grouping there is. Spend it on the domain, put the role last: `brand-card`, `product-list-carousel`, `search-filter-section`. Common roles — `card`, `carousel`, `chip`, `dialog`, `filter`, `form`, `group`, `header`, `item`, `list`, `menu`, `nav`, `panel`, `selector`, `skeleton`, `table`, `thumbnail`, `tree`. Reuse one before inventing a word.
|
|
59
|
+
|
|
60
|
+
**The domain prefix is always singular**, whatever the component holds: `brand-nav` (takes `Brand[]`), `category-cloud-list`, `product-list-carousel`. Plurality is the suffix's job. One domain sorts as one contiguous block — `brands-nav` next to `brand-card` is the failure this prevents.
|
|
61
|
+
|
|
62
|
+
### Dialogs and forms live in their own directories
|
|
63
|
+
|
|
64
|
+
A `*-dialog.tsx` file goes in `components/dialogs`, a `*-form.tsx` file in `components/forms`, imported as `@/components/dialogs/cart-quote-dialog` and `@/components/forms/address-form`. Never create either anywhere else, and move any you find. Everything else here still applies: one component per file, `<domain>-<role>` filenames, singular domain prefix.
|
|
65
|
+
|
|
66
|
+
A dialog or form that fetches splits the same way a section does, and both halves stay in the same directory. Its skeleton follows it too: `account-profile-form-skeleton` sits beside `account-profile-form` in `components/forms`.
|
|
67
|
+
|
|
68
|
+
### A section that fetches splits in two
|
|
69
|
+
|
|
70
|
+
When the render half needs `'use client'`, or is shared by more than one section, it moves to its own file. The `section-*` parent does the `await`; the child is named for **what it renders**, never a prefix-only variation of its parent:
|
|
71
|
+
|
|
72
|
+
| parent (fetches) | child (renders) |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `section-faq` | `faq-accordion` |
|
|
75
|
+
| `section-hero` | `hero-carousel` |
|
|
76
|
+
| `section-related-products` | `product-list-carousel` |
|
|
77
|
+
|
|
78
|
+
`section-faq.tsx` beside `faqs.tsx` is the anti-pattern: nothing in the name says which one holds the `await`.
|
|
79
|
+
|
|
80
|
+
A Suspense fallback occupying a section's band is itself a section — `section-product-about-skeleton` sorts next to `section-product-about`.
|
|
81
|
+
|
|
82
|
+
### Custom hooks live in lib/hooks
|
|
83
|
+
|
|
84
|
+
One file per hook, named after it (`useHeaderPopup` → `lib/hooks/use-header-popup.ts`). Never define hooks elsewhere; move any found. Exception: hooks shipped with shadcn components in `components/ui`.
|
|
85
|
+
|
|
86
|
+
### Helpers live in lib/utils
|
|
87
|
+
|
|
88
|
+
One file per helper, named after it (`cn` → `lib/utils/cn.ts`). Never define helpers elsewhere; move any found. Read `lib/utils` before writing a new one — reuse or extend beats adding. Exception: `components/ui`.
|
|
89
|
+
|
|
90
|
+
Most helpers should never be written. A helper earns its place by holding a decision — branching, a rule, a non-obvious transform, a thought-through constant. One-expression bodies get inlined. Don't write:
|
|
91
|
+
|
|
92
|
+
- Wrappers around one call (`formatPrice` = `n.toFixed(2)`, `isEmpty` = `!arr.length`).
|
|
93
|
+
- Aliases (another function with reordered args or a filled default).
|
|
94
|
+
- Single-use one-liners — put them at the call site.
|
|
95
|
+
- Speculative utils — write it when the second caller appears.
|
|
96
|
+
- What the platform/deps already do — check `Intl`, `URL`, `URLSearchParams`, array/string methods, and `package.json` libs first.
|
|
97
|
+
|
|
98
|
+
`lib/utils` is shared surface: genuinely reusable, named for what it returns, correct beyond the prompting call site. Can't name it without `handle`/`process`/`data`/`helper`/bare `format`? Don't write it. When touching code that calls a bad helper, inline it and delete the file.
|
|
99
|
+
|
|
100
|
+
### Types live in /types
|
|
101
|
+
|
|
102
|
+
Every `type`/`interface` goes in a domain file (`Product` → `types/product.ts`); move any found elsewhere. Import via `import type { … } from '@/types/<domain>'`. Exception: `components/ui`. Inline annotations aren't declarations and stay put.
|
|
103
|
+
|
|
104
|
+
## Types
|
|
105
|
+
|
|
106
|
+
### No escape-hatch types
|
|
107
|
+
|
|
108
|
+
Every value has a shape — write it. Banned everywhere (declarations, annotations, generics, casts, returns): `unknown`, `any`, `Record<string, unknown|any>`, `{ [key: string]: unknown }`, `object`, `{}`, `Function`, `unknown[]`, `any[]`, and laundering casts (`data as Product`, `as unknown as`).
|
|
109
|
+
|
|
110
|
+
If the shape isn't obvious, find it: Schema, API response, library types in `node_modules`, or the producing code. Untyped boundary data gets a declared type plus a parse/narrow step. Retype anything from shadcn/registries/codegen before committing. Generic params are fine (`<T>(items: T[]) => T[]`); constrain when the body needs a property. `unknown` is allowed only in `catch`, narrowed before use.
|
|
111
|
+
|
|
112
|
+
### Empty array / null is still a real field
|
|
113
|
+
|
|
114
|
+
Empty samples prove nothing about a field's shape. Type fields for the data they'll hold: `tierPrices: TierPrice[] | null`, never `never[]`, `[]`, `null`, or dropped.
|
|
115
|
+
|
|
116
|
+
- Don't sample record after record hoping a field fills in, or report "empty in N responses" as evidence.
|
|
117
|
+
- Unobserved element shape: infer from the field's name and siblings, declare in `/types`, flag as unconfirmed in one line. Mock data is never the fallback — fetch a different record.
|
|
118
|
+
|
|
119
|
+
## UI
|
|
120
|
+
|
|
121
|
+
### Use the registry before writing a primitive
|
|
122
|
+
|
|
123
|
+
Never handwrite a primitive. Stop at the first hit:
|
|
124
|
+
|
|
125
|
+
1. Already in `components/ui` → use it (e.g. shadcn `Button`, not raw `<button>`).
|
|
126
|
+
2. In the official shadcn registry → `{{dlx}} shadcn@latest add <component>`. Check the registry, don't trust memory — it's grown, and `components.json` (`{{shadcnStyle}}`) pulls Base UI variants. Never hand-copy source from docs or rebuild what the registry ships.
|
|
127
|
+
3. Nothing fits → write it, and say in one line what you searched and why nothing worked.
|
|
128
|
+
|
|
129
|
+
### Size comes from the size prop
|
|
130
|
+
|
|
131
|
+
Never resize a `components/ui` component with utilities — no `size-8`/`h-9`/`w-12`, no padding/text-size overrides, no inline width/height. Use the closest existing `size` variant (read the component file for the current list). Leave icons unsized — variants set them. No new variants, no widening existing ones, no overrides for in-between pixel values. `className` keeps margins, alignment, grid placement, colour.
|
|
132
|
+
|
|
133
|
+
### Text sizes come from the scale
|
|
134
|
+
|
|
135
|
+
No arbitrary font sizes — no `text-[13px]`, `text-[0.8rem]`, no inline `fontSize`. Use the scale: `text-xs`, `text-sm`, `text-base`, `text-lg`, up. If `app/globals.css` retunes any `--text-*` token, read those values first; the matching class may not be the one its name suggests. If a size is genuinely missing, add a `--text-*` token in `globals.css`, never inline it. Applies to shadcn/registry/Figma imports.
|
|
136
|
+
|
|
137
|
+
### Conditional classes use object style
|
|
138
|
+
|
|
139
|
+
In `cn()`, conditionals are objects keyed by class string — never `&&` or ternaries:
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
cn('flex flex-col gap-3', { 'items-center text-center': align === 'center' }, className)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
A ternary becomes two entries (`{ 'bg-brand': reached, 'bg-border': !reached }`) — don't lean on tailwind-merge for conflicts a condition can state. Unconditional classes stay plain strings; passed-through `className` stays last so it wins merges.
|
|
146
|
+
|
|
147
|
+
### Underline pairs with underline-offset-4
|
|
148
|
+
|
|
149
|
+
Every `underline` gets `underline-offset-4`, including variants (`hover:underline underline-offset-4` — offset stays unprefixed). Fix anything pulled in that underlines without it.
|
|
150
|
+
|
|
151
|
+
## Comments
|
|
152
|
+
|
|
153
|
+
### Comments default to zero
|
|
154
|
+
|
|
155
|
+
A comment is earned only by an external fact the code can't carry: an API/service/tool quirk, an upstream-bug workaround, or why a non-obvious constant has its value. Never narrate code, label sections, restate names in JSDoc, mark changes, record refactor history, or address the reviewer. Delete commented-out code; when unsure, delete; prefer renaming over explaining. Machine directives are not comments and stay: `oxlint-disable*`, `ts-expect-error`, `AUTO-GENERATED` banners.
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { useKeyboard } from "@opentui/react";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
import { type PackageManager, packageManagers } from "../lib/package-manager";
|
|
4
|
-
|
|
5
|
-
type PackageManagerPromptProps = { initialValue: PackageManager; onSelect: (packageManager: PackageManager) => void };
|
|
6
|
-
|
|
7
|
-
export function PackageManagerPrompt({ initialValue, onSelect }: PackageManagerPromptProps) {
|
|
8
|
-
const [index, setIndex] = useState(Math.max(0, packageManagers.indexOf(initialValue)));
|
|
9
|
-
|
|
10
|
-
useKeyboard((key) => {
|
|
11
|
-
if (key.name === "up" || key.name === "k") {
|
|
12
|
-
setIndex((current) => (current + packageManagers.length - 1) % packageManagers.length);
|
|
13
|
-
} else if (key.name === "down" || key.name === "j") {
|
|
14
|
-
setIndex((current) => (current + 1) % packageManagers.length);
|
|
15
|
-
} else if (key.name === "return") {
|
|
16
|
-
onSelect(packageManagers[index]);
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<box flexDirection="column">
|
|
22
|
-
<text>
|
|
23
|
-
<span fg="#a78bfa">◆</span> <strong>Package manager</strong>
|
|
24
|
-
</text>
|
|
25
|
-
{packageManagers.map((value, i) => (
|
|
26
|
-
<text key={value}>
|
|
27
|
-
<span fg="#a78bfa">│ </span>
|
|
28
|
-
{i === index ? <span fg="#4ade80">◉ </span> : <span fg="#555555">○ </span>}
|
|
29
|
-
<span fg={i === index ? "#ffffff" : "#888888"}>{value}</span>
|
|
30
|
-
</text>
|
|
31
|
-
))}
|
|
32
|
-
<text>
|
|
33
|
-
<span fg="#a78bfa">│ </span>
|
|
34
|
-
<span fg="#666666">↑↓ to move · Enter to continue · Esc to go back</span>
|
|
35
|
-
</text>
|
|
36
|
-
</box>
|
|
37
|
-
);
|
|
38
|
-
}
|