Animation Smorgasbord (#15637)

Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Kit Langton
2026-03-02 17:24:32 -05:00
committed by GitHub
parent 78069369e2
commit 9d7852b5c3
62 changed files with 5231 additions and 710 deletions

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