fix(gitea-bot): add write:organization scope so bot can create repos

Without this the bot PAT 403s on POST /orgs/{org}/repos, which is
the single most important operation — creating new project repos
inside the workspace's Gitea org.

Made-with: Cursor
This commit is contained in:
2026-04-21 11:05:55 -07:00
parent d9d3514647
commit 6f79a88abd
66 changed files with 2088 additions and 1713 deletions

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
@@ -45,10 +46,10 @@ export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌁" label="Import Chats" tagline="You've been thinking"
accent="#2e5a4a" onBack={onBack} onClose={onClose}
accent="#059669" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
@@ -66,14 +67,14 @@ export function ChatImportSetup({ workspace, onClose, onBack }: SetupProps) {
placeholder={"Paste conversations from ChatGPT, Claude, Gemini, or any AI tool.\n\nVibn will extract decisions, ideas, open questions, and architecture notes."}
rows={8}
style={{
width: "100%", padding: "12px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.85rem", lineHeight: 1.55,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 20,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13, lineHeight: 1.55,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", resize: "vertical", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
@@ -47,10 +48,10 @@ export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ padding: 28 }}>
<SetupHeader
icon="⌘" label="Import Code" tagline="Already have a repo"
accent="#1a3a5c" onBack={onBack} onClose={onClose}
accent="#1D4ED8" onBack={onBack} onClose={onClose}
/>
<FieldLabel>Project name</FieldLabel>
@@ -70,7 +71,7 @@ export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
<FieldLabel>
Personal Access Token{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(required for private repos)</span>
<span style={{ color: JM.muted, fontWeight: 400 }}>(required for private repos)</span>
</FieldLabel>
<input
type="password"
@@ -78,17 +79,21 @@ export function CodeImportSetup({ workspace, onClose, onBack }: SetupProps) {
onChange={e => setPat(e.target.value)}
placeholder="ghp_… or similar"
style={{
width: "100%", padding: "11px 14px", marginBottom: 20,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 20,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<div style={{
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
padding: "12px 14px", background: JM.cream, borderRadius: 8,
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
}}>
Vibn will clone your repo, read key files, and build a full architecture map tech stack, routes, database, auth, and third-party integrations. Tokens are used only for cloning and are not stored.
</div>

View File

@@ -2,11 +2,20 @@
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus_Jakarta_Sans } from "next/font/google";
import { TypeSelector } from "./TypeSelector";
import { FreshIdeaSetup } from "./FreshIdeaSetup";
import { ChatImportSetup } from "./ChatImportSetup";
import { CodeImportSetup } from "./CodeImportSetup";
import { MigrateSetup } from "./MigrateSetup";
import { JM } from "./modal-theme";
const modalFont = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-justine-jakarta",
display: "swap",
});
export type CreationMode = "fresh" | "chat-import" | "code-import" | "migration";
@@ -62,29 +71,31 @@ export function CreateProjectFlow({ open, onOpenChange, workspace }: CreateProje
<div
onClick={() => onOpenChange(false)}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(26,26,26,0.38)",
position: "fixed", inset: 0, zIndex: 200,
background: JM.overlay,
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(2px)",
animation: "vibn-fadeIn 0.15s ease",
}}
/>
{/* Modal container */}
{/* Modal container — matches justine/03_dashboard.html #modal-new */}
<div style={{
position: "fixed", inset: 0, zIndex: 51,
position: "fixed", inset: 0, zIndex: 201,
display: "flex", alignItems: "center", justifyContent: "center",
padding: 24, pointerEvents: "none",
}}>
<div
onClick={e => e.stopPropagation()}
className={modalFont.variable}
style={{
background: "#fff", borderRadius: 16,
boxShadow: "0 12px 48px rgba(26,26,26,0.16)",
boxShadow: JM.cardShadow,
width: "100%",
maxWidth: 520,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
maxWidth: JM.cardMaxWidth,
fontFamily: JM.fontSans,
pointerEvents: "all",
animation: "vibn-slideUp 0.18s cubic-bezier(0.4,0,0.2,1)",
transition: "max-width 0.2s ease",
overflow: "hidden",
}}
>

View File

@@ -3,11 +3,13 @@
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
import { JM } from "./modal-theme";
import { FieldLabel, TextInput, PrimaryButton, ForWhomSelector, type SetupProps } from "./setup-shared";
export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
const router = useRouter();
const [name, setName] = useState("");
const [forWhom, setForWhom] = useState<"personal" | "client">("personal");
const [loading, setLoading] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
@@ -24,7 +26,7 @@ export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
projectName: name.trim(),
projectType: "web-app",
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
product: { name: name.trim() },
product: { name: name.trim(), isForClient: forWhom === "client" },
creationMode: "fresh",
sourceData: {},
}),
@@ -45,29 +47,45 @@ export function FreshIdeaSetup({ workspace, onClose }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ fontSize: "1.15rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "var(--font-lora), ui-serif, serif" }}>
<div style={{ padding: 28 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 22 }}>
<h3 style={{
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, letterSpacing: "-0.02em",
}}>
New project
</div>
</h3>
<button
type="button"
onClick={onClose}
style={{ background: "none", border: "none", cursor: "pointer", color: "#a09a90", fontSize: "1.1rem", padding: "2px 6px", lineHeight: 1 }}
>×</button>
style={{
background: "none", border: "none", cursor: "pointer",
color: JM.muted, fontSize: 20, padding: 4, lineHeight: 1,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. Foxglove, Meridian, OpsAI…"
onKeyDown={e => { if (e.key === "Enter" && canCreate) handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
<div style={{ marginBottom: 16 }}>
<FieldLabel>Project name</FieldLabel>
<TextInput
value={name}
onChange={setName}
placeholder="e.g. My SaaS App"
onKeyDown={e => { if (e.key === "Enter" && canCreate) void handleCreate(); }}
inputRef={nameRef}
autoFocus
/>
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>
Start
<ForWhomSelector value={forWhom} onChange={setForWhom} />
<PrimaryButton onClick={() => { void handleCreate(); }} disabled={!canCreate} loading={loading}>
Create project
</PrimaryButton>
</div>
);

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { JM } from "./modal-theme";
import { SetupHeader, FieldLabel, TextInput, PrimaryButton, type SetupProps } from "./setup-shared";
const HOSTING_OPTIONS = [
@@ -70,7 +71,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
};
return (
<div style={{ padding: "32px 36px 36px" }}>
<div style={{ padding: 28 }}>
<SetupHeader
icon="⇢" label="Migrate Product" tagline="Move an existing product"
accent="#4a2a5a" onBack={onBack} onClose={onClose}
@@ -86,7 +87,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
<FieldLabel>
Repository URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(recommended)</span>
<span style={{ color: JM.muted, fontWeight: 400 }}>(recommended)</span>
</FieldLabel>
<TextInput
value={repoUrl}
@@ -96,7 +97,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
<FieldLabel>
Live URL{" "}
<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(optional)</span>
<span style={{ color: JM.muted, fontWeight: 400 }}>(optional)</span>
</FieldLabel>
<TextInput
value={liveUrl}
@@ -111,14 +112,16 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
value={hosting}
onChange={e => setHosting(e.target.value)}
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.88rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: hosting ? "#1a1a1a" : "#a09a90",
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 13,
fontFamily: JM.fontSans, color: hosting ? JM.ink : JM.muted,
outline: "none", boxSizing: "border-box", appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23a09a90' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundImage: `url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%239CA3AF' strokeWidth='1.5' strokeLinecap='round' strokeLinejoin='round'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat", backgroundPosition: "right 12px center",
}}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
>
{HOSTING_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
@@ -127,7 +130,7 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
</div>
<div>
<FieldLabel>
PAT{" "}<span style={{ color: "#b5b0a6", fontWeight: 400 }}>(private repos)</span>
PAT{" "}<span style={{ color: JM.muted, fontWeight: 400 }}>(private repos)</span>
</FieldLabel>
<input
type="password"
@@ -135,20 +138,24 @@ export function MigrateSetup({ workspace, onClose, onBack }: SetupProps) {
onChange={e => setPat(e.target.value)}
placeholder="ghp_…"
style={{
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
}}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
</div>
</div>
<div style={{ fontSize: "0.75rem", color: "#a09a90", marginBottom: 20, lineHeight: 1.5, padding: "12px 14px", background: "#faf8f5", borderRadius: 8, border: "1px solid #f0ece4" }}>
<strong style={{ color: "#4a2a5a" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
<div style={{
fontSize: 12, color: JM.mid, marginBottom: 20, lineHeight: 1.5,
padding: "12px 14px", background: JM.cream, borderRadius: 8,
border: `1px solid ${JM.border}`, fontFamily: JM.fontSans,
}}>
<strong style={{ color: "#5B21B6" }}>Non-destructive.</strong> Vibn builds a full audit and migration plan. Your existing product stays live throughout the entire migration process.
</div>
<PrimaryButton onClick={handleCreate} disabled={!canCreate} loading={loading}>

View File

@@ -1,6 +1,7 @@
"use client";
import type { CreationMode } from "./CreateProjectFlow";
import { JM } from "./modal-theme";
interface TypeSelectorProps {
onSelect: (mode: CreationMode) => void;
@@ -22,7 +23,7 @@ const ALL_FLOW_TYPES: {
label: "Fresh Idea",
tagline: "Start from scratch",
desc: "Talk through your idea with Vibn. We'll explore it together and shape it into a full product plan.",
accent: "#4a3728",
accent: "#4338CA",
},
{
id: "chat-import",
@@ -30,7 +31,7 @@ const ALL_FLOW_TYPES: {
label: "Import Chats",
tagline: "You've been thinking",
desc: "Paste conversations from ChatGPT or Claude. Vibn extracts your decisions, ideas, and open questions.",
accent: "#2e5a4a",
accent: "#059669",
},
{
id: "code-import",
@@ -38,7 +39,7 @@ const ALL_FLOW_TYPES: {
label: "Import Code",
tagline: "Already have a repo",
desc: "Point Vibn at your GitHub or Bitbucket repo. We'll map your stack and show what's missing.",
accent: "#1a3a5c",
accent: "#1D4ED8",
hidden: true,
},
{
@@ -47,7 +48,7 @@ const ALL_FLOW_TYPES: {
label: "Migrate Product",
tagline: "Move an existing product",
desc: "Bring your live product into the VIBN infrastructure. Vibn builds a safe, phased migration plan.",
accent: "#4a2a5a",
accent: "#7C3AED",
hidden: true,
},
];
@@ -56,89 +57,88 @@ const FLOW_TYPES = ALL_FLOW_TYPES.filter(t => !t.hidden);
export function TypeSelector({ onSelect, onClose }: TypeSelectorProps) {
return (
<div style={{ padding: "32px 36px 36px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ padding: 28 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div>
<h2 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.4rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 4,
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, marginBottom: 4, letterSpacing: "-0.02em",
}}>
Start a new project
</h2>
<p style={{ fontSize: "0.78rem", color: "#a09a90", margin: 0 }}>
<p style={{ fontSize: 13, color: JM.mid, margin: 0, fontFamily: JM.fontSans, lineHeight: 1.45 }}>
How would you like to begin?
</p>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4,
color: JM.muted, fontSize: 20, lineHeight: 1,
padding: 4, fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
</div>
{/* Type cards */}
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 10 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr", gap: 8 }}>
{FLOW_TYPES.map(type => (
<button
key={type.id}
type="button"
onClick={() => onSelect(type.id)}
style={{
display: "flex", flexDirection: "column", alignItems: "flex-start",
gap: 0, padding: "20px", borderRadius: 12, textAlign: "left",
border: "1px solid #e8e4dc",
background: "#faf8f5",
gap: 0, padding: 16, borderRadius: 12, textAlign: "left",
border: `1px solid ${JM.border}`,
background: JM.inputBg,
cursor: "pointer",
transition: "all 0.14s",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
transition: "border-color 0.15s, background 0.15s, box-shadow 0.15s",
fontFamily: JM.fontSans,
position: "relative",
overflow: "hidden",
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = "#d0ccc4";
e.currentTarget.style.background = "#fff";
e.currentTarget.style.boxShadow = "0 2px 12px rgba(26,26,26,0.07)";
e.currentTarget.style.borderColor = JM.indigo;
e.currentTarget.style.background = JM.cream;
e.currentTarget.style.boxShadow = "0 2px 12px rgba(99,102,241,0.1)";
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = "#e8e4dc";
e.currentTarget.style.background = "#faf8f5";
e.currentTarget.style.borderColor = JM.border;
e.currentTarget.style.background = JM.inputBg;
e.currentTarget.style.boxShadow = "none";
}}
>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 9, marginBottom: 14,
background: `${type.accent}10`,
width: 36, height: 36, borderRadius: 9, marginBottom: 12,
background: "rgba(99,102,241,0.12)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.1rem", color: type.accent,
fontSize: "1.05rem", color: type.accent,
}}>
{type.icon}
</div>
{/* Label + tagline */}
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 2 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: JM.ink, marginBottom: 2 }}>
{type.label}
</div>
<div style={{ fontSize: "0.68rem", fontWeight: 600, color: type.accent, letterSpacing: "0.03em", marginBottom: 8, textTransform: "uppercase" }}>
<div style={{
fontSize: 10.5, fontWeight: 600, color: type.accent,
letterSpacing: "0.07em", marginBottom: 8, textTransform: "uppercase",
}}>
{type.tagline}
</div>
{/* Description */}
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
<div style={{ fontSize: 12.5, color: JM.mid, lineHeight: 1.55 }}>
{type.desc}
</div>
{/* Arrow */}
<div style={{
position: "absolute", right: 16, bottom: 16,
fontSize: "0.85rem", color: "#c5c0b8",
fontSize: 14, color: JM.muted,
}}>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { ReactNode, CSSProperties } from "react";
import { JM } from "./modal-theme";
export interface SetupProps {
workspace: string;
@@ -8,7 +9,68 @@ export interface SetupProps {
onBack: () => void;
}
// Shared modal header
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{
display: "block", fontSize: 12, fontWeight: 600, color: JM.mid,
marginBottom: 6, fontFamily: JM.fontSans,
}}>
{children}
</label>
);
}
export function ForWhomSelector({
value,
onChange,
}: {
value: "personal" | "client";
onChange: (v: "personal" | "client") => void;
}) {
const cardBase: CSSProperties = {
flex: 1,
border: `1px solid ${JM.border}`,
borderRadius: 9,
padding: 14,
cursor: "pointer",
textAlign: "center" as const,
background: JM.inputBg,
transition: "all 0.15s",
fontFamily: JM.fontSans,
};
const row = (key: "personal" | "client", emoji: string, title: string, sub: string) => {
const sel = value === key;
return (
<button
type="button"
key={key}
onClick={() => onChange(key)}
style={{
...cardBase,
borderColor: sel ? JM.indigo : JM.border,
background: sel ? JM.cream : JM.inputBg,
boxShadow: sel ? "0 0 0 1px rgba(99,102,241,0.2)" : undefined,
}}
>
<div style={{ fontSize: 20, marginBottom: 5 }}>{emoji}</div>
<div style={{ fontSize: 12.5, fontWeight: 600, color: JM.ink }}>{title}</div>
<div style={{ fontSize: 11, color: JM.muted, marginTop: 2 }}>{sub}</div>
</button>
);
};
return (
<div style={{ marginBottom: 22 }}>
<FieldLabel>This project is for</FieldLabel>
<div style={{ display: "flex", gap: 8 }}>
{row("personal", "🧑‍💻", "Myself", "My own product")}
{row("client", "🤝", "A client", "Client project")}
</div>
</div>
);
}
export function SetupHeader({
icon,
label,
@@ -25,41 +87,47 @@ export function SetupHeader({
onClose: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 28 }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 22 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onBack}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1rem", padding: "3px 5px",
color: JM.muted, fontSize: "1rem", padding: "3px 5px",
borderRadius: 4, lineHeight: 1, flexShrink: 0,
fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
onMouseEnter={e => (e.currentTarget.style.color = JM.ink)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
</button>
<div>
<h2 style={{
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400,
color: "#1a1a1a", margin: 0, marginBottom: 3,
fontFamily: JM.fontDisplay, fontSize: 18, fontWeight: 700,
color: JM.ink, margin: 0, marginBottom: 3, letterSpacing: "-0.02em",
}}>
{label}
</h2>
<p style={{ fontSize: "0.72rem", fontWeight: 600, color: accent, textTransform: "uppercase", letterSpacing: "0.04em", margin: 0 }}>
<p style={{
fontSize: 10.5, fontWeight: 600, color: accent, textTransform: "uppercase",
letterSpacing: "0.07em", margin: 0, fontFamily: JM.fontSans,
}}>
{tagline}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
background: "none", border: "none", cursor: "pointer",
color: "#b5b0a6", fontSize: "1.2rem", lineHeight: 1,
padding: "2px 5px", borderRadius: 4, flexShrink: 0,
color: JM.muted, fontSize: 20, lineHeight: 1,
padding: 4, flexShrink: 0, fontFamily: JM.fontSans,
}}
onMouseEnter={e => (e.currentTarget.style.color = "#6b6560")}
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
onMouseEnter={e => (e.currentTarget.style.color = JM.mid)}
onMouseLeave={e => (e.currentTarget.style.color = JM.muted)}
>
×
</button>
@@ -67,14 +135,6 @@ export function SetupHeader({
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
return (
<label style={{ display: "block", fontSize: "0.72rem", fontWeight: 600, color: "#6b6560", marginBottom: 6, letterSpacing: "0.02em" }}>
{children}
</label>
);
}
export function TextInput({
value,
onChange,
@@ -88,13 +148,13 @@ export function TextInput({
placeholder?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
inputRef?: React.RefObject<HTMLInputElement>;
inputRef?: React.RefObject<HTMLInputElement | null> | React.RefObject<HTMLInputElement>;
}) {
const base: CSSProperties = {
width: "100%", padding: "11px 14px", marginBottom: 16,
borderRadius: 8, border: "1px solid #e0dcd4",
background: "#faf8f5", fontSize: "0.9rem",
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#1a1a1a",
width: "100%", padding: "10px 13px", marginBottom: 16,
borderRadius: 8, border: `1px solid ${JM.border}`,
background: JM.inputBg, fontSize: 14,
fontFamily: JM.fontSans, color: JM.ink,
outline: "none", boxSizing: "border-box",
};
return (
@@ -107,8 +167,8 @@ export function TextInput({
placeholder={placeholder}
autoFocus={autoFocus}
style={base}
onFocus={e => (e.currentTarget.style.borderColor = "#1a1a1a")}
onBlur={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
onFocus={e => (e.currentTarget.style.borderColor = JM.indigo)}
onBlur={e => (e.currentTarget.style.borderColor = JM.border)}
/>
);
}
@@ -127,27 +187,44 @@ export function PrimaryButton({
const active = !disabled && !loading;
return (
<button
type="button"
onClick={onClick}
disabled={!active}
style={{
width: "100%", padding: "12px",
borderRadius: 8, border: "none",
background: active ? "#1a1a1a" : "#e0dcd4",
color: active ? "#fff" : "#b5b0a6",
fontSize: "0.88rem", fontWeight: 600,
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
background: active ? JM.primaryGradient : "#E5E7EB",
color: active ? "#fff" : JM.muted,
fontSize: 14, fontWeight: 600,
fontFamily: JM.fontSans,
cursor: active ? "pointer" : "not-allowed",
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
boxShadow: active ? JM.primaryShadow : "none",
transition: "box-shadow 0.2s, transform 0.15s, opacity 0.15s",
}}
onMouseEnter={e => {
if (active) {
e.currentTarget.style.boxShadow = JM.primaryShadowHover;
e.currentTarget.style.transform = "translateY(-1px)";
}
}}
onMouseLeave={e => {
e.currentTarget.style.boxShadow = active ? JM.primaryShadow : "none";
e.currentTarget.style.transform = "none";
}}
onMouseEnter={e => { if (active) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={e => { e.currentTarget.style.opacity = "1"; }}
>
{loading ? (
<>
<span style={{ width: 14, height: 14, borderRadius: "50%", border: "2px solid #fff4", borderTopColor: "#fff", animation: "vibn-spin 0.7s linear infinite", display: "inline-block" }} />
<span style={{
width: 14, height: 14, borderRadius: "50%",
border: "2px solid rgba(255,255,255,0.35)", borderTopColor: "#fff",
animation: "vibn-spin 0.7s linear infinite", display: "inline-block",
}} />
Creating
</>
) : children}
) : (
children
)}
</button>
);
}