{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"status-morph","type":"registry:ui","title":"StatusMorph","description":"A 1em SVG status icon that morphs between idle, loading, success, and error, closing its spinning ring into a drawn check or cross with a portalled live region announcing each state","categories":["feedback"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/status-morph/StatusMorph.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { HTMLAttributes, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { Portal } from \"../../internals/Portal\";\nimport { vibrate } from \"../../internals/motion/haptics\";\nimport \"./status-morph.css\";\n\n/** The four states StatusMorph can be in. */\nexport type StatusMorphState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nconst DEFAULT_LABELS = { loading: \"Loading\", success: \"Done\", error: \"Failed\" } as const;\n\ninterface BaseProps {\n\t/** Current state. The component only ever writes it back to `\"idle\"`\n\t * itself, when `resetAfter` fires — every other write is the caller's,\n\t * and is always honoured: StatusMorph never fights its owner. */\n\tstate?: StatusMorphState;\n\t/** Called whenever StatusMorph itself writes `state` back to `\"idle\"`\n\t * (the `resetAfter` timer). React has no two-way binding, so a caller\n\t * that wants to observe/control the reset wires this into its own\n\t * `state`. */\n\tonStateChange?: (state: StatusMorphState) => void;\n\t/** Milliseconds until an automatic reset to `\"idle\"` after `\"success\"` or\n\t * `\"error\"`. `0` disables the timer entirely (manual reset only). Cleared\n\t * whenever `state` changes again — internally or externally — or on\n\t * unmount. */\n\tresetAfter?: number;\n\t/** Live-region text per state. Unset keys fall back to the defaults. */\n\tlabels?: { loading?: string; success?: string; error?: string };\n\t/** `\"current\"` paints every glyph in `currentColor` (matches the\n\t * surrounding text/button colour). `\"semantic\"` reads the AI-family\n\t * `--ft-status-running/-done/-error` vocabulary instead — the ring track\n\t * stays neutral in both. */\n\ttone?: \"current\" | \"semantic\";\n\t/** Best-effort tactile feedback (the `success`/`error` haptic patterns)\n\t * on entering those states. Opt-in; silently a no-op wherever the\n\t * Vibration API is unsupported or refused. */\n\thaptic?: boolean;\n\t/** Custom idle content, rendered in the same `calc(1em + 1px)` footprint\n\t * instead of the default transparent scaffold — so swapping to it never\n\t * shifts layout. */\n\tidle?: ReactNode;\n\t/** Additional CSS classes. */\n\tclassName?: string;\n}\n\n/**\n * Props for StatusMorph.\n */\nexport interface StatusMorphProps\n\textends BaseProps,\n\t\tOmit<HTMLAttributes<HTMLSpanElement>, keyof BaseProps | \"className\"> {}\n\nexport const StatusMorph = forwardRef<HTMLSpanElement, StatusMorphProps>(function StatusMorph(\n\t{\n\t\tstate = \"idle\",\n\t\tonStateChange,\n\t\tresetAfter = 1800,\n\t\tlabels = {},\n\t\ttone = \"current\",\n\t\thaptic = false,\n\t\tidle,\n\t\tclassName,\n\t\t...restProps\n\t},\n\tref\n) {\n\tconst resolvedLabels = { ...DEFAULT_LABELS, ...labels };\n\n\tconst liveText =\n\t\tstate === \"loading\"\n\t\t\t? resolvedLabels.loading\n\t\t\t: state === \"success\"\n\t\t\t\t? resolvedLabels.success\n\t\t\t\t: state === \"error\"\n\t\t\t\t\t? resolvedLabels.error\n\t\t\t\t\t: \"\";\n\n\t// The auto-reset timer for success/error → idle. Keyed on `state` (and\n\t// `resetAfter`), an effect already gives the exact \"clear on any state\n\t// change\" guarantee for free: React tears down the PREVIOUS run's cleanup\n\t// (which owns the pending `setTimeout`) before the effect body reruns for\n\t// a new value, so a caller flipping success → error mid-countdown cancels\n\t// the stale success→idle timer and starts a fresh error→idle one.\n\tconst onStateChangeRef = useRef(onStateChange);\n\tonStateChangeRef.current = onStateChange;\n\tuseEffect(() => {\n\t\tif ((state === \"success\" || state === \"error\") && resetAfter > 0) {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tonStateChangeRef.current?.(\"idle\");\n\t\t\t}, resetAfter);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}\n\t}, [state, resetAfter]);\n\n\t// Best-effort tactile feedback on entering success/error. `vibrate()` is\n\t// itself a no-op — returns false, never throws — on any environment\n\t// without a Vibration API or outside a user gesture, so this needs no\n\t// touch/pointer check of its own; see haptics.ts.\n\t//\n\t// `lastHapticState` (a ref, non-reactive) is what makes this fire on\n\t// TRANSITION into success/error rather than on every run where `haptic`\n\t// is true: without it, flipping `haptic` false→true while already\n\t// sitting in `state === \"success\"` would buzz again with nothing having\n\t// changed. Mounting straight into success/error is intentionally still a\n\t// \"transition\" here, from the effect's own first-run perspective.\n\tconst lastHapticState = useRef<StatusMorphState | undefined>(undefined);\n\tuseEffect(() => {\n\t\tconst current = state;\n\t\tconst changed = current !== lastHapticState.current;\n\t\tlastHapticState.current = current;\n\t\tif (!haptic || !changed) return;\n\t\tif (current === \"success\") vibrate(\"success\");\n\t\telse if (current === \"error\") vibrate(\"error\");\n\t}, [state, haptic]);\n\n\treturn (\n\t\t<span\n\t\t\tref={ref}\n\t\t\tclassName={cn(\"ft-statusmorph\", className)}\n\t\t\t{...restProps}\n\t\t\tdata-state={state}\n\t\t\tdata-tone={tone}\n\t\t>\n\t\t\t{idle ? (\n\t\t\t\t<span className=\"ft-statusmorph-idle\" aria-hidden=\"true\">\n\t\t\t\t\t{idle}\n\t\t\t\t</span>\n\t\t\t) : null}\n\t\t\t<svg className=\"ft-statusmorph-svg\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" data-state={state}>\n\t\t\t\t<circle\n\t\t\t\t\tclassName=\"ft-statusmorph-track\"\n\t\t\t\t\tcx=\"12\"\n\t\t\t\t\tcy=\"12\"\n\t\t\t\t\tr=\"10\"\n\t\t\t\t\tpathLength=\"1\"\n\t\t\t\t\tvectorEffect=\"non-scaling-stroke\"\n\t\t\t\t/>\n\t\t\t\t<circle\n\t\t\t\t\tclassName=\"ft-statusmorph-arc\"\n\t\t\t\t\tcx=\"12\"\n\t\t\t\t\tcy=\"12\"\n\t\t\t\t\tr=\"10\"\n\t\t\t\t\tpathLength=\"1\"\n\t\t\t\t\tvectorEffect=\"non-scaling-stroke\"\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tclassName=\"ft-statusmorph-check\"\n\t\t\t\t\td=\"M7 12.5l3.5 3.5L17 9\"\n\t\t\t\t\tpathLength=\"1\"\n\t\t\t\t\tvectorEffect=\"non-scaling-stroke\"\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tclassName=\"ft-statusmorph-cross-a\"\n\t\t\t\t\td=\"M8 8l8 8\"\n\t\t\t\t\tpathLength=\"1\"\n\t\t\t\t\tvectorEffect=\"non-scaling-stroke\"\n\t\t\t\t/>\n\t\t\t\t<path\n\t\t\t\t\tclassName=\"ft-statusmorph-cross-b\"\n\t\t\t\t\td=\"M16 8l-8 8\"\n\t\t\t\t\tpathLength=\"1\"\n\t\t\t\t\tvectorEffect=\"non-scaling-stroke\"\n\t\t\t\t/>\n\t\t\t</svg>\n\t\t\t{/*\n\t\t\t\tMounted unconditionally from first render, not conditionally per\n\t\t\t\tstate — a live region has to already exist before its content\n\t\t\t\tchanges for assistive tech to reliably announce the change.\n\t\t\t\tPortalled to document.body so this text never joins a host\n\t\t\t\telement's accessible-name computation: rendered as a Button's\n\t\t\t\ticonStart, an un-portalled span here would make a <button>\n\t\t\t\tannounce \"Loading Save changes\" instead of \"Save changes\",\n\t\t\t\tre-announced on every focus. role=\"status\" stays static; only\n\t\t\t\taria-live toggles polite↔assertive for the error case — a live\n\t\t\t\taria-live swap is a more consistently supported AT pattern than\n\t\t\t\ta live role swap.\n\t\t\t*/}\n\t\t\t<Portal>\n\t\t\t\t<div role=\"status\" aria-live={state === \"error\" ? \"assertive\" : \"polite\"} className=\"sr-only\">\n\t\t\t\t\t{liveText}\n\t\t\t\t</div>\n\t\t\t</Portal>\n\t\t</span>\n\t);\n});\n\nStatusMorph.displayName = \"StatusMorph\";\n","type":"registry:ui","target":"@ui/fancy/components/status-morph/StatusMorph.tsx"},{"path":"react/src/components/status-morph/index.ts","content":"\"use client\";\n\nexport { StatusMorph } from \"./StatusMorph\";\nexport type { StatusMorphProps, StatusMorphState } from \"./StatusMorph\";\n","type":"registry:ui","target":"@ui/fancy/components/status-morph/index.ts"},{"path":"react/src/components/status-morph/status-morph.css","content":".ft-statusmorph {\n\tposition: relative;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: calc(1em + 1px);\n\theight: calc(1em + 1px);\n\tvertical-align: -0.125em;\n}\n\n.ft-statusmorph-idle,\n.ft-statusmorph-svg {\n\tposition: absolute;\n\tinset: 0;\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n}\n\n/*\n * `calc(1em + 1px)`, `stroke-width: 2`, and the `color-mix(in oklab,\n * currentColor 30%, transparent)` track tint all match `.ft-btn-spinner`'s\n * own values exactly (Button.svelte, read-only) — StatusMorph's loading\n * ring is meant to read as the same visual weight, just built from SVG\n * `<circle>`s instead of a CSS border so the ring can later morph into a\n * drawn check/cross via the same `stroke-dasharray`/`stroke-dashoffset`\n * coordinate system. Not pixel-identical, though: Button's border sits\n * flush with the box edge, while this ring is `r=\"10\"` inside a 24-unit\n * viewBox — its outer diameter reads roughly 8% smaller.\n */\n.ft-statusmorph-svg {\n\twidth: calc(1em + 1px);\n\theight: calc(1em + 1px);\n\tfill: none;\n\tstroke: currentColor;\n\tstroke-width: 2;\n\tstroke-linecap: round;\n\tstroke-linejoin: round;\n\tvector-effect: non-scaling-stroke;\n}\n\n/*\n * All five shapes always exist in the DOM — this IS the CLS-free idle\n * answer: idle is simply every shape at opacity 0 (the default below,\n * unless a state-specific rule turns one on), the exact same 24×24\n * footprint as every other state, no special-casing needed for \"don't\n * shift layout\".\n */\n.ft-statusmorph-track,\n.ft-statusmorph-arc,\n.ft-statusmorph-check,\n.ft-statusmorph-cross-a,\n.ft-statusmorph-cross-b {\n\topacity: 0;\n}\n.ft-statusmorph-arc,\n.ft-statusmorph-check {\n\ttransform-box: fill-box;\n\ttransform-origin: center;\n}\n.ft-statusmorph-check {\n\ttransform: scale(1);\n}\n\n.ft-statusmorph-track {\n\tstroke-dasharray: 1;\n\t/* Neutral in both tones — matches `.ft-btn-spinner`'s own track\n\t   treatment, which never re-tints regardless of the spin state. */\n\tstroke: var(--ft-statusmorph-track, color-mix(in oklab, currentColor 30%, transparent));\n}\n.ft-statusmorph-arc {\n\t/* A quarter arc (dash 0.25, gap 0.75) for the loading spinner; closes to\n\t   a full solid ring (dasharray 1, same as the track) once success/error\n\t   is reached — the \"ring close\" the timing table below animates. */\n\tstroke-dasharray: 0.25 0.75;\n}\nsvg[data-state=\"success\"] .ft-statusmorph-track,\nsvg[data-state=\"success\"] .ft-statusmorph-arc,\nsvg[data-state=\"error\"] .ft-statusmorph-track,\nsvg[data-state=\"error\"] .ft-statusmorph-arc,\nsvg[data-state=\"loading\"] .ft-statusmorph-track,\nsvg[data-state=\"loading\"] .ft-statusmorph-arc {\n\topacity: 1;\n}\nsvg[data-state=\"success\"] .ft-statusmorph-arc,\nsvg[data-state=\"error\"] .ft-statusmorph-arc {\n\t/* `1 1`, not the bare `1` the track uses statically: transitioning a\n\t   two-item dasharray (`0.25 0.75`, the loading state) to a one-item\n\t   list needs the UA to repeat the shorter list to match lengths before\n\t   interpolating, which not every engine does consistently. `1 1`\n\t   repeats to exactly what `1` already means, so nothing here changes\n\t   visually while every engine now has a same-length list to animate. */\n\tstroke-dasharray: 1 1;\n}\n\n/*\n * Check/cross draw via the classic pathLength-normalised dash trick: a\n * single dash exactly as long as the path (dasharray: 1) with dashoffset\n * toggling between 1 (shifted a full period — invisible) and 0 (fully\n * drawn). Opacity flips straight to 1 the instant a shape becomes relevant\n * (no transition declared for it) because the dash trick itself already\n * hides an undrawn path at any opacity — the reveal comes from dashoffset\n * animating, not from a separate fade.\n */\n.ft-statusmorph-check,\n.ft-statusmorph-cross-a,\n.ft-statusmorph-cross-b {\n\tstroke-dasharray: 1;\n\tstroke-dashoffset: 1;\n}\nsvg[data-state=\"success\"] .ft-statusmorph-check {\n\topacity: 1;\n\tstroke-dashoffset: 0;\n}\nsvg[data-state=\"error\"] .ft-statusmorph-cross-a,\nsvg[data-state=\"error\"] .ft-statusmorph-cross-b {\n\topacity: 1;\n\tstroke-dashoffset: 0;\n}\n\n/* tone=\"semantic\" swaps the arc/check/cross colour resolution per state;\n   the literal fallbacks are the exact AI-family --ft-status-* values\n   (ToolCall, CopyButton, and 15 other sites read the same three). tone\n   defaults to \"current\" (currentColor everywhere), so these rules only\n   ever apply under [data-tone=\"semantic\"]. */\n.ft-statusmorph[data-tone=\"semantic\"] svg[data-state=\"loading\"] .ft-statusmorph-arc {\n\tstroke: var(\n\t\t--ft-statusmorph-loading,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n.ft-statusmorph[data-tone=\"semantic\"] svg[data-state=\"success\"] .ft-statusmorph-arc,\n.ft-statusmorph[data-tone=\"semantic\"] svg[data-state=\"success\"] .ft-statusmorph-check {\n\tstroke: var(\n\t\t--ft-statusmorph-success,\n\t\tvar(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)))\n\t);\n}\n.ft-statusmorph[data-tone=\"semantic\"] svg[data-state=\"error\"] .ft-statusmorph-arc,\n.ft-statusmorph[data-tone=\"semantic\"] svg[data-state=\"error\"] .ft-statusmorph-cross-a,\n.ft-statusmorph[data-tone=\"semantic\"] svg[data-state=\"error\"] .ft-statusmorph-cross-b {\n\tstroke: var(\n\t\t--ft-statusmorph-error,\n\t\tvar(--ft-status-error, light-dark(oklch(0.5 0.19 25), oklch(0.7 0.18 25)))\n\t);\n}\n\n/*\n * Idle content (when supplied) only shows in the idle state; the default\n * transparent SVG scaffold occupies the exact same footprint the rest of\n * the time, so swapping between the two never shifts layout.\n */\n.ft-statusmorph-idle {\n\topacity: 0;\n}\n.ft-statusmorph[data-state=\"idle\"] .ft-statusmorph-idle {\n\topacity: 1;\n}\n\n/*\n * Every transition/keyframe below lives inside no-preference — outside it,\n * the rules above already ARE the correct final glyph for the current\n * state (opacity, stroke-dasharray, stroke-dashoffset, colour), so reduced\n * motion shows it instantly with nothing further to override.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-statusmorph-track {\n\t\t/* An arrival (the ring becoming visible), so --ft-ease-out — the\n\t\t   arc below shares this same duration/easing for its own opacity so\n\t\t   track and arc fade in/out together instead of in two visible\n\t\t   pieces (the arc previously used --ft-ease-in, a departure curve,\n\t\t   for what is on the opacity axis an arrival either way). */\n\t\ttransition: opacity var(--ft-duration-exit, 200ms)\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));\n\t}\n\t.ft-statusmorph-arc {\n\t\ttransition:\n\t\t\tstroke-dasharray var(--ft-duration-exit, 200ms)\n\t\t\t\tvar(--ft-ease-in, cubic-bezier(0.7, 0, 0.84, 0)),\n\t\t\topacity var(--ft-duration-exit, 200ms) var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));\n\t}\n\t/* Kept spinning through success/error, not scoped to `loading` alone —\n\t   a closed ring rotating is visually identical to a static one, and\n\t   stopping the animation the instant `data-state` changes would make\n\t   `transform` snap back to `none` in the same frame the dasharray\n\t   starts closing, reading as a visible teleport-then-grow instead of a\n\t   continuous close. */\n\tsvg[data-state=\"loading\"] .ft-statusmorph-arc,\n\tsvg[data-state=\"success\"] .ft-statusmorph-arc,\n\tsvg[data-state=\"error\"] .ft-statusmorph-arc {\n\t\tanimation: ft-statusmorph-spin 0.8s linear infinite;\n\t}\n\n\t/* The lead-in before the check starts drawing is --ft-duration-micro\n\t   (tokens.ts DURATIONS.micro), the rung that exists for exactly this:\n\t   a glyph-scale beat long enough to read as a beat and no longer. */\n\t.ft-statusmorph-check {\n\t\ttransition: stroke-dashoffset var(--ft-duration-base, 300ms)\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) var(--ft-duration-micro, 80ms);\n\t}\n\t/* The one --ft-ease-overshoot consumer in the family: the check pops\n\t   past scale(1) and settles, layered on top of (not instead of) the\n\t   dashoffset draw above — both keyed to the same --ft-duration-micro\n\t   delay so the pop and the draw read as one gesture. */\n\tsvg[data-state=\"success\"] .ft-statusmorph-check {\n\t\tanimation: ft-statusmorph-check-pop var(--ft-duration-base, 300ms)\n\t\t\tvar(--ft-ease-overshoot, cubic-bezier(0.34, 1.56, 0.64, 1)) var(--ft-duration-micro, 80ms)\n\t\t\tboth;\n\t}\n\n\t/* Same draw technique and easing as the check (an arrival, not a\n\t   departure), staggered one --ft-duration-micro apart so the X reads\n\t   as two strokes rather than one simultaneous flash. The second\n\t   stroke's delay is CALCULATED from the same token rather than\n\t   hardcoded at its default's 160ms: a theme that retunes the token\n\t   would otherwise start both strokes together at 160ms, or — past\n\t   that — draw the nominally second stroke first. No second token,\n\t   deliberately: R11 adds one rung, not two. The 160ms DURATIONS on\n\t   both lines stay off-scale (a stroke that takes --ft-duration-fast\n\t   reads slower than the beat between the two), as is the 0.8s spin\n\t   above: left as literals rather than forced onto a token that would\n\t   change their value. */\n\t.ft-statusmorph-cross-a {\n\t\ttransition: stroke-dashoffset 160ms var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1))\n\t\t\tvar(--ft-duration-micro, 80ms);\n\t}\n\t.ft-statusmorph-cross-b {\n\t\ttransition: stroke-dashoffset 160ms var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1))\n\t\t\tcalc(var(--ft-duration-micro, 80ms) * 2);\n\t}\n\t/* `.ft-statusmorph` here is a PORT-ADDED anchor (PORTING.md §Styling\n\t   rule 2). Every other selector in this file already carries an owned\n\t   `.ft-statusmorph-*` token; this one targets the bare `<svg>`, which\n\t   the Svelte compiler's scoping hash confined to this component but\n\t   plain global CSS would not — it would shake ANY consumer element\n\t   matching `svg[data-state=\"error\"]`. The root class the component\n\t   already sets anchors it back. Specificity is inert here: nothing\n\t   else animates this element. */\n\t.ft-statusmorph svg[data-state=\"error\"] {\n\t\tanimation: ft-statusmorph-shake var(--ft-duration-base, 300ms)\n\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t}\n}\n\n@keyframes ft-statusmorph-spin {\n\tto {\n\t\ttransform: rotate(360deg);\n\t}\n}\n@keyframes ft-statusmorph-check-pop {\n\tfrom {\n\t\ttransform: scale(0.6);\n\t}\n\tto {\n\t\ttransform: scale(1);\n\t}\n}\n@keyframes ft-statusmorph-shake {\n\t0%,\n\t100% {\n\t\ttransform: translateX(0);\n\t}\n\t25% {\n\t\ttransform: translateX(-2px);\n\t}\n\t75% {\n\t\ttransform: translateX(2px);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/status-morph/status-morph.css"}]}