| 1 | import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; |
| 2 | |
| 3 | export const SPINNER_FRAMES = [ |
| 4 | "⠋", |
| 5 | "⠙", |
| 6 | "⠹", |
| 7 | "⠸", |
| 8 | "⠼", |
| 9 | "⠴", |
| 10 | "⠦", |
| 11 | "⠧", |
| 12 | "⠇", |
| 13 | "⠏", |
| 14 | ]; |
| 15 | |
| 16 | export 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. */ |
| 21 | const 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 | */ |
| 28 | export 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 | |
| 35 | export 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. */ |
| 49 | export 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 | |
| 79 | export 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 | |
| 176 | export 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 | } |