1import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
2
3export const SPINNER_FRAMES = [
4 "⠋",
5 "⠙",
6 "⠹",
7 "⠸",
8 "⠼",
9 "⠴",
10 "⠦",
11 "⠧",
12 "⠇",
13 "⠏",
14];
15
16export const prefersReducedMotion = () =>
17 typeof window !== "undefined" &&
18 window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19
20/** Pauses of at least this long are a demo holding a finished frame. */
21const HOLD_MS = 2000;
22
23/**
24 * Timeline pause for the scripted terminal demos. With reduced motion the
25 * script fast-forwards to its first finished frame and stays there: short
26 * steps resolve immediately and the first hold never resolves.
27 */
28export const sleep = (ms: number) =>
29 prefersReducedMotion()
30 ? ms >= HOLD_MS
31 ? new Promise<void>(() => {})
32 : Promise.resolve()
33 : new Promise<void>((r) => setTimeout(r, ms));
34
35export function useSpinner(active: boolean, intervalMs = 80): string {
36 const [i, setI] = useState(0);
37 useEffect(() => {
38 if (!active || prefersReducedMotion()) return;
39 const t = setInterval(
40 () => setI((v) => (v + 1) % SPINNER_FRAMES.length),
41 intervalMs,
42 );
43 return () => clearInterval(t);
44 }, [active, intervalMs]);
45 return SPINNER_FRAMES[i]!;
46}
47
48/** Run an async loop only once the element scrolls into view, and stop when it leaves. */
49export function useInViewLoop(
50 ref: React.RefObject<HTMLElement | null>,
51 run: (signal: { aborted: boolean }) => void | Promise<void>,
52) {
53 useEffect(() => {
54 const el = ref.current;
55 if (!el) return;
56 const signal = { aborted: false };
57 let started = false;
58 const obs = new IntersectionObserver(
59 (entries) => {
60 for (const e of entries) {
61 if (e.isIntersecting && !started) {
62 started = true;
63 void run(signal);
64 obs.disconnect();
65 }
66 }
67 },
68 { threshold: 0.2 },
69 );
70 obs.observe(el);
71 return () => {
72 signal.aborted = true;
73 obs.disconnect();
74 };
75 // eslint-disable-next-line react-hooks/exhaustive-deps
76 }, []);
77}
78
79export function TermChrome({
80 title,
81 badge,
82 badgeColor,
83 children,
84 bodyMinHeight,
85 maxLines,
86 bare,
87}: {
88 title: string;
89 badge?: string;
90 badgeColor?: string;
91 children: ReactNode;
92 bodyMinHeight?: number;
93 /**
94 * Locks the terminal body to exactly this many lines of content (computed
95 * from the body font-size × line-height + padding). Use in docs/embedded
96 * contexts where layout shift below the terminal must be avoided. Count
97 * the maximum number of visible lines the body ever renders. Takes
98 * precedence over `bodyMinHeight`.
99 */
100 maxLines?: number;
101 /**
102 * When true, omit the outer `.alc-term` wrapper and the dots/title header.
103 * Useful when this terminal is embedded inside another chrome (e.g. the
104 * mobile tab toggle that combines code + deploy into one card).
105 */
106 bare?: boolean;
107}) {
108 // `1lh` resolves to the computed line-height of `.alc-term__body`, so the
109 // reservation scales with font-size and browser zoom. Padding (28px = 14px
110 // top + 14px bottom) is fixed visual chrome; with `box-sizing: border-box`
111 // it's included in `height`.
112 const bodyStyle: CSSProperties | undefined =
113 maxLines !== undefined
114 ? {
115 boxSizing: "border-box",
116 height: `calc(${maxLines} * 1lh + 28px)`,
117 minHeight: `calc(${maxLines} * 1lh + 28px)`,
118 overflow: "hidden",
119 }
120 : bodyMinHeight
121 ? { minHeight: bodyMinHeight }
122 : undefined;
123 if (bare) {
124 return (
125 <pre className="alc-term__body" style={bodyStyle}>
126 {children}
127 </pre>
128 );
129 }
130 return (
131 // `data-nosnippet`: the simulated terminal is decoration, not prose. Left
132 // visible to crawlers it leaks into Google-generated snippets as chrome
133 // fragments ("~/my-appDEV", "○localhost:1337/ HMR").
134 <div className="alc-term not-content" data-nosnippet="">
135 <div className="alc-term__header">
136 <span
137 className="alc-code-block__dot"
138 style={{ background: "var(--alc-danger)" }}
139 />
140 <span
141 className="alc-code-block__dot"
142 style={{ background: "var(--alc-warn)" }}
143 />
144 <span
145 className="alc-code-block__dot"
146 style={{ background: "var(--alc-accent-bright)" }}
147 />
148 <span className="alc-term__title">{title}</span>
149 <span style={{ flex: 1 }} />
150 {badge && badgeColor && (
151 <span
152 style={{
153 fontFamily: "var(--alc-font-mono)",
154 fontSize: 10,
155 letterSpacing: "0.14em",
156 fontWeight: 700,
157 padding: "2px 8px",
158 borderRadius: 4,
159 color: badgeColor,
160 border: `1px solid ${badgeColor}`,
161 background: "transparent",
162 transition: "color 280ms ease, border-color 280ms ease",
163 }}
164 >
165 {badge}
166 </span>
167 )}
168 </div>
169 <pre className="alc-term__body" style={bodyStyle}>
170 {children}
171 </pre>
172 </div>
173 );
174}
175
176export function Line({
177 children,
178 style,
179}: {
180 children?: ReactNode;
181 style?: CSSProperties;
182}) {
183 return (
184 <div style={{ minHeight: "1.55em", whiteSpace: "pre", ...style }}>
185 {children}
186 </div>
187 );
188}