mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 20:51:17 +00:00
81 lines
2.5 KiB
TypeScript
81 lines
2.5 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useSyncExternalStore } from "react"
|
|
|
|
import { play } from "@/lib/sound"
|
|
import { cn } from "@/lib/utils"
|
|
import type { OutputLine, OutputTone } from "@/modules/landingPage/lib/tools"
|
|
|
|
// Slow enough that each typewriter tick is audible on its own
|
|
const LINE_DELAY_MS = 55
|
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
|
|
const toneClass: Record<OutputTone, string> = {
|
|
default: "text-white",
|
|
muted: "text-ink-400",
|
|
success: "text-signal-success",
|
|
error: "text-signal-error",
|
|
}
|
|
|
|
function subscribeReducedMotion(onChange: () => void) {
|
|
const query = window.matchMedia(REDUCED_MOTION)
|
|
query.addEventListener("change", onChange)
|
|
return () => query.removeEventListener("change", onChange)
|
|
}
|
|
|
|
function usePrefersReducedMotion() {
|
|
return useSyncExternalStore(
|
|
subscribeReducedMotion,
|
|
() => window.matchMedia(REDUCED_MOTION).matches,
|
|
() => false
|
|
)
|
|
}
|
|
|
|
/** Terminal-style output that appears line by line (motion #7). */
|
|
export function ToolOutput({
|
|
lines,
|
|
placeholder,
|
|
}: {
|
|
lines: OutputLine[] | null
|
|
placeholder: string
|
|
}) {
|
|
const reduced = usePrefersReducedMotion()
|
|
const [reveal, setReveal] = useState({ lines, visible: 0 })
|
|
|
|
// A new result restarts the reveal (state adjusted during render, not in an effect)
|
|
if (reveal.lines !== lines) setReveal({ lines, visible: 0 })
|
|
|
|
useEffect(() => {
|
|
if (!lines || reduced) return
|
|
let shown = 0
|
|
const interval = window.setInterval(() => {
|
|
shown += 1
|
|
play("tick")
|
|
setReveal({ lines, visible: shown })
|
|
if (shown >= lines.length) window.clearInterval(interval)
|
|
}, LINE_DELAY_MS)
|
|
return () => window.clearInterval(interval)
|
|
}, [lines, reduced])
|
|
|
|
const visible = reduced ? (lines?.length ?? 0) : reveal.visible
|
|
|
|
return (
|
|
<div className="min-h-44 overflow-x-auto rounded-xl border border-white/10 bg-black/40 p-4 font-mono text-label leading-relaxed">
|
|
{/* Announce the complete result once, not every single line */}
|
|
<p className="sr-only" aria-live="polite">
|
|
{lines ? lines.slice(1).map((line) => line.text).join(". ") : ""}
|
|
</p>
|
|
{lines ? (
|
|
<pre aria-hidden="true" className="whitespace-pre">
|
|
{lines.slice(0, visible).map((line, index) => (
|
|
<span key={index} className={cn("block", toneClass[line.tone ?? "default"])}>
|
|
{line.text}
|
|
</span>
|
|
))}
|
|
</pre>
|
|
) : (
|
|
<p className="text-ink-400">{placeholder}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|