since3-website/modules/landingPage/ui/components/tools/tool-output.tsx
2026-09-25 00:45:33 +02:00

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>
)
}