| 35 | 35 | | |
| 36 | 36 | | import { AI_PROVIDER_GROUPS, AI_PROVIDER_LOOKS, aiProvidersIn } from "../lib/ai-providers"; |
| 37 | 37 | | import { autoOffered, defaultModel, modelOptions } from "../lib/ask-model"; |
| 38 | + | import { cn } from "../lib/cn"; |
| 38 | 39 | | import { |
| 39 | 40 | | type OnboardingStepView, |
| 40 | 41 | | type OnboardingView, |
| ⋯ |
| 154 | 155 | | |
| 155 | 156 | | return ( |
| 156 | 157 | | <Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}> |
| 157 | | − | <DialogContent size="split" aria-describedby="onboarding-about"> |
| 158 | + | <DialogContent |
| 159 | + | size="split" |
| 160 | + | aria-describedby="onboarding-about" |
| 161 | + | onOpenAutoFocus={(event) => { |
| 162 | + | // On a phone the sheet itself takes focus, not the first step's |
| 163 | + | // segment (a ring for no reason) or a field (the keyboard). |
| 164 | + | if (!matchMedia("(max-width: 639px)").matches) return; |
| 165 | + | event.preventDefault(); |
| 166 | + | (event.currentTarget as HTMLElement).focus(); |
| 167 | + | }} |
| 168 | + | > |
| 158 | 169 | | {view ? ( |
| 159 | 170 | | <Guide view={view} step={current} onStep={setStep} onFinish={close} /> |
| 160 | 171 | | ) : ( |
| ⋯ |
| 240 | 251 | | </p> |
| 241 | 252 | | </aside> |
| 242 | 253 | | |
| 243 | | − | {/* The step at hand. */} |
| 244 | | − | <section className="flex min-h-0 flex-col"> |
| 245 | | − | <div className="space-y-3 px-4 pt-5 sm:hidden"> |
| 246 | | − | <p className="text-xs text-muted tabular-nums"> |
| 247 | | − | Getting started · Step {index + 1} of {steps.length} |
| 248 | | − | </p> |
| 249 | | − | <Progress value={progress.percent} aria-label="Setup progress" indicatorClassName={done ? "bg-success" : undefined} /> |
| 250 | | − | </div> |
| 251 | | − | <div className="min-h-0 grow overflow-y-auto overscroll-contain p-6 max-sm:px-4 max-sm:pt-4"> |
| 254 | + | {/* The step at hand. On a touch screen its buttons are a finger's |
| 255 | + | size (`h-8` and `h-7` ones grow to 40 and 36 pixels). */} |
| 256 | + | <section className="flex min-h-0 flex-col pointer-coarse:[&_[data-slot=button].h-7]:h-9 pointer-coarse:[&_[data-slot=button].h-8]:h-10 pointer-coarse:**:[[role=tab]]:py-2"> |
| 257 | + | <StepStrip view={view} index={index} onStep={onStep} done={done} /> |
| 258 | + | {/* Keyed by step, so each one starts at its top. */} |
| 259 | + | <div key={step} className="min-h-0 grow overflow-y-auto overscroll-contain p-6 max-sm:px-4 max-sm:pt-5"> |
| 252 | 260 | | {steps.map((s) => ( |
| 253 | 261 | | <TabsContent key={s.key} value={s.key} className="mt-0 space-y-5"> |
| 254 | 262 | | <header className="space-y-2 pr-8"> |
| ⋯ |
| 284 | 292 | | ); |
| 285 | 293 | | } |
| 286 | 294 | | |
| 295 | + | /** |
| 296 | + | * A phone's rail: where the guide stands, as one segment per step to tap |
| 297 | + | * to go to it (green done, lavender here, dimmed skipped), under the |
| 298 | + | * workspace's name and the step's place. |
| 299 | + | */ |
| 300 | + | function StepStrip({ |
| 301 | + | view, |
| 302 | + | index, |
| 303 | + | onStep, |
| 304 | + | done, |
| 305 | + | }: { |
| 306 | + | view: OnboardingView; |
| 307 | + | index: number; |
| 308 | + | onStep: (step: OnboardingStep) => void; |
| 309 | + | done: boolean; |
| 310 | + | }) { |
| 311 | + | const { steps } = view; |
| 312 | + | const progress = progressOf(steps); |
| 313 | + | return ( |
| 314 | + | <nav aria-label="Steps" className="border-b border-line px-4 pt-4 pr-14 sm:hidden"> |
| 315 | + | <p className="flex min-w-0 items-baseline gap-1.5 text-xs text-muted tabular-nums"> |
| 316 | + | <span className="truncate font-medium text-fg">{view.name}</span> |
| 317 | + | <span aria-hidden="true">·</span> |
| 318 | + | <span className="shrink-0"> |
| 319 | + | {done ? "All set" : `Step ${index + 1} of ${steps.length} · ${progress.settled} done`} |
| 320 | + | </span> |
| 321 | + | </p> |
| 322 | + | <ol className="-mx-0.5 flex"> |
| 323 | + | {steps.map((s, i) => ( |
| 324 | + | <li key={s.key} className="min-w-0 grow"> |
| 325 | + | <button |
| 326 | + | type="button" |
| 327 | + | onClick={() => onStep(s.key)} |
| 328 | + | aria-current={i === index ? "step" : undefined} |
| 329 | + | aria-label={`${s.title}: ${s.done ? "done" : s.skipped ? "skipped" : "to do"}`} |
| 330 | + | className="group flex h-10 w-full items-center px-0.5 outline-none" |
| 331 | + | > |
| 332 | + | <span |
| 333 | + | className={cn( |
| 334 | + | "h-1.5 w-full rounded-full transition-[background-color,height] group-focus-visible:ring-2 group-focus-visible:ring-accent/50", |
| 335 | + | s.done ? "bg-success" : s.skipped ? "bg-line" : "bg-line-strong", |
| 336 | + | i === index && (s.done ? "h-2" : "h-2 bg-accent"), |
| 337 | + | i !== index && s.done && "opacity-60", |
| 338 | + | )} |
| 339 | + | /> |
| 340 | + | </button> |
| 341 | + | </li> |
| 342 | + | ))} |
| 343 | + | </ol> |
| 344 | + | </nav> |
| 345 | + | ); |
| 346 | + | } |
| 347 | + | |
| 287 | 348 | | /** Skip, back and next; Finish when there is nothing left, or on the last step. */ |
| 288 | 349 | | function Footer({ |
| 289 | 350 | | view, |
| ⋯ |
| 336 | 397 | | Back |
| 337 | 398 | | </Button> |
| 338 | 399 | | )} |
| 400 | + | {/* All done, the steps are still there to look back over. */} |
| 401 | + | {done && !last && ( |
| 402 | + | <Button variant="outline" size="sm" onClick={next}> |
| 403 | + | Next |
| 404 | + | <ArrowRight size={14} /> |
| 405 | + | </Button> |
| 406 | + | )} |
| 339 | 407 | | {done || last ? ( |
| 340 | 408 | | <Button variant="accent" size="sm" onClick={onFinish}> |
| 341 | 409 | | {done ? "Finish" : "Done for now"} |
| ⋯ |
| 425 | 493 | | <AiProviderMark provider={connection.provider} size={28} /> |
| 426 | 494 | | <div className="min-w-0 grow"> |
| 427 | 495 | | <p className="truncate text-sm font-medium">{connection.name}</p> |
| 428 | | − | <p className="truncate text-xs text-muted"> |
| 496 | + | <p className="text-xs text-muted sm:truncate"> |
| 429 | 497 | | {[AI_PROVIDERS[connection.provider].label, connection.models.length > 0 && `${connection.models.length} models`, connection.secretHint && `key ${connection.secretHint}`] |
| 430 | 498 | | .filter(Boolean) |
| 431 | 499 | | .join(" · ")} |
| ⋯ |
| 697 | 765 | | <li className="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-3 gap-y-2"> |
| 698 | 766 | | <Badge shape="mark" className="tabular-nums">{n}</Badge> |
| 699 | 767 | | <p className="self-center text-sm font-medium">{title}</p> |
| 700 | | − | <div className="col-start-2 space-y-2">{children}</div> |
| 768 | + | <div className="col-start-2 space-y-2 max-sm:col-span-2 max-sm:col-start-1">{children}</div> |
| 701 | 769 | | </li> |
| 702 | 770 | | ); |
| 703 | 771 | | } |