mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-27 02:54:48 +00:00
Animation Smorgasbord (#15637)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
837
packages/ui/src/components/thinking-heading.stories.tsx
Normal file
837
packages/ui/src/components/thinking-heading.stories.tsx
Normal file
@@ -0,0 +1,837 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal, createEffect, on, onMount, onCleanup } from "solid-js"
|
||||
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 [current, setCurrent] = createSignal(props.text)
|
||||
const [leaving, setLeaving] = createSignal(undefined)
|
||||
const [width, setWidth] = createSignal("auto")
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [swapping, setSwapping] = createSignal(false)
|
||||
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
|
||||
setWidth(`${px}px`)
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
if (!current()) {
|
||||
setWidth("0px")
|
||||
return
|
||||
}
|
||||
const px = measureEnter()
|
||||
if (px > 0) setWidth(`${px}px`)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.text,
|
||||
(next, prev) => {
|
||||
if (next === prev) return
|
||||
setSwapping(true)
|
||||
setLeaving(prev)
|
||||
setCurrent(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
|
||||
setSwapping(false)
|
||||
} else {
|
||||
containerRef?.offsetHeight
|
||||
setSwapping(false)
|
||||
measure()
|
||||
}
|
||||
frame = undefined
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
measure()
|
||||
document.fonts?.ready.finally(() => {
|
||||
measure()
|
||||
requestAnimationFrame(() => setReady(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 [heading, setHeading] = createSignal(HEADINGS[0])
|
||||
const [headingIndex, setHeadingIndex] = createSignal(0)
|
||||
const [active, setActive] = createSignal(true)
|
||||
const [cycling, setCycling] = createSignal(false)
|
||||
let cycleTimer
|
||||
|
||||
// tunable params
|
||||
const [duration, setDuration] = createSignal(550)
|
||||
const [blur, setBlur] = createSignal(2)
|
||||
const [travel, setTravel] = createSignal(4)
|
||||
const [bounce, setBounce] = createSignal(1.35)
|
||||
const [maskSize, setMaskSize] = createSignal(12)
|
||||
const [maskPad, setMaskPad] = createSignal(9)
|
||||
const [maskHeight, setMaskHeight] = createSignal(0)
|
||||
const [debug, setDebug] = createSignal(false)
|
||||
const [odoBlur, setOdoBlur] = createSignal(false)
|
||||
|
||||
const nextHeading = () => {
|
||||
setHeadingIndex((i) => {
|
||||
const next = (i + 1) % HEADINGS.length
|
||||
setHeading(HEADINGS[next])
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const prevHeading = () => {
|
||||
setHeadingIndex((i) => {
|
||||
const prev = (i - 1 + HEADINGS.length) % HEADINGS.length
|
||||
setHeading(HEADINGS[prev])
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCycling = () => {
|
||||
if (cycling()) {
|
||||
clearTimeout(cycleTimer)
|
||||
cycleTimer = undefined
|
||||
setCycling(false)
|
||||
return
|
||||
}
|
||||
setCycling(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 = () => {
|
||||
setHeading(undefined)
|
||||
if (cycling()) {
|
||||
clearTimeout(cycleTimer)
|
||||
cycleTimer = undefined
|
||||
setCycling(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) => setDuration(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) => setBlur(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) => setTravel(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) => setBounce(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) => setMaskSize(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) => setMaskPad(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) => setMaskHeight(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={() => setActive((v) => !v)} style={smallBtn(active())}>
|
||||
{active() ? "Shimmer: on" : "Shimmer: off"}
|
||||
</button>
|
||||
<button onClick={() => setDebug((v) => !v)} style={smallBtn(debug())}>
|
||||
{debug() ? "Debug mask: on" : "Debug mask"}
|
||||
</button>
|
||||
<button onClick={() => setOdoBlur((v) => !v)} 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={() => {
|
||||
setHeadingIndex(i)
|
||||
setHeading(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>
|
||||
)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user