import type { ReactNode } from "react";
import { Box, Text, TextAttributes, useUiHost } from "../../ui";
import { useViewport } from "../../react/input";
import { blendHex } from "../../theme/colors";
import { useThemeColors } from "../../theme/theme-context";
import { t } from "../../i18n";
import { Button, ListView, type ListViewItem } from "../ui";
import type { ButtonProps } from "../ui/button";
import { TITLEBAR_OVERLAY_HEIGHT_PX } from "../layout/titlebar-overlay";
/**
* Spacing for the DOM card, in px. The card is a form the user fills in, not
* pane chrome, so it does not sit on the terminal cell grid: an 8px rhythm with
* one larger break before the footer.
*/
export const ONBOARDING_DESKTOP = {
padding: "26px 28px 24px",
radius: 10,
afterProgress: 22,
afterTitle: 6,
afterHeader: 20,
beforeFooter: 24,
buttonHeight: "28px",
} as const;
export function OnboardingModal({
children,
width = 66,
height = 18,
desktopWidth,
}: {
children: ReactNode;
/** Terminal card width in cells. */
width?: number;
/** Terminal card height in rows; the DOM card sizes to its content. */
height?: number;
/** CSS width for the DOM card when a step needs more than the default surface. */
desktopWidth?: string;
}) {
const colors = useThemeColors();
const desktop = useUiHost().kind === "desktop-web";
const viewport = useViewport();
if (desktop) {
return (
{children}
);
}
const overlayHeight = Math.max(1, viewport.height - 1);
const availableWidth = Math.max(1, viewport.width - 4);
const availableHeight = Math.max(1, overlayHeight - 1);
const cardWidth = Math.min(Math.max(42, Math.min(width, availableWidth)), availableWidth);
const cardHeight = Math.min(Math.max(10, Math.min(height, availableHeight)), availableHeight);
const top = Math.max(0, Math.floor((overlayHeight - cardHeight) / 2));
const left = Math.max(0, Math.floor((viewport.width - cardWidth) / 2));
return (
{children}
);
}
export function OnboardingCoach({
step,
title,
children,
actions,
}: {
step: string;
title: string;
children: ReactNode;
actions: ReactNode;
}) {
const colors = useThemeColors();
const desktop = useUiHost().kind === "desktop-web";
const viewport = useViewport();
if (desktop) {
return (
{step}
{title}
{children}
{actions}
);
}
const availableWidth = Math.max(1, viewport.width - 4);
const cardWidth = Math.min(Math.max(40, Math.min(54, availableWidth)), availableWidth);
const cardHeight = Math.min(12, Math.max(1, viewport.height - 3));
const right = viewport.width - cardWidth >= 2 ? 2 : 0;
return (
{step}
{title}
{children}
{actions}
);
}
export function OnboardingTitle({
step,
title,
titlePrefix,
titleSuffix,
description,
}: {
step?: string;
title: string;
titlePrefix?: ReactNode;
/** Short qualifier after the title, e.g. why a struck-through anchor price differs. */
titleSuffix?: string;
description?: string;
}) {
const colors = useThemeColors();
const desktop = useUiHost().kind === "desktop-web";
if (desktop) {
return (
{step ? (
{step}
) : null}
{titlePrefix}
{title}
{titleSuffix ? {titleSuffix} : null}
{description ? (
{description}
) : null}
);
}
return (
{step ? (
{step}
) : null}
{titlePrefix}
{title}
{titleSuffix ? {titleSuffix} : null}
{description ? (
<>
{description}
>
) : null}
);
}
export type OnboardingSectionId = "portfolio" | "cloud" | "pro";
export function OnboardingHeader({
active,
available,
onNavigate,
onDismiss,
dismissShortcut,
dismissing = false,
dismissDisabled = false,
showDismiss = true,
}: {
active?: OnboardingSectionId;
available?: Partial>;
onNavigate?: (section: OnboardingSectionId) => void;
onDismiss: () => void;
/** The key the wizard binds to Skip setup, shown on the button. */
dismissShortcut?: string;
dismissing?: boolean;
dismissDisabled?: boolean;
showDismiss?: boolean;
}) {
const colors = useThemeColors();
const desktop = useUiHost().kind === "desktop-web";
const sections: Array<{ id: OnboardingSectionId; label: string }> = [
{ id: "portfolio", label: t("Portfolio") },
{ id: "cloud", label: t("Gloom Cloud") },
{ id: "pro", label: t("Pro") },
];
if (!desktop) {
return (
{t("GLOOMBERB SETUP")}
{showDismiss ? (
) : null}
);
}
// Progress, not navigation: three segments, the current one lit, the ones
// behind it filled. A filled segment is still a way back.
const activeIndex = sections.findIndex((section) => section.id === active);
return (
{sections.map((section, index) => {
const state = index === activeIndex ? "current" : index < activeIndex ? "done" : "upcoming";
const clickable = state !== "current" && available?.[section.id] !== false && !!onNavigate;
const bar = state === "current"
? colors.borderFocused
: state === "done"
? colors.textDim
: blendHex(colors.border, colors.bg, 0.3);
const label = state === "current"
? colors.textBright
: state === "done"
? colors.text
: colors.textMuted;
return (
onNavigate?.(section.id) : undefined}
style={{ gap: 6, cursor: clickable ? "pointer" : "default" }}
>
{section.label}
);
})}
{showDismiss ? (
) : null}
);
}
/**
* Footer row. On the DOM renderer the hint sits on the left and the buttons
* on the right of the same line, so nothing floats. Terminal steps print
* their hints in the body and ignore `hint`.
*/
export function OnboardingActions({ children, hint }: { children: ReactNode; hint?: ReactNode }) {
const desktop = useUiHost().kind === "desktop-web";
const colors = useThemeColors();
if (desktop) {
return (
{typeof hint === "string" ? {hint} : hint}
{children}
);
}
return (
{children}
);
}
export function OnboardingButton(props: ButtonProps) {
const desktop = useUiHost().kind === "desktop-web";
return ;
}
export function OnboardingChoiceList({
items,
selectedIndex,
onSelect,
onActivate,
}: {
items: ListViewItem[];
selectedIndex: number;
onSelect: (index: number) => void;
onActivate: (index: number) => void;
}) {
const colors = useThemeColors();
const desktop = useUiHost().kind === "desktop-web";
if (!desktop) {
return (
{items.map((item, index) => {
const selected = index === selectedIndex;
const rowBg = selected ? colors.selected : colors.bg;
return (
{
if (item.disabled) return;
onSelect(index);
onActivate(index);
}}
>
{selected ? "▸ " : " "}
{item.label}
{item.detail ? {item.detail} : null}
{item.description ? (
{item.description}
) : null}
);
})}
);
}
return (
onActivate(index)}
surface="framed"
rowGap={0}
rowHeight={2}
selectOnHover
renderRow={(item, state) => (
{item.label}
{item.description ? (
{item.description}
) : null}
{item.detail ? (
{item.detail}
) : null}
)}
/>
);
}
export function OnboardingFeature({
title,
description,
}: {
title: string;
description: string;
}) {
const colors = useThemeColors();
const desktop = useUiHost().kind === "desktop-web";
if (!desktop) {
return (
·
{title}
{description}
);
}
return (
{title}
{description}
);
}