# Launch Workflow

> Scope → Planner → OG in one room. Shared ProjectBrief. Preview without keys; AI Host APIs are BYOK.

A shadcn/ui block from the [@atroui](https://www.atroui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/atroui/launch-workflow).

- Page, with a live preview: https://shadcn.exchange/r/atroui/launch-workflow
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/atroui/launch-workflow-light-cecbcb47928f88da.png
- Registry item: https://www.atroui.com/r/launch-workflow.json
- Listed: 2026-10-25, source last changed 2026-10-25

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/atroui/launch-workflow.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`, `next`
- Registry items: `@atroui/utils`, `@atroui/project-brief`, `@atroui/scope-chat`, `@atroui/project-planner`, `@atroui/og-workspace`, `@atroui/api-scope`, `@atroui/api-generate`

## Files

### `registry/default/blocks/launch-room.tsx`

```tsx
"use client"

/**
 * Launch Room — Scope → Planner → Social in one frame.
 * Family Values: one step at a time; brief travels; handoff CTA is the primary.
 *
 * Install: npx shadcn@latest add @atroui/launch-workflow
 */

import { useEffect, useState } from "react"
import Link from "next/link"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ArrowRight, Check } from "lucide-react"

import {
  PROJECT_BRIEF_STORAGE_KEY,
  briefOgSubtitle,
  briefOgTitle,
  buildOgHref,
  isProjectBrief,
  parseProjectBrief,
  type ProjectBrief,
  EMPTY_PROJECT_BRIEF,
} from "@/lib/project-brief"
import { cn } from "@/lib/utils"
import { ScopeChat } from "./scope-chat"
import { ProjectPlanner } from "./project-planner"
import { OgWorkspace } from "./og-workspace"

const CONTENT = {
  ogPath: "/og",
  install: "npx shadcn@latest add @atroui/launch-workflow",
}

type StepId = "scope" | "planner" | "social"

const STEPS: { id: StepId; label: string }[] = [
  { id: "scope", label: "Scope" },
  { id: "planner", label: "Planner" },
  { id: "social", label: "Social" },
]

const layoutTween = {
  type: "tween" as const,
  duration: 0.22,
  ease: [0.16, 1, 0.3, 1] as const,
}

const switchLayoutTween = {
  type: "tween" as const,
  duration: 0.28,
  ease: [0.16, 1, 0.3, 1] as const,
}

function readStoredBrief(): ProjectBrief | null {
  if (typeof window === "undefined") return null
  try {
    const raw = window.localStorage.getItem(PROJECT_BRIEF_STORAGE_KEY)
    if (!raw) return null
    return parseProjectBrief(raw)
  } catch {
    return null
  }
}

function writeStoredBrief(brief: ProjectBrief) {
  if (typeof window === "undefined") return
  try {
    window.localStorage.setItem(
      PROJECT_BRIEF_STORAGE_KEY,
      JSON.stringify(brief),
    )
  } catch {
    /* ignore quota */
  }
}

