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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

# testing
/coverage
/.artifacts/
/playwright-report/
/test-results/
/e2e/.auth/
Expand Down
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,19 @@ Read [docs/architecture.md](./docs/architecture.md) before product or architectu

## UI Direction

There is no active v3 design system in the repository. The previous design draft
has been removed. Before substantial UI work, define or confirm the current
visual direction instead of inheriting old visual rules.
The active v3 design system is defined in [docs/design.md](./docs/design.md).
It derives from SST and is the source of truth for Fulling's visual language,
tokens, typography, spacing, components, states, and responsive behavior. Read
it before substantial UI work. Existing UI that conflicts with it is migration
work, not design precedent.

Avoid generic AI copywriting cliches such as "Elevate", "Seamless", and
"Unleash".

## Key Files

- [docs/architecture.md](./docs/architecture.md) — v3 system architecture and product model
- [docs/design.md](./docs/design.md) — active v3 design system and SST-derived style reference
- `lib/auth/` — Better Auth and application session boundary
- `lib/kubeconfig/` — User credential persistence
- `lib/k8s/` — Validation and user-specific Kubernetes service
Expand Down
6 changes: 3 additions & 3 deletions app/(auth)/login/_components/github-login-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export function GitHubLoginButton() {
}

return (
<div className="mt-8">
<Button type="button" size="lg" className="h-12 w-full bg-[#101418] text-white hover:bg-[#2a3037]" disabled={isPending} onClick={handleSignIn}>
{isPending ? <LoaderCircle className="size-5 animate-spin" /> : <Github className="size-5" />}
<div className="mt-8 border-t border-border pt-6">
<Button type="button" size="lg" className="w-full" disabled={isPending} onClick={handleSignIn}>
{isPending ? <LoaderCircle className="animate-spin" /> : <Github />}
{isPending ? 'Connecting...' : 'Continue with GitHub'}
</Button>
{error ? <p role="alert" className="mt-3 text-sm text-destructive">{error}</p> : null}
Expand Down
32 changes: 17 additions & 15 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'

import { FullingBrand } from '@/components/fulling-brand'
import { getSession } from '@/lib/auth/session'

import { GitHubLoginButton } from './_components/github-login-button'
Expand All @@ -15,19 +15,21 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const { error } = await searchParams

return (
<main className="grid min-h-screen bg-[#f7f8f6] lg:grid-cols-[1fr_1.05fr]">
<section className="flex items-center px-6 py-12 sm:px-12 lg:px-20">
<div className="w-full max-w-md">
<Link
href="/"
className="font-[family-name:var(--font-heading)] text-2xl font-bold text-[#101418]"
>
Fulling
</Link>
<h1 className="mt-14 text-4xl font-semibold text-[#101418]">
<main className="flex min-h-screen flex-col bg-background text-foreground">
<header className="flex h-16 shrink-0 items-center border-b border-border px-4 sm:px-6">
<FullingBrand />
</header>
<section className="flex flex-1 items-center justify-center px-4 py-12 sm:px-6">
<div className="w-full max-w-sm">
<p className="font-mono text-[length:var(--text-micro)] font-medium uppercase leading-[var(--leading-micro)] tracking-[0.056em] text-muted-foreground">
Workspace access
</p>
<h1 className="mt-3 text-xl font-semibold leading-[var(--leading-heading)]">
Sign in to your workspace
</h1>
<p className="mt-4 text-base text-[#66717d]">Use your GitHub account to continue.</p>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Use your GitHub account to continue to Fulling.
</p>
{error === 'oauth' ? (
<p role="alert" className="mt-5 text-sm text-destructive">
GitHub sign-in could not be completed. Please try again.
Expand All @@ -36,9 +38,9 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
<GitHubLoginButton />
</div>
</section>
<div className="relative hidden overflow-hidden border-l border-[#e1e5e8] bg-white lg:block">
<div className="absolute inset-10 bg-[url('/images/landing-workspace-concept.png')] bg-contain bg-center bg-no-repeat" />
</div>
<footer className="h-12 shrink-0 border-t border-border px-4 text-xs leading-[48px] text-muted-foreground sm:px-6">
Dedicated AI workspaces
</footer>
</main>
)
}
11 changes: 6 additions & 5 deletions app/(dashboard)/_components/dashboard-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LogOut, Settings2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'

import { FullingBrand } from '@/components/fulling-brand'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { signOut } from '@/lib/auth-client'
Expand All @@ -29,20 +30,20 @@ export function DashboardHeader({ userName, userEmail, userImage }: DashboardHea
}

return (
<header className="border-b border-[#dde2e5] bg-white">
<div className="mx-auto flex h-16 max-w-6xl items-center gap-4 px-5 sm:px-8">
<Link href="/workspace" className="font-[family-name:var(--font-heading)] text-xl font-bold">Fulling</Link>
<header className="border-b border-border bg-background">
<div className="mx-auto flex h-16 max-w-[var(--page-max-width)] items-center gap-4 px-4 sm:px-6">
<FullingBrand href="/workspace" />
<nav className="ml-3 hidden items-center gap-1 sm:flex" aria-label="Dashboard">
<Button asChild variant="ghost" size="sm"><Link href="/workspace">Workspace</Link></Button>
<Button asChild variant="ghost" size="sm"><Link href="/settings/kubeconfig"><Settings2 />Kubeconfig</Link></Button>
</nav>
<div className="ml-auto flex min-w-0 items-center gap-3">
<Avatar className="size-8"><AvatarImage src={userImage ?? undefined} alt="" /><AvatarFallback>{userName.slice(0, 1).toUpperCase()}</AvatarFallback></Avatar>
<div className="hidden min-w-0 md:block"><p className="truncate text-sm font-medium text-[#171a1f]">{userName}</p><p className="truncate text-xs leading-4">{userEmail}</p></div>
<div className="hidden min-w-0 md:block"><p className="truncate text-sm font-medium text-foreground">{userName}</p><p className="truncate text-xs leading-4 text-muted-foreground">{userEmail}</p></div>
<Button type="button" variant="ghost" size="icon" disabled={isSigningOut} onClick={handleSignOut} aria-label="Sign out" title="Sign out"><LogOut /></Button>
</div>
</div>
<nav className="flex h-11 items-center gap-1 border-t border-[#edf0f2] px-4 sm:hidden" aria-label="Dashboard mobile">
<nav className="flex h-11 items-center gap-1 border-t border-border px-4 sm:hidden" aria-label="Dashboard mobile">
<Button asChild variant="ghost" size="sm"><Link href="/workspace">Workspace</Link></Button>
<Button asChild variant="ghost" size="sm"><Link href="/settings/kubeconfig"><Settings2 />Kubeconfig</Link></Button>
</nav>
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default async function DashboardLayout({ children }: Readonly<{ children:
const { user } = await requireSession()

return (
<div className="min-h-screen bg-[#f7f8f6] text-[#171a1f]">
<div className="min-h-screen bg-background text-foreground">
<DashboardHeader userName={user.name} userEmail={user.email} userImage={user.image ?? null} />
{children}
</div>
Expand Down
26 changes: 13 additions & 13 deletions app/(dashboard)/settings/kubeconfig/_components/kubeconfig-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,11 @@ export function KubeconfigForm() {
}

return (
<main className="mx-auto w-full max-w-4xl px-5 py-10 sm:px-8 sm:py-14">
<header className="border-b border-[#dce1e4] pb-8">
<p className="text-sm font-semibold text-[#287355]">Settings</p>
<h1 className="mt-2 text-3xl font-semibold text-[#171a1f]">Kubeconfig</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-[#5e6974]">
<main className="mx-auto w-full max-w-[var(--page-max-width)] px-4 py-10 sm:px-6 sm:py-12">
<header className="border-b border-border pb-8">
<p className="font-mono text-[length:var(--text-micro)] font-medium uppercase leading-[var(--leading-micro)] tracking-[0.056em] text-muted-foreground">Settings</p>
<h1 className="mt-2 text-xl font-semibold leading-[var(--leading-heading)]">Kubeconfig</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Fulling validates this credential against the selected Kubernetes cluster before storing
it as plaintext. Saved content is never displayed again.
</p>
Expand All @@ -127,7 +127,7 @@ export function KubeconfigForm() {
<h2 id="status-heading" className="text-lg font-semibold">
Saved credential
</h2>
<p className="mt-1 text-sm text-[#5e6974]">
<p className="mt-1 text-sm text-muted-foreground">
{isLoading
? 'Loading status...'
: status?.configured
Expand All @@ -136,20 +136,20 @@ export function KubeconfigForm() {
</p>
</div>
{status?.configured ? (
<div className="flex items-center gap-2 text-sm font-medium text-[#287355]">
<div className="flex items-center gap-2 text-sm font-medium text-[var(--color-status-success)]">
<CheckCircle2 className="size-5" aria-hidden="true" />
Configured
</div>
) : null}
</div>
</section>

<section className="border-t border-[#dce1e4] py-8" aria-labelledby="editor-heading">
<section className="border-t border-border py-8" aria-labelledby="editor-heading">
<div className="max-w-3xl">
<h2 id="editor-heading" className="text-lg font-semibold">
{status?.configured ? 'Replace kubeconfig' : 'Configure kubeconfig'}
</h2>
<p className="mt-2 text-sm leading-6 text-[#5e6974]">
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Paste the complete replacement. Existing content is intentionally not prefilled.
</p>
<Label htmlFor="kubeconfig" className="mt-6">
Expand All @@ -169,7 +169,7 @@ export function KubeconfigForm() {
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-h-5 text-sm" aria-live="polite">
{error ? <p role="alert" className="text-destructive">{error}</p> : null}
{notice ? <p className="text-[#287355]">{notice}</p> : null}
{notice ? <p className="text-[var(--color-status-success)]">{notice}</p> : null}
</div>
<Button
type="button"
Expand All @@ -184,11 +184,11 @@ export function KubeconfigForm() {
</section>

{status?.configured ? (
<section className="border-t border-[#dce1e4] py-8" aria-labelledby="delete-heading">
<section className="border-t border-border py-8" aria-labelledby="delete-heading">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 id="delete-heading" className="text-lg font-semibold">Delete kubeconfig</h2>
<p className="mt-1 text-sm text-[#5e6974]">
<p className="mt-1 text-sm text-muted-foreground">
Your account and session remain active after deletion.
</p>
</div>
Expand All @@ -209,7 +209,7 @@ export function KubeconfigForm() {
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
variant="destructiveSolid"
onClick={() => void handleDelete()}
>
Delete kubeconfig
Expand Down
52 changes: 40 additions & 12 deletions app/(dashboard)/workspace/_components/workspace-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'

import { formatUpdatedAt, type KubeconfigStatus,parseKubeconfigStatus } from '../../_components/kubeconfig-status'
import {
formatUpdatedAt,
type KubeconfigStatus,
parseKubeconfigStatus,
} from '../../_components/kubeconfig-status'

type WorkspaceOverviewProps = { user: { name: string; email: string; image: string | null } }

Expand All @@ -29,24 +33,48 @@ export function WorkspaceOverview({ user }: WorkspaceOverviewProps) {
useEffect(() => { void loadStatus() }, [loadStatus])

return (
<main className="mx-auto w-full max-w-6xl px-5 py-10 sm:px-8 sm:py-14">
<div className="flex flex-col gap-3 border-b border-[#dce1e4] pb-8 sm:flex-row sm:items-end sm:justify-between">
<div><p className="text-sm font-semibold text-[#287355]">Workspace</p><h1 className="mt-2 text-3xl font-semibold text-[#171a1f]">Welcome, {user.name}</h1></div>
<Button asChild><Link href="/settings/kubeconfig">Open settings <ArrowRight /></Link></Button>
<main className="mx-auto w-full max-w-[var(--page-max-width)] px-4 py-10 sm:px-6 sm:py-12">
<div className="flex flex-col gap-4 border-b border-border pb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="font-mono text-[length:var(--text-micro)] font-medium uppercase leading-[var(--leading-micro)] tracking-[0.056em] text-muted-foreground">
Workspace
</p>
<h1 className="mt-2 text-xl font-semibold leading-[var(--leading-heading)]">
Welcome, {user.name}
</h1>
</div>
<Button asChild>
<Link href="/settings/kubeconfig">Open settings <ArrowRight /></Link>
</Button>
</div>
<div className="grid gap-10 py-10 md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.7fr)]">
<div className="grid gap-10 py-8 md:grid-cols-[minmax(0,1fr)_minmax(18rem,0.7fr)]">
<section aria-labelledby="account-heading">
<h2 id="account-heading" className="text-lg font-semibold">Account</h2>
<h2 id="account-heading" className="text-lg font-semibold leading-[var(--leading-heading-sm)]">Account</h2>
<div className="mt-5 flex items-center gap-4">
<Avatar className="size-12"><AvatarImage src={user.image ?? undefined} alt="" /><AvatarFallback>{user.name.slice(0, 1).toUpperCase()}</AvatarFallback></Avatar>
<div className="min-w-0"><p className="truncate font-medium text-[#171a1f]">{user.name}</p><p className="truncate text-sm">{user.email}</p></div>
<div className="min-w-0"><p className="truncate font-medium text-foreground">{user.name}</p><p className="truncate text-sm text-muted-foreground">{user.email}</p></div>
</div>
</section>
<section aria-labelledby="connection-heading" className="border-t border-[#dce1e4] pt-8 md:border-l md:border-t-0 md:pl-10 md:pt-0">
<h2 id="connection-heading" className="text-lg font-semibold">Kubernetes connection</h2>
{!status && !error ? <p className="mt-5 text-sm">Loading status...</p> : null}
<section aria-labelledby="connection-heading" className="border-t border-border pt-8 md:border-l md:border-t-0 md:pl-10 md:pt-0">
<h2 id="connection-heading" className="text-lg font-semibold leading-[var(--leading-heading-sm)]">Kubernetes connection</h2>
{!status && !error ? <p className="mt-5 text-sm text-muted-foreground">Loading status...</p> : null}
{error ? <div className="mt-5"><p role="alert" className="text-sm text-destructive">{error}</p><Button className="mt-3" variant="outline" size="sm" onClick={() => void loadStatus()}><RefreshCw />Retry</Button></div> : null}
{status ? <div className="mt-5 space-y-4"><div className="flex items-center gap-2">{status.configured ? <CheckCircle2 className="size-5 text-[#287355]" /> : <CircleDashed className="size-5 text-[#6a737d]" />}<span className="font-medium">{status.configured ? 'Configured' : 'Not configured'}</span></div><div><p className="text-xs font-semibold uppercase">Last updated</p><p className="mt-1 text-sm text-[#171a1f]">{formatUpdatedAt(status.updatedAt)}</p></div></div> : null}
{status ? (
<div className="mt-5 space-y-4">
<div className="flex items-center gap-2">
{status.configured ? (
<CheckCircle2 className="size-5 text-[var(--color-status-success)]" />
) : (
<CircleDashed className="size-5 text-muted-foreground" />
)}
<span className="font-medium">{status.configured ? 'Configured' : 'Not configured'}</span>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-[0.056em] text-muted-foreground">Last updated</p>
<p className="mt-1 font-mono text-xs text-foreground">{formatUpdatedAt(status.updatedAt)}</p>
</div>
</div>
) : null}
</section>
</div>
</main>
Expand Down
Loading
Loading