Redesign tour intro preview (#2852)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-26 15:20:43 -07:00 committed by GitHub
parent 69986f2ff5
commit 656dab3afd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 489 additions and 260 deletions

View File

@ -1808,12 +1808,18 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
/* Frame 2 Orchestration: child cards flash as bubbles land along the
dashed paths fanning down from the coordinator. */
/* Why: bubbles use percent-based left/top so they always travel from the
root card's right edge (34%) to the child card's left edge (64%),
regardless of preview width. The previous offset-path used hardcoded
pixels and drifted off the dashed lines once the preview resized. */
.feature-tour-orch-bubble {
position: absolute;
top: 0;
left: 0;
left: 34%;
top: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
border-radius: 3px;
background: var(--primary);
color: var(--primary-foreground);
@ -1821,58 +1827,124 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
align-items: center;
justify-content: center;
opacity: 0;
offset-rotate: 0deg;
z-index: 4;
animation: feature-tour-orch-travel var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1)
}
.feature-tour-orch-bubble.top {
animation: feature-tour-orch-travel-top var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1)
infinite;
}
.feature-tour-orch-bubble.left {
offset-path: path('M 168 36 C 132 56, 102 66, 72 86');
offset-anchor: center;
animation-delay: 4s;
}
.feature-tour-orch-bubble.right {
offset-path: path('M 168 36 C 204 56, 234 66, 264 86');
offset-anchor: center;
animation-delay: 4s;
.feature-tour-orch-bubble.mid {
animation: feature-tour-orch-travel-mid var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1)
infinite;
animation-delay: calc(4s + 0.2s);
}
.feature-tour-orch-bubble.left.b2 {
animation-delay: calc(4s + 0.55s);
.feature-tour-orch-bubble.bot {
animation: feature-tour-orch-travel-bot var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1)
infinite;
animation-delay: calc(4s + 0.4s);
}
.feature-tour-orch-bubble.right.b2 {
animation-delay: calc(4s + 0.75s);
}
@keyframes feature-tour-orch-travel {
@keyframes feature-tour-orch-travel-top {
0% {
offset-distance: 0%;
left: 34%;
top: 50%;
opacity: 0;
}
2% {
offset-distance: 0%;
opacity: 1;
}
3.5% {
left: 49%;
top: 50%;
opacity: 1;
}
6.5% {
offset-distance: 100%;
left: 64%;
top: 18%;
opacity: 1;
}
7.5% {
offset-distance: 100%;
left: 64%;
top: 18%;
opacity: 0;
}
25%,
100% {
offset-distance: 100%;
left: 64%;
top: 18%;
opacity: 0;
}
}
@keyframes feature-tour-orch-travel-mid {
0% {
left: 34%;
top: 50%;
opacity: 0;
}
2% {
opacity: 1;
}
6.5% {
left: 64%;
top: 50%;
opacity: 1;
}
7.5% {
left: 64%;
top: 50%;
opacity: 0;
}
25%,
100% {
left: 64%;
top: 50%;
opacity: 0;
}
}
@keyframes feature-tour-orch-travel-bot {
0% {
left: 34%;
top: 50%;
opacity: 0;
}
2% {
opacity: 1;
}
3.5% {
left: 49%;
top: 50%;
opacity: 1;
}
6.5% {
left: 64%;
top: 82%;
opacity: 1;
}
7.5% {
left: 64%;
top: 82%;
opacity: 0;
}
25%,
100% {
left: 64%;
top: 82%;
opacity: 0;
}
}
.feature-tour-orch-child {
animation: feature-tour-orch-child-flash var(--feature-tour-cycle) linear infinite;
}
.feature-tour-orch-child.top {
animation-delay: 4s;
}
.feature-tour-orch-child.right {
.feature-tour-orch-child.mid {
animation-delay: calc(4s + 0.2s);
}
.feature-tour-orch-child.bot {
animation-delay: calc(4s + 0.4s);
}
@keyframes feature-tour-orch-child-flash {
0%,
6% {
@ -1897,14 +1969,18 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
/* Frame 3 Tasks: cursor lands on the second row, the "Open" pill morphs
to a dark "Start workspace →" pill, then a workspace card slides in. */
.feature-tour-tasks-cursor {
/* Why: anchored from the row's right edge so the cursor sits just past the
pill's right edge (12px row padding + ~6px past pill end) instead of
covering the label text. The arrow tip (top-left of the SVG) lands on the
dark pill's trailing edge with the body of the cursor extending right. */
position: absolute;
top: 0;
left: 0;
top: 50%;
right: 14px;
width: 14px;
height: 14px;
pointer-events: none;
z-index: 8;
transform: translate(20px, 8px);
transform: translate(120px, -30%);
opacity: 0;
animation: feature-tour-tasks-cursor var(--feature-tour-cycle) cubic-bezier(0.45, 0.05, 0.2, 1)
infinite;
@ -1917,22 +1993,30 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
}
@keyframes feature-tour-tasks-cursor {
0% {
transform: translate(20px, 8px);
transform: translate(160px, -10%);
opacity: 0;
}
2% {
opacity: 1;
}
5% {
transform: translate(120px, 60px);
transform: translate(60px, -30%);
opacity: 1;
}
8% {
transform: translate(238px, 60px);
transform: translate(0px, -30%);
opacity: 1;
}
9% {
transform: translate(0px, -10%);
opacity: 1;
}
10.5% {
transform: translate(0px, -30%);
opacity: 1;
}
23.5% {
transform: translate(238px, 60px);
transform: translate(0px, -30%);
opacity: 1;
}
24.5% {
@ -1940,7 +2024,7 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
}
25%,
100% {
transform: translate(238px, 60px);
transform: translate(0px, -30%);
opacity: 0;
}
}
@ -1977,25 +2061,29 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
@keyframes feature-tour-tasks-pill-morph {
0%,
6% {
width: 36px;
width: 28px;
background: rgb(34 197 94 / 0.14);
border-color: rgb(34 197 94 / 0.3);
box-shadow: none;
}
8% {
width: 110px;
width: 78px;
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.18);
}
23.5% {
width: 110px;
width: 78px;
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.18);
}
25%,
100% {
width: 36px;
width: 28px;
background: rgb(34 197 94 / 0.14);
border-color: rgb(34 197 94 / 0.3);
box-shadow: none;
}
}
@ -2027,11 +2115,15 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
/* Click ring fires when the cursor "clicks" the morphed pill on the tasks
row. Positioned to overlay the right-side pill of the second task. */
.feature-tour-tasks-click-ring {
/* Why: ring is centered on the cursor tip (right=28 from row's right edge)
so the click feedback lands where the cursor is, on the arrow icon not
centered on the pill, which would visually disconnect from the cursor. */
position: absolute;
top: 50%;
right: 14px;
top: 50px;
width: 22px;
height: 22px;
width: 28px;
height: 28px;
margin-top: -14px;
border-radius: 9999px;
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
opacity: 0;

View File

@ -1,24 +1,13 @@
import type { JSX } from 'react'
import { cn } from '@/lib/utils'
import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons'
function CodexInlineIcon(): JSX.Element {
return (
<svg
width={12}
height={12}
viewBox="0 0 24 24"
aria-hidden
focusable="false"
className="text-foreground"
>
<path
fill="currentColor"
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
/>
</svg>
)
}
import {
CodexInlineIcon,
CursorIcon,
MailGlyph,
WorkingSpinner
} from './feature-tour-preview-glyphs'
import { FeatureTourWorkspaceCard } from './FeatureTourWorkspaceCard'
type FrameId = 1 | 2 | 3 | 4
@ -54,214 +43,277 @@ export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] =
}
]
function WorkingSpinner({ size = 'sm' }: { size?: 'sm' | 'xs' }): JSX.Element {
// Why: matches AgentStateDot's working indicator so the preview teaches
// the same state language users see in the real app.
const ring = size === 'xs' ? 'size-1.5 border' : 'size-2 border-2'
return (
<span
className={cn(
'inline-block shrink-0 rounded-full border-yellow-500 border-t-transparent animate-spin',
ring
)}
aria-hidden
/>
)
}
function CursorIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden focusable="false">
<path
d="M2 1.5 L2 12 L5 9 L7.2 14.5 L9.5 13.6 L7.3 8 L11.5 8 Z"
className="fill-background stroke-foreground"
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
)
}
function MailGlyph(): JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden width="9" height="9" fill="none" strokeWidth={1.6}>
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" />
<path d="M3 7l9 6 9-6" stroke="currentColor" />
</svg>
)
}
function WorkspaceFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-1.5 bg-card p-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
Isolated workspaces
</div>
<div className="rounded-md border border-border bg-background px-2 py-1.5">
<div className="flex items-center gap-2">
<WorkingSpinner />
<span className="truncate text-[11px] font-medium leading-none text-foreground">
fix login race condition
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1.5 pl-3.5">
<div className="flex items-center gap-2">
<WorkingSpinner size="xs" />
<ClaudeIcon size={12} />
<span className="h-1.5 w-[55%] rounded-full bg-foreground/15" />
</div>
<div className="flex items-center gap-2">
<WorkingSpinner size="xs" />
<CodexInlineIcon />
<span className="h-1.5 w-[48%] rounded-full bg-foreground/15" />
</div>
</div>
</div>
<div className="rounded-md border border-border/70 bg-background px-2 py-1.5">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-emerald-500" />
<span className="truncate text-[11px] leading-none text-muted-foreground">
speed up CI pipeline
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1.5 pl-3.5">
<div className="flex items-center gap-2">
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
<OpenCodeGoIcon size={12} />
<span className="h-1.5 w-[42%] rounded-full bg-foreground/15" />
</div>
</div>
{/* Why: 3 cards in a row tells the "ship several at once" story by
composition; the wide preview aspect (~4.9:1) makes a vertical stack
read as wasted space. The grid auto-sizes (no flex-1) so the cards
don't stretch to the container's bottom edge keeps headroom under
the dot indicators. */}
<div className="grid grid-cols-3 gap-3 px-4">
<FeatureTourWorkspaceCard
status="working"
title="fix login race condition"
agents={[
{ kind: 'claude', barWidth: '60%', state: 'working' },
{ kind: 'codex', barWidth: '52%', state: 'working' }
]}
/>
<FeatureTourWorkspaceCard
status="done"
title="speed up CI pipeline"
agents={[{ kind: 'opencode-go', barWidth: '70%', state: 'done' }]}
/>
<FeatureTourWorkspaceCard
status="working"
title="refactor billing webhook"
agents={[{ kind: 'claude', barWidth: '38%', state: 'working' }]}
/>
</div>
</div>
)
}
type OrchChildAgent = 'claude' | 'codex' | 'opencode-go'
const ORCH_CHILDREN: readonly {
key: 'top' | 'mid' | 'bot'
position: string
label: string
agent: OrchChildAgent
}[] = [
// Why: card vertical centers anchor to 18% / 50% / 82% — the same Y
// endpoints the dashed SVG paths terminate at — so the connectors land on
// each card's center regardless of card height.
{ key: 'top', position: 'top-[18%] -translate-y-1/2', label: 'PR 1/3', agent: 'claude' },
{ key: 'mid', position: 'top-1/2 -translate-y-1/2', label: 'PR 2/3', agent: 'codex' },
{ key: 'bot', position: 'top-[82%] -translate-y-1/2', label: 'PR 3/3', agent: 'opencode-go' }
]
function OrchestrationFrame(): JSX.Element {
// Why: a horizontal fan (root → 3 children L→R) reads naturally as
// "fans out and ships parallel PRs" at the wide aspect; the previous
// top-down tree wasted the horizontal space. SVG paths are sized to a
// 600×130 viewBox and stretched non-uniformly, so the dashed lines flex
// with the container while the absolutely-positioned cards stay aligned
// to viewport-relative anchors (root left, children right column).
return (
<div className="absolute inset-0 flex flex-col gap-1.5 bg-card p-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
Agent orchestration
</div>
<div className="relative w-full flex-1">
{/* Why: viewBox is percent-units (100×100, preserveAspectRatio="none")
so endpoints anchor to the same percentage anchors as the cards
and the bubbles root right edge at 34%, child left edge at 64%,
child Y centers at 18%/50%/82%. Explicit width/height attrs are
required because an SVG with a 1:1 viewBox and only inset-0
otherwise picks its intrinsic 1:1 aspect for height. */}
<svg
className="pointer-events-none absolute inset-0 text-foreground/20"
viewBox="0 0 332 130"
className="pointer-events-none absolute inset-0 text-foreground/30"
viewBox="0 0 100 100"
preserveAspectRatio="none"
width="100%"
height="100%"
aria-hidden
>
{/* Why: vectorEffect is NOT inheritable in SVG, so the
non-scaling-stroke attribute must live on each path. Hoisting
it onto <g> let preserveAspectRatio="none" stretch the dashes
into trapezoids on the diagonal connectors. */}
<path
d="M 166 36 C 130 56, 100 66, 72 86"
d="M 34 50 C 49 50, 49 18, 64 18"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="2 3"
vectorEffect="non-scaling-stroke"
/>
<path
d="M 166 36 C 202 56, 232 66, 260 86"
d="M 34 50 L 64 50"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="2 3"
vectorEffect="non-scaling-stroke"
/>
<path
d="M 34 50 C 49 50, 49 82, 64 82"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeDasharray="2 3"
vectorEffect="non-scaling-stroke"
/>
</svg>
<div className="absolute left-12 right-12 top-1 flex h-7 items-center gap-2 rounded-md border border-border bg-background px-2.5">
<span className="font-mono text-[9.5px] text-muted-foreground">redesign auth flow</span>
<span className="h-1.5 flex-1 rounded-full bg-foreground/12" />
<WorkingSpinner />
{/* Why: parent matches WorkspaceCard composition spinner + title row,
then a Claude agent row underneath so the user reads it as "a
workspace running Claude as the orchestrator," consistent with how
workspaces look elsewhere in the app. */}
<div className="absolute left-0 top-1/2 flex w-[34%] -translate-y-1/2 flex-col rounded-md border border-border bg-background px-3 py-2">
<div className="flex items-center gap-2">
<WorkingSpinner />
<span className="truncate text-[15px] font-medium leading-none text-foreground">
redesign auth flow
</span>
</div>
<div className="mt-2 flex items-center gap-1.5 pl-3.5">
<WorkingSpinner size="xs" />
<ClaudeIcon size={13} />
<span className="truncate text-[12.5px] leading-none text-muted-foreground">
orchestrating 3 agents
</span>
</div>
</div>
<div className="absolute left-1/2 top-[42px] -translate-x-1/2 rounded-full border border-border bg-muted px-1.5 py-px font-sans text-[9.5px] leading-none text-muted-foreground">
2 children
</div>
{/* Why: children mirror the parent's WorkspaceCard composition so the
fan reads as "coordinator workspace dispatches to 3 child
workspaces, each running its own agent." */}
{ORCH_CHILDREN.map(({ key, position, label, agent }) => (
<div
key={key}
className={cn(
'feature-tour-orch-child absolute right-0 flex w-[36%] flex-col rounded-md border border-border bg-background px-3 py-2',
key,
position
)}
>
<div className="flex items-center gap-2">
<WorkingSpinner />
<span className="truncate font-mono text-[14px] font-medium leading-none text-foreground">
{label}
</span>
</div>
<div className="mt-2 flex items-center gap-1.5 pl-3.5">
<WorkingSpinner size="xs" />
{agent === 'claude' ? (
<ClaudeIcon size={12} />
) : agent === 'codex' ? (
<CodexInlineIcon />
) : (
<OpenCodeGoIcon size={12} />
)}
<span className="h-2 flex-1 rounded-full bg-foreground/15" />
</div>
</div>
))}
<div className="feature-tour-orch-child absolute left-0 top-[78px] flex h-7 w-[120px] items-center gap-2 rounded-md border border-border bg-background px-2.5">
<span className="font-mono text-[10px] leading-none text-muted-foreground">PR 1/2</span>
<span className="h-1.5 flex-1 rounded-full bg-foreground/12" />
</div>
<div className="feature-tour-orch-child right absolute right-0 top-[78px] flex h-7 w-[120px] items-center gap-2 rounded-md border border-border bg-background px-2.5">
<span className="font-mono text-[10px] leading-none text-muted-foreground">PR 2/2</span>
<span className="h-1.5 flex-1 rounded-full bg-foreground/12" />
</div>
<div className="feature-tour-orch-bubble left">
<MailGlyph />
</div>
<div className="feature-tour-orch-bubble right">
<MailGlyph />
</div>
<div className="feature-tour-orch-bubble left b2">
<MailGlyph />
</div>
<div className="feature-tour-orch-bubble right b2">
<MailGlyph />
</div>
{ORCH_CHILDREN.map(({ key }) => (
<div key={`bubble-${key}`} className={cn('feature-tour-orch-bubble', key)}>
<MailGlyph />
</div>
))}
</div>
</div>
)
}
function TasksFrame(): JSX.Element {
// Why: a left→right pipeline reads as "pick from backlog → workspace
// appears" in one glance. The wide aspect lets the backlog and the
// resulting workspace card sit side-by-side instead of stacked, which
// makes the cause/effect visible in the composition itself.
return (
<div className="absolute inset-0 flex flex-col gap-1 bg-card px-3 pb-3 pt-2.5">
<div className="px-2.5 pb-1 text-[10px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
GitHub & Linear tasks
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
GitHub &amp; Linear tasks
</div>
<div className="grid h-[26px] grid-cols-[34px_minmax(0,1fr)_auto] items-center gap-2.5 rounded-md px-2.5">
<span className="inline-flex h-3.5 w-[30px] items-center justify-center rounded-[3px] border border-border bg-muted font-mono text-[9px] leading-none text-muted-foreground">
#1799
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
Bulk archive in source control
</span>
<span className="h-4 w-9 rounded-full border border-emerald-500/30 bg-emerald-500/15" />
<div className="relative grid flex-1 grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center gap-4 px-4">
<div className="flex flex-col gap-2">
<div className="flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
GH #1799
</span>
{/* Why: surrounding rows show only the issue number + a skeleton
so the user's eye is drawn to the row that has real text the
one the cursor clicks on. */}
<span className="h-2 w-[60%] rounded-full bg-foreground/12" />
</div>
<div className="feature-tour-tasks-row relative flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
GH #1842
</span>
<span className="truncate text-[15px] font-medium leading-none text-foreground">
Worktree picker truncates
</span>
<span className="feature-tour-tasks-pill relative ml-auto flex h-6 items-center justify-center overflow-hidden rounded-full border border-emerald-500/30 bg-emerald-500/15">
<span className="feature-tour-tasks-pill-label flex items-center gap-1 whitespace-nowrap pl-3 pr-2.5 text-[13px] font-semibold leading-none tracking-[0.01em] text-primary-foreground">
Start
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M3 8h10" />
<path d="M9 4l4 4-4 4" />
</svg>
</span>
</span>
{/* Why: cursor + ring live inside the row so they anchor to the
pill's right-edge ml-auto, instead of using fixed pixel offsets
that drift when the preview is resized. */}
<span className="feature-tour-tasks-cursor">
<CursorIcon />
</span>
<span className="feature-tour-tasks-click-ring" aria-hidden />
</div>
<div className="flex h-9 items-center gap-2.5 rounded-md border border-border bg-background px-3">
<span className="inline-flex h-5 items-center justify-center rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[13px] leading-none text-muted-foreground">
LIN-329
</span>
<span className="h-2 w-[45%] rounded-full bg-foreground/12" />
</div>
</div>
<div className="feature-tour-tasks-workspace flex flex-col gap-2 rounded-md border border-border bg-background px-4 py-2.5">
<div className="flex items-center gap-2.5">
<WorkingSpinner />
<span className="truncate text-[15.5px] font-medium leading-none text-foreground">
fix/worktree-picker-truncates
</span>
<span className="ml-auto inline-flex">
<ClaudeIcon size={13} />
</span>
</div>
<div className="flex items-center gap-2.5 pl-4">
<WorkingSpinner size="xs" />
<ClaudeIcon size={12} />
<span className="h-2 w-[55%] rounded-full bg-foreground/15" />
</div>
<div className="text-[13.5px] leading-none text-muted-foreground">Linked to GH #1842</div>
</div>
</div>
<div className="feature-tour-tasks-row relative grid h-[26px] grid-cols-[34px_minmax(0,1fr)_auto] items-center gap-2.5 rounded-md px-2.5">
<span className="inline-flex h-3.5 w-[30px] items-center justify-center rounded-[3px] border border-border bg-muted font-mono text-[9px] leading-none text-muted-foreground">
#1842
</span>
<span className="truncate text-[11px] font-medium leading-none text-foreground">
Worktree picker truncates
</span>
<span className="feature-tour-tasks-pill relative flex h-4 items-center justify-center overflow-hidden rounded-full border border-emerald-500/30 bg-emerald-500/15">
<span className="feature-tour-tasks-pill-label whitespace-nowrap text-[10px] font-medium leading-none text-primary-foreground">
Start workspace
</span>
</span>
</div>
<div className="feature-tour-tasks-workspace mt-1.5 flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5">
<WorkingSpinner />
<span className="truncate text-[10.5px] font-medium leading-none text-foreground">
fix/worktree-picker-truncates
</span>
<span className="ml-auto inline-flex items-center gap-1">
<ClaudeIcon size={11} />
</span>
</div>
<span className="feature-tour-tasks-cursor">
<CursorIcon />
</span>
<span className="feature-tour-tasks-click-ring" aria-hidden />
</div>
)
}
function TerminalFrame(): JSX.Element {
return (
<div className="absolute inset-0 flex flex-col gap-1.5 bg-card p-3">
<div className="text-[10px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
<div className="absolute inset-0 flex flex-col gap-5 bg-card px-4 py-4">
<div className="text-[14.5px] font-semibold uppercase tracking-[0.07em] leading-none text-muted-foreground">
Splittable terminal
</div>
<div className="flex h-[136px] flex-col overflow-hidden rounded-md border border-border bg-background">
<div className="mx-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-background">
<div className="flex items-center gap-1.5 border-b border-border bg-muted/40 px-2 py-1">
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="size-1.5 rounded-full bg-foreground/15" />
<span className="ml-2 font-mono text-[9px] leading-none text-muted-foreground">
<span className="ml-2 font-mono text-[13.5px] leading-none text-muted-foreground">
orca · zsh
</span>
</div>
<div className="grid flex-1 grid-cols-2 divide-x divide-border font-mono text-[10px] leading-[1.4] text-foreground">
<div className="grid flex-1 grid-cols-2 divide-x divide-border font-mono text-[14.5px] leading-[1.4] text-foreground">
<div className="min-w-0 p-2">
<div className="flex items-center gap-1">
<span className="text-emerald-500">$</span>
@ -302,7 +354,7 @@ function TerminalFrame(): JSX.Element {
className="feature-tour-terminal-output flex min-w-0 items-center gap-1.5"
data-line="1"
>
<ClaudeIcon size={10} />
<ClaudeIcon size={12} />
<span className="truncate text-muted-foreground">session started</span>
</div>
<div
@ -331,7 +383,7 @@ export function FeatureTourPreview(props: { className?: string }): JSX.Element {
return (
<div
className={cn(
'relative h-[180px] w-full overflow-hidden rounded-lg border border-border bg-muted/40',
'relative h-[260px] w-full overflow-hidden rounded-lg border border-border bg-muted/40',
props.className
)}
aria-hidden

View File

@ -0,0 +1,64 @@
import type { JSX } from 'react'
import { cn } from '@/lib/utils'
import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons'
import { CodexInlineIcon, WorkingSpinner } from './feature-tour-preview-glyphs'
export type FeatureTourWorkspaceCardAgent = {
kind: 'claude' | 'codex' | 'opencode-go'
barWidth: string
state: 'working' | 'done'
}
export function FeatureTourWorkspaceCard({
status,
title,
agents
}: {
status: 'working' | 'done'
title: string
agents: readonly FeatureTourWorkspaceCardAgent[]
}): JSX.Element {
return (
<div
className={cn(
'flex min-w-0 flex-col rounded-md border bg-background px-3 py-2',
status === 'done' ? 'border-border/70' : 'border-border'
)}
>
<div className="flex items-center gap-2">
{status === 'working' ? (
<WorkingSpinner />
) : (
<span className="size-2 rounded-full bg-emerald-500" />
)}
<span
className={cn(
'truncate text-[16.5px] leading-none',
status === 'done' ? 'text-muted-foreground' : 'font-medium text-foreground'
)}
>
{title}
</span>
</div>
<div className="mt-2 flex flex-col gap-1.5 pl-3.5">
{agents.map((agent, idx) => (
<div key={idx} className="flex items-center gap-1.5">
{agent.state === 'working' ? (
<WorkingSpinner size="xs" />
) : (
<span className="inline-block size-1.5 rounded-full bg-emerald-500" />
)}
{agent.kind === 'claude' ? (
<ClaudeIcon size={13} />
) : agent.kind === 'codex' ? (
<CodexInlineIcon />
) : (
<OpenCodeGoIcon size={13} />
)}
<span className="h-2 rounded-full bg-foreground/15" style={{ width: agent.barWidth }} />
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,57 @@
import type { JSX } from 'react'
import { cn } from '@/lib/utils'
export function CodexInlineIcon(): JSX.Element {
return (
<svg
width={14}
height={14}
viewBox="0 0 24 24"
aria-hidden
focusable="false"
className="text-foreground"
>
<path
fill="currentColor"
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
/>
</svg>
)
}
export function WorkingSpinner({ size = 'sm' }: { size?: 'sm' | 'xs' }): JSX.Element {
// Why: matches AgentStateDot's working indicator so the preview teaches
// the same state language users see in the real app.
const ring = size === 'xs' ? 'size-1.5 border' : 'size-2 border-2'
return (
<span
className={cn(
'inline-block shrink-0 rounded-full border-yellow-500 border-t-transparent animate-spin',
ring
)}
aria-hidden
/>
)
}
export function CursorIcon(): JSX.Element {
return (
<svg viewBox="0 0 16 16" aria-hidden focusable="false">
<path
d="M2 1.5 L2 12 L5 9 L7.2 14.5 L9.5 13.6 L7.3 8 L11.5 8 Z"
className="fill-background stroke-foreground"
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
)
}
export function MailGlyph(): JSX.Element {
return (
<svg viewBox="0 0 24 24" aria-hidden width="9" height="9" fill="none" strokeWidth={1.6}>
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" />
<path d="M3 7l9 6 9-6" stroke="currentColor" />
</svg>
)
}

View File

@ -32,14 +32,14 @@ describe('OnboardingFlow', () => {
)
expect(html).toContain('Explore Orca')
expect(html).toContain('Preview the core workflow.')
expect(html).toContain('Run agents in isolated worktrees.')
expect(html).toContain('Orchestrate agents to finish larger tasks.')
expect(html).toContain('Start tasks from GitHub or Linear.')
expect(html).toContain('Send webpage elements to agents from the Orca browser.')
expect(html).not.toContain('Write and preview Markdown.')
expect(html).toContain('items-start')
expect(html).toContain('text-left')
expect(html).toContain('Take a 60-second tour of Orca&#x27;s advanced features.')
expect(html).toContain('Take the tour')
// Why: the prior intro carried a redundant lead, a four-item checklist, and
// a help-menu footnote. The tour animation already conveys all of that, so
// the body is now just the preview + a single CTA — guard against drift.
expect(html).not.toContain('Preview the core workflow.')
expect(html).not.toContain('Run agents in isolated worktrees.')
expect(html).not.toContain('Available later under Help')
expect(html).toContain('Continue')
expect(html).toContain('Skip to project setup')
expect(html).not.toContain('Skip the tour')

View File

@ -40,7 +40,7 @@ const stepCopy = {
},
tour: {
title: 'Explore Orca',
subtitle: ''
subtitle: "Take a 60-second tour of Orca's advanced features."
},
repo: {
title: 'Point Orca at some code',

View File

@ -1,19 +1,12 @@
import type { JSX } from 'react'
import { flushSync } from 'react-dom'
import { ArrowRight, Check } from 'lucide-react'
import { ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth'
import { FeatureTourPreview } from '../feature-wall/FeatureTourPreview'
import { FeatureWallTourSurface } from '../feature-wall/FeatureWallTourSurface'
import { usePrefersReducedMotion } from '../feature-wall/feature-wall-modal-helpers'
const TOUR_LEARNING_POINTS: readonly string[] = [
'Run agents in isolated worktrees.',
'Orchestrate agents to finish larger tasks.',
'Start tasks from GitHub or Linear.',
'Send webpage elements to agents from the Orca browser.'
]
type OnboardingTourStepProps = {
tourStarted: boolean
busyLabel: string | null
@ -100,41 +93,12 @@ export function OnboardingTourStep({
}
return (
<div className="flex h-full min-h-[430px] flex-col">
<div className="grid w-full grid-cols-1 items-start gap-10 md:grid-cols-[1fr_minmax(0,340px)]">
<div className="flex flex-col gap-4">
<p className="text-sm font-medium text-foreground">Preview the core workflow.</p>
<ul className="flex flex-col gap-2.5">
{TOUR_LEARNING_POINTS.map((point) => (
<li key={point} className="flex items-start gap-3">
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-foreground/[0.06] text-foreground">
<Check className="size-2.5" strokeWidth={3} />
</span>
<span className="text-sm leading-snug text-foreground">{point}</span>
</li>
))}
</ul>
</div>
<div className="flex w-full flex-col gap-3">
<FeatureTourPreview className="w-full" />
<div className="rounded-lg border border-border bg-muted/40 p-3">
<Button
variant="default"
onClick={handleStartTour}
disabled={Boolean(busyLabel)}
className="w-full gap-2"
>
Take the tour
<ArrowRight className="size-4" />
</Button>
<div className="mt-2 text-center text-xs text-muted-foreground">~ 60 seconds</div>
</div>
</div>
</div>
<p className="mt-auto max-w-[560px] text-left text-xs leading-relaxed text-muted-foreground">
Available later under Help &gt; Explore Orca.
</p>
<div className="flex flex-col gap-5">
<FeatureTourPreview />
<Button onClick={handleStartTour} disabled={Boolean(busyLabel)} className="gap-2 self-start">
Take the tour
<ArrowRight className="size-4" />
</Button>
</div>
)
}