export function LaunchRoom() {
  const reduce = useReducedMotion()
  const [step, setStep] = useState<StepId>("scope")
  const [brief, setBrief] = useState<ProjectBrief>(EMPTY_PROJECT_BRIEF)
  const [mounted, setMounted] = useState(false)
  const [copied, setCopied] = useState(false)

  useEffect(() => {
    const stored = readStoredBrief()
    if (stored) setBrief(stored)
    setMounted(true)
  }, [])

  const persistBrief = (next: ProjectBrief) => {
    setBrief(next)
    writeStoredBrief(next)
  }

  const hasBrief =
    mounted &&
    isProjectBrief(brief) &&
    Boolean(brief.name.trim() || brief.oneLiner.trim())

  const reading = hasBrief
    ? `${brief.name || "Untitled"}${brief.oneLiner ? ` · ${brief.oneLiner.slice(0, 48)}` : ""}`
    : "Describe a project to start"

  const stepIndex = STEPS.findIndex((s) => s.id === step)

  const goPlanner = () => {
    if (!hasBrief) return
    setStep("planner")
  }

  const goSocial = () => {
    if (!hasBrief) return
    setStep("social")
  }

  const copyInstall = async () => {
    try {
      await navigator.clipboard.writeText(CONTENT.install)
      setCopied(true)
      window.setTimeout(() => setCopied(false), 1600)
    } catch {
      /* ignore */
    }
  }

  const ogHref = buildOgHref(brief, { path: CONTENT.ogPath })

  return (
    <div className="overflow-hidden rounded-[var(--atro-panel-radius)] border border-border-subtle bg-background">
      <header className="flex flex-wrap items-end justify-between gap-3 border-b border-border-subtle px-4 py-3.5 @min-[40rem]:px-5">
        <div className="min-w-0">
          <p className="font-mono text-[10px] tracking-[0.16em] text-muted-foreground uppercase">
            Launch room
          </p>
          <h1 className="mt-1 text-[1.45rem] font-medium tracking-[-0.035em] text-foreground @min-[40rem]:text-[1.75rem]">
            Scope to{" "}
            <em className="font-normal italic text-brand">social</em>
          </h1>
        </div>
        <p
          className="max-w-sm font-mono text-[10px] leading-snug tracking-[0.04em] text-muted-foreground @min-[40rem]:text-right"
          aria-live="polite"
        >
          {reading}
        </p>
      </header>

      <div
        role="tablist"
        aria-label="Launch steps"
        className="sticky top-0 z-10 flex gap-0.5 border-b border-border-subtle bg-background/95 px-2 py-1.5 backdrop-blur-sm"
      >
        {STEPS.map((item, i) => {
          const selected = step === item.id
          const done = i < stepIndex
          return (
            <button
              key={item.id}
              type="button"
              role="tab"
              aria-selected={selected}
              onClick={() => {
                if (i === 0 || hasBrief) setStep(item.id)
              }}
              disabled={i > 0 && !hasBrief}
              className={cn(
                "relative flex flex-1 items-center justify-center gap-1 rounded-[calc(var(--atro-control-radius)-1px)] px-1 py-1.5 text-[11px] font-medium tracking-tight outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40",
                selected
                  ? "text-foreground"
                  : "text-muted-foreground hover:text-foreground",
              )}
            >
              {selected && !reduce ? (
                <motion.span
                  layoutId="launch-room-step-pill"
                  className="absolute inset-0 rounded-[calc(var(--atro-control-radius)-1px)] bg-muted/80 ring-1 ring-border-subtle"
                  transition={switchLayoutTween}
                  aria-hidden
                />
              ) : null}
              {selected && reduce ? (
                <span
                  className="absolute inset-0 rounded-[calc(var(--atro-control-radius)-1px)] bg-muted/80 ring-1 ring-border-subtle"
                  aria-hidden
                />
              ) : null}
              <span className="relative z-[1] inline-flex items-center gap-1">
                {done ? (
                  <Check className="size-3 text-brand" strokeWidth={2.5} aria-hidden />
                ) : null}
                {item.label}
              </span>
            </button>
          )
        })}
      </div>

      <div className="relative min-h-[28rem] bg-[radial-gradient(90%_60%_at_50%_0%,color-mix(in_oklch,var(--brand)_10%,transparent),transparent_55%)]">
        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={step}
            role="tabpanel"
            className="p-3 @min-[40rem]:p-4"
            initial={reduce ? false : { opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduce ? undefined : { opacity: 0, y: -6 }}
            transition={layoutTween}
          >
            {step === "scope" ? (
              <ScopeChat onBriefChange={persistBrief} />
            ) : null}
            {step === "planner" ? (
              <ProjectPlanner onBriefChange={persistBrief} />
            ) : null}
            {step === "social" ? (
              <OgWorkspace
                title={briefOgTitle(brief) || undefined}
                subtitle={briefOgSubtitle(brief) || undefined}
              />
            ) : null}
          </motion.div>
        </AnimatePresence>
      </div>

      <div className="sticky bottom-0 z-10 flex flex-col gap-2 border-t border-border-subtle bg-background/95 px-3 py-3 backdrop-blur-sm @min-[40rem]:flex-row @min-[40rem]:items-center @min-[40rem]:justify-between @min-[40rem]:px-4">
        <p className="font-mono text-[10px] tracking-[0.08em] text-muted-foreground">
          {step === "scope"
            ? "Send a message, then continue"
            : step === "planner"
              ? "Reach estimate, then continue — or skip to social"
              : "Preview without keys · AI needs Host API env"}
        </p>
        <div className="flex flex-wrap items-center gap-2">
          {step === "scope" ? (
            <button
              type="button"
              className="atro-btn"
              disabled={!hasBrief}
              onClick={goPlanner}
            >
              Continue to planner
              <ArrowRight className="size-4" aria-hidden />
            </button>
          ) : null}
          {step === "planner" ? (
            <>
              <button
                type="button"
                className="atro-btn"
                disabled={!hasBrief}
                onClick={goSocial}
              >
                Continue to social
                <ArrowRight className="size-4" aria-hidden />
              </button>
              <Link href={ogHref} className="atro-btn-ghost">
                Open full OG
              </Link>
            </>
          ) : null}
          {step === "social" ? (
            <>
              <Link href={ogHref} className="atro-btn">
                Open full OG
                <ArrowRight className="size-4" aria-hidden />
              </Link>
              <button type="button" className="atro-btn-ghost" onClick={copyInstall}>
                {copied ? "Copied" : "Copy install"}
              </button>
            </>
          ) : null}
        </div>
      </div>
    </div>
  )
}
```

### `registry/default/app/launch/page.tsx`

```tsx
import { LaunchRoom } from "@/components/blocks/launch-room"

/**
 * Consumer launch path — dropped by @atroui/launch-workflow.
 * Wire SiteHeader / layout in your app as needed.
 */
export default function LaunchPage() {
  return (
    <main className="mx-auto max-w-5xl px-4 py-8 @min-[40rem]:px-6 @min-[40rem]:py-10">
      <LaunchRoom />
    </main>
  )
}
```
