mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-15 13:14:35 +00:00
855 lines
28 KiB
TypeScript
855 lines
28 KiB
TypeScript
// @ts-nocheck
|
|
import { createEffect, on, onMount, onCleanup } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
import { TextShimmer } from "./text-shimmer"
|
|
import { TextReveal } from "./text-reveal"
|
|
|
|
export default {
|
|
title: "UI/ThinkingHeading",
|
|
id: "components-thinking-heading",
|
|
tags: ["autodocs"],
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
component: `### Overview
|
|
Playground for animating the secondary heading beside "Thinking".
|
|
|
|
Uses TextReveal for the production heading animation with tunable
|
|
duration, travel, bounce, and fade controls.`,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
const HEADINGS = [
|
|
"Planning key generation details",
|
|
"Analyzing error handling",
|
|
undefined,
|
|
"Reviewing authentication flow",
|
|
"Considering edge cases",
|
|
"Evaluating performance",
|
|
"Structuring the response",
|
|
"Checking type safety",
|
|
"Designing the API surface",
|
|
"Mapping dependencies",
|
|
"Outlining test strategy",
|
|
]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSS
|
|
//
|
|
// Custom properties driven by sliders:
|
|
// --h-duration transition duration (e.g. "600ms")
|
|
// --h-duration-raw unitless number for calc (e.g. "600")
|
|
// --h-blur blur radius (e.g. "4px")
|
|
// --h-travel vertical travel distance (e.g. "18px")
|
|
// --h-spring full cubic-bezier for movement (set from bounce slider)
|
|
// --h-spring-soft softer version for width transitions
|
|
// --h-mask-size fade depth at top/bottom of odometer mask
|
|
// --h-mask-pad base padding-block on odometer track
|
|
// --h-mask-height extra vertical mask area per side
|
|
// --h-mask-bg background color for fade overlays
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STYLES = `
|
|
/* ── shared base ────────────────────────────────────────────────── */
|
|
[data-variant] {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
|
|
[data-variant] [data-slot="track"] {
|
|
display: grid;
|
|
overflow: visible;
|
|
min-height: 20px;
|
|
justify-items: start;
|
|
align-items: center;
|
|
transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1));
|
|
}
|
|
|
|
[data-variant] [data-slot="entering"],
|
|
[data-variant] [data-slot="leaving"] {
|
|
grid-area: 1 / 1;
|
|
line-height: 20px;
|
|
white-space: nowrap;
|
|
justify-self: start;
|
|
}
|
|
|
|
/* kill transitions before fonts are ready */
|
|
[data-variant][data-ready="false"] [data-slot="track"],
|
|
[data-variant][data-ready="false"] [data-slot="entering"],
|
|
[data-variant][data-ready="false"] [data-slot="leaving"] {
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
|
|
/* ── 1. spring-up ───────────────────────────────────────────────── *
|
|
* New text rises from below, old text exits upward. */
|
|
|
|
[data-variant="spring-up"] [data-slot="entering"],
|
|
[data-variant="spring-up"] [data-slot="leaving"] {
|
|
transition-property: transform, opacity, filter;
|
|
transition-duration:
|
|
var(--h-duration, 600ms),
|
|
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
|
|
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
|
|
transition-timing-function: var(--h-spring), ease-out, ease-out;
|
|
}
|
|
[data-variant="spring-up"] [data-slot="entering"] {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
}
|
|
[data-variant="spring-up"] [data-slot="leaving"] {
|
|
transform: translateY(calc(var(--h-travel, 18px) * -1));
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 0px));
|
|
}
|
|
[data-variant="spring-up"][data-swapping="true"] [data-slot="entering"] {
|
|
transform: translateY(var(--h-travel, 18px));
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 0px));
|
|
transition-duration: 0ms !important;
|
|
}
|
|
[data-variant="spring-up"][data-swapping="true"] [data-slot="leaving"] {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
|
|
/* ── 2. spring-down ─────────────────────────────────────────────── *
|
|
* New text drops from above, old text exits downward. */
|
|
|
|
[data-variant="spring-down"] [data-slot="entering"],
|
|
[data-variant="spring-down"] [data-slot="leaving"] {
|
|
transition-property: transform, opacity, filter;
|
|
transition-duration:
|
|
var(--h-duration, 600ms),
|
|
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
|
|
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
|
|
transition-timing-function: var(--h-spring), ease-out, ease-out;
|
|
}
|
|
[data-variant="spring-down"] [data-slot="entering"] {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
}
|
|
[data-variant="spring-down"] [data-slot="leaving"] {
|
|
transform: translateY(var(--h-travel, 18px));
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 0px));
|
|
}
|
|
[data-variant="spring-down"][data-swapping="true"] [data-slot="entering"] {
|
|
transform: translateY(calc(var(--h-travel, 18px) * -1));
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 0px));
|
|
transition-duration: 0ms !important;
|
|
}
|
|
[data-variant="spring-down"][data-swapping="true"] [data-slot="leaving"] {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
|
|
/* ── 3. spring-pop ──────────────────────────────────────────────── *
|
|
* Scale + slight vertical shift + blur. Playful, bouncy. */
|
|
|
|
[data-variant="spring-pop"] [data-slot="entering"],
|
|
[data-variant="spring-pop"] [data-slot="leaving"] {
|
|
transition-property: transform, opacity, filter;
|
|
transition-duration:
|
|
var(--h-duration, 600ms),
|
|
calc(var(--h-duration-raw, 600) * 0.55 * 1ms),
|
|
calc(var(--h-duration-raw, 600) * 0.55 * 1ms);
|
|
transition-timing-function: var(--h-spring), ease-out, ease-out;
|
|
transform-origin: left center;
|
|
}
|
|
[data-variant="spring-pop"] [data-slot="entering"] {
|
|
transform: translateY(0) scale(1);
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
}
|
|
[data-variant="spring-pop"] [data-slot="leaving"] {
|
|
transform: translateY(calc(var(--h-travel, 18px) * -0.35)) scale(0.92);
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 3px));
|
|
}
|
|
[data-variant="spring-pop"][data-swapping="true"] [data-slot="entering"] {
|
|
transform: translateY(calc(var(--h-travel, 18px) * 0.35)) scale(0.92);
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 3px));
|
|
transition-duration: 0ms !important;
|
|
}
|
|
[data-variant="spring-pop"][data-swapping="true"] [data-slot="leaving"] {
|
|
transform: translateY(0) scale(1);
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
|
|
/* ── 4. spring-blur ─────────────────────────────────────────────── *
|
|
* Pure crossfade with heavy blur. No vertical movement. *
|
|
* Width still animates with spring. */
|
|
|
|
[data-variant="spring-blur"] [data-slot="entering"],
|
|
[data-variant="spring-blur"] [data-slot="leaving"] {
|
|
transition-property: opacity, filter;
|
|
transition-duration:
|
|
calc(var(--h-duration-raw, 600) * 0.75 * 1ms),
|
|
var(--h-duration, 600ms);
|
|
transition-timing-function: ease-out, var(--h-spring-soft);
|
|
}
|
|
[data-variant="spring-blur"] [data-slot="entering"] {
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
}
|
|
[data-variant="spring-blur"] [data-slot="leaving"] {
|
|
opacity: 0;
|
|
filter: blur(calc(var(--h-blur, 4px) * 2));
|
|
}
|
|
[data-variant="spring-blur"][data-swapping="true"] [data-slot="entering"] {
|
|
opacity: 0;
|
|
filter: blur(calc(var(--h-blur, 4px) * 2));
|
|
transition-duration: 0ms !important;
|
|
}
|
|
[data-variant="spring-blur"][data-swapping="true"] [data-slot="leaving"] {
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
|
|
/* ── 5. odometer ──────────────────────────────────────────────── *
|
|
* Both texts scroll vertically through a clipped track. *
|
|
* *
|
|
* overflow:hidden clips at the padding-box edge. *
|
|
* mask-image fades to transparent at that same edge. *
|
|
* Result: content is invisible at the clip boundary → no hard *
|
|
* edge ever visible. Padding + mask height extend the clip area *
|
|
* so text has room to travel through the gradient fade zone. *
|
|
* *
|
|
* Uses transparent→white which works in both alpha & luminance *
|
|
* mask modes (transparent=hidden, white=visible in both). */
|
|
|
|
[data-variant="odometer"] [data-slot="track"] {
|
|
--h-mask-stop: min(var(--h-mask-size, 20px), calc(50% - 0.5px));
|
|
--h-odo-shift: calc(
|
|
100% + var(--h-travel, 18px) + var(--h-mask-height, 0px) + max(calc(var(--h-mask-pad, 28px) - 28px), 0px)
|
|
);
|
|
position: relative;
|
|
align-items: stretch;
|
|
overflow: hidden;
|
|
padding-block: calc(var(--h-mask-pad, 28px) + var(--h-mask-height, 0px));
|
|
margin-block: calc((var(--h-mask-pad, 28px) + var(--h-mask-height, 0px)) * -1);
|
|
-webkit-mask-image: linear-gradient(
|
|
to bottom,
|
|
transparent 0px,
|
|
white var(--h-mask-stop),
|
|
white calc(100% - var(--h-mask-stop)),
|
|
transparent 100%
|
|
);
|
|
mask-image: linear-gradient(
|
|
to bottom,
|
|
transparent 0px,
|
|
white var(--h-mask-stop),
|
|
white calc(100% - var(--h-mask-stop)),
|
|
transparent 100%
|
|
);
|
|
transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1));
|
|
}
|
|
|
|
/* on swap, jump width instantly to the max of both texts */
|
|
[data-variant="odometer"][data-swapping="true"] [data-slot="track"] {
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
[data-variant="odometer"] [data-slot="entering"],
|
|
[data-variant="odometer"] [data-slot="leaving"] {
|
|
transition-property: transform;
|
|
transition-duration: var(--h-duration, 600ms);
|
|
transition-timing-function: var(--h-spring);
|
|
opacity: 1;
|
|
}
|
|
/* settled: entering in view, leaving pushed below */
|
|
[data-variant="odometer"] [data-slot="entering"] {
|
|
transform: translateY(0);
|
|
}
|
|
[data-variant="odometer"] [data-slot="leaving"] {
|
|
transform: translateY(var(--h-odo-shift));
|
|
}
|
|
/* swapping: snap entering above, leaving in-place */
|
|
[data-variant="odometer"][data-swapping="true"] [data-slot="entering"] {
|
|
transform: translateY(calc(var(--h-odo-shift) * -1));
|
|
transition-duration: 0ms !important;
|
|
}
|
|
[data-variant="odometer"][data-swapping="true"] [data-slot="leaving"] {
|
|
transform: translateY(0);
|
|
transition-duration: 0ms !important;
|
|
}
|
|
|
|
/* ── odometer + blur ──────────────────────────────────────────── *
|
|
* Optional: adds opacity + blur transitions on top of the *
|
|
* positional odometer movement. */
|
|
|
|
[data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"],
|
|
[data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] {
|
|
transition-property: transform, opacity, filter;
|
|
transition-duration:
|
|
var(--h-duration, 600ms),
|
|
calc(var(--h-duration-raw, 600) * 0.6 * 1ms),
|
|
calc(var(--h-duration-raw, 600) * 0.5 * 1ms);
|
|
}
|
|
[data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"] {
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
}
|
|
[data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] {
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 4px));
|
|
}
|
|
[data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="entering"] {
|
|
opacity: 0;
|
|
filter: blur(var(--h-blur, 4px));
|
|
}
|
|
[data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="leaving"] {
|
|
opacity: 1;
|
|
filter: blur(0);
|
|
}
|
|
|
|
/* ── debug: show fade zones ───────────────────────────────────── */
|
|
[data-variant="odometer"][data-debug="true"] [data-slot="track"] {
|
|
outline: 1px dashed rgba(255, 0, 0, 0.6);
|
|
}
|
|
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::before,
|
|
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
height: var(--h-mask-stop);
|
|
pointer-events: none;
|
|
}
|
|
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::before {
|
|
top: 0;
|
|
background: linear-gradient(to bottom, rgba(255, 0, 0, 0.3), transparent);
|
|
}
|
|
[data-variant="odometer"][data-debug="true"] [data-slot="track"]::after {
|
|
bottom: 0;
|
|
background: linear-gradient(to top, rgba(255, 0, 0, 0.3), transparent);
|
|
}
|
|
|
|
|
|
/* ── slider styling ─────────────────────────────────────────────── */
|
|
input[type="range"].heading-slider {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 140px;
|
|
height: 4px;
|
|
border-radius: 2px;
|
|
background: var(--color-divider, #444);
|
|
outline: none;
|
|
}
|
|
input[type="range"].heading-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
background: var(--color-accent, #58f);
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
`
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Animated heading component
|
|
//
|
|
// Width is measured via scrollWidth (NOT Range.getBoundingClientRect) because
|
|
// getBoundingClientRect includes CSS transforms — so scale(0.92) during the
|
|
// swap phase would measure 92% of the real width and permanently clip text.
|
|
// scrollWidth returns the layout/intrinsic width, unaffected by transforms.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AnimatedHeading(props) {
|
|
const [state, setState] = createStore({
|
|
current: props.text,
|
|
leaving: undefined,
|
|
width: "auto",
|
|
ready: false,
|
|
swapping: false,
|
|
})
|
|
const current = () => state.current
|
|
const leaving = () => state.leaving
|
|
const width = () => state.width
|
|
const ready = () => state.ready
|
|
const swapping = () => state.swapping
|
|
let enterRef
|
|
let leaveRef
|
|
let containerRef
|
|
let frame
|
|
|
|
const measureEnter = () => enterRef?.scrollWidth ?? 0
|
|
const measureLeave = () => leaveRef?.scrollWidth ?? 0
|
|
const widen = (px) => {
|
|
if (px <= 0) return
|
|
const w = Number.parseFloat(width())
|
|
if (Number.isFinite(w) && px <= w) return
|
|
setState("width", `${px}px`)
|
|
}
|
|
|
|
const measure = () => {
|
|
if (!current()) {
|
|
setState("width", "0px")
|
|
return
|
|
}
|
|
const px = measureEnter()
|
|
if (px > 0) setState("width", `${px}px`)
|
|
}
|
|
|
|
createEffect(
|
|
on(
|
|
() => props.text,
|
|
(next, prev) => {
|
|
if (next === prev) return
|
|
setState("swapping", true)
|
|
setState("leaving", prev)
|
|
setState("current", next)
|
|
|
|
if (frame) cancelAnimationFrame(frame)
|
|
frame = requestAnimationFrame(() => {
|
|
// For odometer keep width as a grow-only max so heading never shrinks.
|
|
if (props.variant === "odometer") {
|
|
const enterW = measureEnter()
|
|
const leaveW = measureLeave()
|
|
widen(Math.max(enterW, leaveW))
|
|
containerRef?.offsetHeight // reflow with max width + swap positions
|
|
setState("swapping", false)
|
|
} else {
|
|
containerRef?.offsetHeight
|
|
setState("swapping", false)
|
|
measure()
|
|
}
|
|
frame = undefined
|
|
})
|
|
},
|
|
),
|
|
)
|
|
|
|
onMount(() => {
|
|
measure()
|
|
document.fonts?.ready.finally(() => {
|
|
measure()
|
|
requestAnimationFrame(() => setState("ready", true))
|
|
})
|
|
})
|
|
|
|
onCleanup(() => {
|
|
if (frame) cancelAnimationFrame(frame)
|
|
})
|
|
|
|
return (
|
|
<span
|
|
ref={containerRef}
|
|
data-variant={props.variant}
|
|
data-ready={ready()}
|
|
data-swapping={swapping()}
|
|
data-debug={props.debug ? "true" : undefined}
|
|
data-odo-blur={props.odoBlur ? "true" : undefined}
|
|
>
|
|
<span data-slot="track" style={{ width: width() }}>
|
|
<span data-slot="entering" ref={enterRef}>
|
|
{current() ?? "\u00A0"}
|
|
</span>
|
|
<span data-slot="leaving" ref={leaveRef}>
|
|
{leaving() ?? "\u00A0"}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Button / layout styles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const btn = (accent) => ({
|
|
padding: "6px 14px",
|
|
"border-radius": "6px",
|
|
border: "1px solid var(--color-divider, #333)",
|
|
background: accent ? "var(--color-danger-fill, #c33)" : "var(--color-fill-element, #222)",
|
|
color: "var(--color-text, #eee)",
|
|
cursor: "pointer",
|
|
"font-size": "13px",
|
|
})
|
|
|
|
const smallBtn = (active) => ({
|
|
padding: "4px 12px",
|
|
"border-radius": "6px",
|
|
border: active ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
|
|
background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
|
color: "var(--color-text, #eee)",
|
|
cursor: "pointer",
|
|
"font-size": "12px",
|
|
})
|
|
|
|
const sliderLabel = {
|
|
"font-size": "11px",
|
|
"font-family": "monospace",
|
|
color: "var(--color-text-weak, #666)",
|
|
"min-width": "70px",
|
|
"flex-shrink": "0",
|
|
"text-align": "right",
|
|
}
|
|
|
|
const sliderValue = {
|
|
"font-family": "monospace",
|
|
"font-size": "11px",
|
|
color: "var(--color-text-weak, #aaa)",
|
|
"min-width": "60px",
|
|
}
|
|
|
|
const cardLabel = {
|
|
"font-size": "11px",
|
|
"font-family": "monospace",
|
|
color: "var(--color-text-weak, #666)",
|
|
}
|
|
|
|
const thinkingRow = {
|
|
display: "flex",
|
|
"align-items": "center",
|
|
gap: "8px",
|
|
"min-width": "0",
|
|
"font-size": "14px",
|
|
"font-weight": "500",
|
|
"line-height": "20px",
|
|
"min-height": "20px",
|
|
color: "var(--text-weak, #aaa)",
|
|
}
|
|
|
|
const headingSlot = {
|
|
"min-width": "0",
|
|
overflow: "visible",
|
|
"white-space": "nowrap",
|
|
color: "var(--text-weaker, #888)",
|
|
"font-weight": "400",
|
|
}
|
|
|
|
const cardStyle = {
|
|
padding: "16px 20px",
|
|
"border-radius": "10px",
|
|
border: "1px solid var(--color-divider, #333)",
|
|
background: "var(--h-mask-bg, #1a1a1a)",
|
|
display: "grid",
|
|
gap: "8px",
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Variants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const VARIANTS: { key: string; label: string }[] = []
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Story
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const Playground = {
|
|
render: () => {
|
|
const [state, setState] = createStore({
|
|
heading: HEADINGS[0],
|
|
headingIndex: 0,
|
|
active: true,
|
|
cycling: false,
|
|
duration: 550,
|
|
blur: 2,
|
|
travel: 4,
|
|
bounce: 1.35,
|
|
maskSize: 12,
|
|
maskPad: 9,
|
|
maskHeight: 0,
|
|
debug: false,
|
|
odoBlur: false,
|
|
})
|
|
const heading = () => state.heading
|
|
const headingIndex = () => state.headingIndex
|
|
const active = () => state.active
|
|
const cycling = () => state.cycling
|
|
const duration = () => state.duration
|
|
const blur = () => state.blur
|
|
const travel = () => state.travel
|
|
const bounce = () => state.bounce
|
|
const maskSize = () => state.maskSize
|
|
const maskPad = () => state.maskPad
|
|
const maskHeight = () => state.maskHeight
|
|
const debug = () => state.debug
|
|
const odoBlur = () => state.odoBlur
|
|
let cycleTimer
|
|
|
|
const nextHeading = () => {
|
|
const next = (headingIndex() + 1) % HEADINGS.length
|
|
setState("headingIndex", next)
|
|
setState("heading", HEADINGS[next])
|
|
}
|
|
|
|
const prevHeading = () => {
|
|
const prev = (headingIndex() - 1 + HEADINGS.length) % HEADINGS.length
|
|
setState("headingIndex", prev)
|
|
setState("heading", HEADINGS[prev])
|
|
}
|
|
|
|
const toggleCycling = () => {
|
|
if (cycling()) {
|
|
clearTimeout(cycleTimer)
|
|
cycleTimer = undefined
|
|
setState("cycling", false)
|
|
return
|
|
}
|
|
setState("cycling", true)
|
|
const tick = () => {
|
|
if (!cycling()) return
|
|
nextHeading()
|
|
cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550))
|
|
}
|
|
cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550))
|
|
}
|
|
|
|
const clearHeading = () => {
|
|
setState("heading", undefined)
|
|
if (cycling()) {
|
|
clearTimeout(cycleTimer)
|
|
cycleTimer = undefined
|
|
setState("cycling", false)
|
|
}
|
|
}
|
|
|
|
onCleanup(() => {
|
|
if (cycleTimer) clearTimeout(cycleTimer)
|
|
})
|
|
|
|
const vars = () => ({
|
|
"--h-duration": `${duration()}ms`,
|
|
"--h-duration-raw": `${duration()}`,
|
|
"--h-blur": `${blur()}px`,
|
|
"--h-travel": `${travel()}px`,
|
|
"--h-spring": `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`,
|
|
"--h-spring-soft": `cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`,
|
|
"--h-mask-size": `${maskSize()}px`,
|
|
"--h-mask-pad": `${maskPad()}px`,
|
|
"--h-mask-height": `${maskHeight()}px`,
|
|
"--h-mask-bg": "#1a1a1a",
|
|
})
|
|
|
|
return (
|
|
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "820px", ...vars() }}>
|
|
<style>{STYLES}</style>
|
|
|
|
{/* ── Variant cards ─────────────────────────────────── */}
|
|
<div style={{ display: "grid", "grid-template-columns": "1fr", gap: "16px" }}>
|
|
<div style={cardStyle}>
|
|
<span style={cardLabel}>TextReveal (production)</span>
|
|
<span style={thinkingRow}>
|
|
<TextShimmer text="Thinking" active={active()} />
|
|
<span style={headingSlot}>
|
|
<TextReveal
|
|
text={heading()}
|
|
duration={duration()}
|
|
travel={25}
|
|
edge={17}
|
|
spring={`cubic-bezier(0.34, ${bounce()}, 0.64, 1)`}
|
|
springSoft={`cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`}
|
|
growOnly
|
|
/>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{VARIANTS.map((v) => (
|
|
<div style={cardStyle}>
|
|
<span style={cardLabel}>{v.label}</span>
|
|
<span style={thinkingRow}>
|
|
<TextShimmer text="Thinking" active={active()} />
|
|
<span style={headingSlot}>
|
|
<AnimatedHeading
|
|
text={heading()}
|
|
variant={v.key}
|
|
debug={v.key === "odometer" && debug()}
|
|
odoBlur={v.key === "odometer" && odoBlur()}
|
|
/>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Sliders ──────────────────────────────────────── */}
|
|
<div
|
|
style={{
|
|
"border-top": "1px solid var(--color-divider, #333)",
|
|
"padding-top": "16px",
|
|
display: "grid",
|
|
gap: "10px",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>duration</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={200}
|
|
max={1400}
|
|
step={50}
|
|
value={duration()}
|
|
onInput={(e) => setState("duration", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>{duration()}ms</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>blur</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={0}
|
|
max={16}
|
|
step={0.5}
|
|
value={blur()}
|
|
onInput={(e) => setState("blur", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>{blur()}px</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>travel</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={4}
|
|
max={120}
|
|
step={1}
|
|
value={travel()}
|
|
onInput={(e) => setState("travel", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>{travel()}px</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>bounce</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={1}
|
|
max={2.2}
|
|
step={0.05}
|
|
value={bounce()}
|
|
onInput={(e) => setState("bounce", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>
|
|
{bounce().toFixed(2)} {bounce() <= 1.05 ? "(none)" : bounce() >= 1.9 ? "(heavy)" : ""}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>mask</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={0}
|
|
max={50}
|
|
step={1}
|
|
value={maskSize()}
|
|
onInput={(e) => setState("maskSize", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>
|
|
{maskSize()}px {maskSize() === 0 ? "(hard)" : ""}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>mask pad</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={0}
|
|
max={60}
|
|
step={1}
|
|
value={maskPad()}
|
|
onInput={(e) => setState("maskPad", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>{maskPad()}px</span>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
|
<span style={sliderLabel}>mask height</span>
|
|
<input
|
|
type="range"
|
|
class="heading-slider"
|
|
min={0}
|
|
max={80}
|
|
step={1}
|
|
value={maskHeight()}
|
|
onInput={(e) => setState("maskHeight", Number(e.currentTarget.value))}
|
|
/>
|
|
<span style={sliderValue}>{maskHeight()}px</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Controls ─────────────────────────────────────── */}
|
|
<div style={{ display: "grid", gap: "12px" }}>
|
|
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
|
<button onClick={toggleCycling} style={btn(cycling())}>
|
|
{cycling() ? "Stop sim" : "Simulate jitter"}
|
|
</button>
|
|
<button onClick={prevHeading} style={btn()}>
|
|
Prev
|
|
</button>
|
|
<button onClick={nextHeading} style={btn()}>
|
|
Next
|
|
</button>
|
|
<button onClick={clearHeading} style={btn()}>
|
|
Clear
|
|
</button>
|
|
<button onClick={() => setState("active", (value) => !value)} style={smallBtn(active())}>
|
|
{active() ? "Shimmer: on" : "Shimmer: off"}
|
|
</button>
|
|
<button onClick={() => setState("debug", (value) => !value)} style={smallBtn(debug())}>
|
|
{debug() ? "Debug mask: on" : "Debug mask"}
|
|
</button>
|
|
<button onClick={() => setState("odoBlur", (value) => !value)} style={smallBtn(odoBlur())}>
|
|
{odoBlur() ? "Odo blur: on" : "Odo blur"}
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
|
{HEADINGS.map((h, i) => (
|
|
<button
|
|
onClick={() => {
|
|
setState("headingIndex", i)
|
|
setState("heading", h)
|
|
}}
|
|
style={smallBtn(headingIndex() === i)}
|
|
>
|
|
{h ?? "(no submessage)"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
"font-size": "11px",
|
|
color: "var(--color-text-weak, #888)",
|
|
"font-family": "monospace",
|
|
}}
|
|
>
|
|
heading: {heading() ?? "(none)"} · sim: {cycling() ? "on" : "off"} · bounce: {bounce().toFixed(2)} ·
|
|
odo-blur: {odoBlur() ? "on" : "off"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
}
|