{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"follow-button","type":"registry:ui","title":"FollowButton","description":"A follow toggle that shows the result at once, then commits when the server agrees or rolls back and says so, with the width held steady across every label","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["actions"],"registryDependencies":["https://fancy-ui.rama.app/r/button.json","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/sound.json","https://fancy-ui.rama.app/r/status-morph.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/follow-button/FollowButton.tsx","content":"\"use client\";\n\nimport {\n\tforwardRef,\n\tuseCallback,\n\tuseEffect,\n\tuseRef,\n\tuseState,\n\ttype ButtonHTMLAttributes,\n\ttype CSSProperties,\n\ttype MouseEvent,\n\ttype ReactNode,\n} from \"react\";\nimport { cn } from \"../../utils\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { sound as soundFx } from \"../../sound/sound\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { Button, type ButtonProps } from \"../button/Button\";\nimport type { ButtonVariant, ButtonSize } from \"../button/types\";\nimport { StatusMorph } from \"../status-morph/StatusMorph\";\nimport {\n\tadopt,\n\tannouncement,\n\tcancel,\n\tclearError,\n\tcreateFollowState,\n\tisThenable,\n\tmorphState,\n\tpress,\n\tresolveLabels,\n\tsettle,\n\tshownFollowing,\n\tvisibleLabel,\n\ttype FollowLabels,\n\ttype FollowState,\n} from \"./follow-core\";\nimport \"./follow-button.css\";\n\nexport type { FollowLabels };\n\nexport interface FollowButtonProps extends Omit<\n\tButtonHTMLAttributes<HTMLButtonElement>,\n\t\"onClick\" | \"onToggle\" | \"className\" | \"type\" | \"children\" | \"disabled\"\n> {\n\t/**\n\t * Whether the viewer follows. Controlled whenever it is not `undefined`: the\n\t * prop is the committed value, a resolved attempt reports through\n\t * `onFollowingChange` without writing it, and a parent that refuses the change\n\t * keeps the button on the prop. Left out, the component keeps its own copy\n\t * (see `defaultFollowing`). A change while an attempt is pending wins and\n\t * drops it.\n\t */\n\tfollowing?: boolean;\n\t/** Initial value when `following` is not passed */\n\tdefaultFollowing?: boolean;\n\t/** Called with the committed value, once per successful attempt. Not called for a rolled-back or superseded one. */\n\tonFollowingChange?: (following: boolean) => void;\n\t/**\n\t * Called with the value the click asks for. Return nothing to commit at\n\t * once, or a promise: resolve commits, reject (or throw) rolls back, shows\n\t * the error and announces it. Clicking again after a failure retries.\n\t */\n\tonToggle?: (next: boolean) => void | Promise<unknown>;\n\t/** Stable accessible name, e.g. \"Follow Rama\". Defaults to the idle label. */\n\tlabel?: string;\n\t/** Visible text per state; unset keys keep the defaults. */\n\tlabels?: Partial<FollowLabels>;\n\t/** Visible text and announcement after a failed attempt */\n\terrorLabel?: string;\n\t/** How long the error state holds before reverting, in milliseconds */\n\tresetMs?: number;\n\t/** Button variant. By default the idle state is `primary` and the following state `outline`. */\n\tvariant?: ButtonVariant;\n\t/** Passed straight through to the underlying Button */\n\tsize?: ButtonSize;\n\t/** Disables the button and blocks the toggle */\n\tdisabled?: boolean;\n\t/** Drops the visible text, keeping the accessible name */\n\ticonOnly?: boolean;\n\t/**\n\t * Plays the matching interface cue (toggle-on, toggle-off, error) and the\n\t * error vibration. Off by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n\t/** Fires on activation, after the toggle has started. Never called while `disabled` or pending. */\n\tonClick?: (event: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n/** How long the outgoing label stays on screen, in milliseconds. */\nconst LEAVE_MS = 80;\n\n/**\n * The inline `style` rides StatusMorph's rest props and lands as an attribute,\n * which beats its own width rule. `font-size` is set beside it so StatusMorph's\n * inner `calc(1em + 1px)` resolves to the same `1rem` and the glyph fills the\n * 16px box. `--ft-statusmorph-error` reads the same `--ft-status-error` chain\n * the failure skin in `follow-button.css` resolves, so a theme that declares\n * neither token still paints one red on the cross and the border.\n */\nconst MORPH_STYLE = {\n\twidth: \"1rem\",\n\theight: \"1rem\",\n\tfontSize: \"calc(1rem - 1px)\",\n\t\"--ft-statusmorph-error\":\n\t\t\"var(--ft-status-error, light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216)))\",\n} as CSSProperties;\n\nfunction glyph(shown: boolean): ReactNode {\n\treturn (\n\t\t<svg\n\t\t\tclassName=\"size-full\"\n\t\t\tviewBox=\"0 0 24 24\"\n\t\t\tfill=\"none\"\n\t\t\tstroke=\"currentColor\"\n\t\t\tstrokeWidth=\"2\"\n\t\t\tstrokeLinecap=\"round\"\n\t\t\tstrokeLinejoin=\"round\"\n\t\t\taria-hidden=\"true\"\n\t\t>\n\t\t\t{shown ? <path d=\"M20 6 9 17l-5-5\" /> : <path d=\"M12 5v14M5 12h14\" />}\n\t\t</svg>\n\t);\n}\n\nexport const FollowButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, FollowButtonProps>(\n\tfunction FollowButton(\n\t\t{\n\t\t\tfollowing: followingProp,\n\t\t\tdefaultFollowing = false,\n\t\t\tonFollowingChange,\n\t\t\tonToggle,\n\t\t\tlabel,\n\t\t\tlabels,\n\t\t\terrorLabel = \"Try again\",\n\t\t\tresetMs = 2000,\n\t\t\tvariant,\n\t\t\tsize = \"md\",\n\t\t\tdisabled = false,\n\t\t\ticonOnly = false,\n\t\t\tsound = false,\n\t\t\tclassName,\n\t\t\tonClick,\n\t\t\t...restProps\n\t\t},\n\t\tref\n\t) {\n\t\t// The machine's snapshot, plus a ref mirror for the async callbacks: a\n\t\t// promise that settles later must read the CURRENT snapshot, not the one\n\t\t// its click closed over.\n\t\tconst [core, setCore] = useState<FollowState>(() =>\n\t\t\tcreateFollowState(followingProp ?? defaultFollowing)\n\t\t);\n\t\tconst coreRef = useRef(core);\n\t\tuseIsomorphicLayoutEffect(() => {\n\t\t\tcoreRef.current = core;\n\t\t}, [core]);\n\n\t\tconst commit = useCallback((next: FollowState) => {\n\t\t\tcoreRef.current = next;\n\t\t\tsetCore(next);\n\t\t}, []);\n\n\t\t// The parent speaks: its value wins over a pending attempt or a standing\n\t\t// error. Derived during render (the documented \"adjust state on prop\n\t\t// change\" pattern) so no frame paints the stale value, and keyed on the\n\t\t// prop CHANGING, the same shape Toggle and Slider use.\n\t\tconst [seenProp, setSeenProp] = useState(followingProp);\n\t\tif (followingProp !== seenProp) {\n\t\t\tsetSeenProp(followingProp);\n\t\t\t// Also on the switch to controlled (undefined -> defined) with the SAME\n\t\t\t// value: a pending attempt must be dropped then too, or it would later\n\t\t\t// report a change the parent never asked for. `adopt` is a no-op for an\n\t\t\t// idle button that already holds the value.\n\t\t\tif (\n\t\t\t\tfollowingProp !== undefined &&\n\t\t\t\t(seenProp === undefined || followingProp !== core.following)\n\t\t\t) {\n\t\t\t\tsetCore(adopt(core, followingProp));\n\t\t\t}\n\t\t}\n\n\t\t// Anything still in flight at unmount is stale from here on.\n\t\tuseEffect(\n\t\t\t() => () => {\n\t\t\t\tcoreRef.current = cancel(coreRef.current);\n\t\t\t},\n\t\t\t[]\n\t\t);\n\n\t\t// `following` is controlled whenever it is not `undefined`: the prop is then\n\t\t// the committed value, `core.following` is only a mirror of it, and a\n\t\t// resolved attempt reports through `onFollowingChange` without writing it.\n\t\tconst live = useLiveRef({ onToggle, onFollowingChange, onClick, sound, followingProp });\n\n\t\t// The machine's snapshot with the committed value read from the prop when\n\t\t// controlled. Pure; the render and the async callbacks both go through it.\n\t\tconst withCommitted = (state: FollowState, committed: boolean | undefined): FollowState =>\n\t\t\tcommitted === undefined || state.following === committed\n\t\t\t\t? state\n\t\t\t\t: {\n\t\t\t\t\t\t...state,\n\t\t\t\t\t\tfollowing: committed,\n\t\t\t\t\t\ttarget: state.phase === \"pending\" ? state.target : committed,\n\t\t\t\t\t};\n\t\tconst view = withCommitted(core, followingProp);\n\n\t\tconst resolvedLabels = resolveLabels(labels);\n\t\tconst shown = shownFollowing(view);\n\t\tconst currentLabel = visibleLabel(view, resolvedLabels, errorLabel);\n\t\t// The accessible name never follows the visible text: a name that changes\n\t\t// next to `aria-pressed` would announce the state twice.\n\t\tconst name = label ?? restProps[\"aria-label\"] ?? resolvedLabels.follow;\n\t\tconst showsPreview = view.phase === \"idle\" && view.following;\n\t\tconst failed = view.phase === \"error\";\n\n\t\t// With no explicit variant the button flips primary <-> outline, and the\n\t\t// outline has a 1px border: reserve it (transparent) in the primary state\n\t\t// so the box is identical across idle, following, pending and error.\n\t\tconst reserveBorder = variant === undefined;\n\t\tconst classes = cn(\n\t\t\t\"ft-follow\",\n\t\t\t(failed || reserveBorder) && \"border\",\n\t\t\treserveBorder && !shown && !failed && \"border-transparent\",\n\t\t\tfailed && \"ft-follow--failed\",\n\t\t\tclassName\n\t\t);\n\n\t\t// The error window. Armed per failure and keyed on the ticket, so a retry\n\t\t// or a parent write that lands first is never cleared by a stale timer. 0\n\t\t// means \"revert on the next tick\", the reading the copy button gives it.\n\t\tuseEffect(() => {\n\t\t\tif (core.phase !== \"error\") return;\n\t\t\tconst armed = core.ticket;\n\t\t\tconst timer = setTimeout(\n\t\t\t\t() => {\n\t\t\t\t\tcommit(clearError(coreRef.current, armed));\n\t\t\t\t},\n\t\t\t\tresetMs > 0 ? resetMs : 1\n\t\t\t);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}, [core.phase, core.ticket, resetMs, commit]);\n\n\t\tfunction finish(ticket: number, ok: boolean) {\n\t\t\tconst handlers = live.current;\n\t\t\tconst base = withCommitted(coreRef.current, handlers.followingProp);\n\t\t\tconst result = settle(base, ticket, ok);\n\t\t\t// Superseded by the parent, a retry or an unmount: it has nothing to say.\n\t\t\tif (result.outcome === \"stale\") return;\n\t\t\t// Controlled: the attempt ends, but the committed value stays whatever\n\t\t\t// the parent holds. It writes the new value back through\n\t\t\t// `onFollowingChange`, or it refuses and the prop stays the truth.\n\t\t\tcommit(\n\t\t\t\thandlers.followingProp === undefined\n\t\t\t\t\t? result.state\n\t\t\t\t\t: { ...result.state, following: base.following, target: base.following }\n\t\t\t);\n\t\t\tif (ok) {\n\t\t\t\tif (handlers.sound) soundFx.play(result.state.following ? \"toggle-on\" : \"toggle-off\");\n\t\t\t\thandlers.onFollowingChange?.(result.state.following);\n\t\t\t} else if (handlers.sound) {\n\t\t\t\tsoundFx.play(\"error\");\n\t\t\t}\n\t\t}\n\n\t\tfunction handleClick(event: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) {\n\t\t\t// A click while an attempt is in flight is swallowed: neither the toggle\n\t\t\t// nor the consumer's own handler runs, and nothing is queued.\n\t\t\tconst started = press(withCommitted(coreRef.current, live.current.followingProp));\n\t\t\tif (!started) return;\n\t\t\t// The outcome cue may play after an await, when the transient activation\n\t\t\t// is gone: create/resume the audio context while the gesture is live.\n\t\t\tif (sound && soundFx.enabled) void soundFx.unlock();\n\t\t\tcommit(started.state);\n\t\t\tconst ticket = started.ticket;\n\t\t\tlet result: unknown;\n\t\t\tlet threw = false;\n\t\t\ttry {\n\t\t\t\tresult = live.current.onToggle?.(started.next);\n\t\t\t} catch {\n\t\t\t\tthrew = true;\n\t\t\t}\n\t\t\tif (threw) finish(ticket, false);\n\t\t\telse if (isThenable(result)) {\n\t\t\t\tresult.then(\n\t\t\t\t\t() => finish(ticket, true),\n\t\t\t\t\t() => finish(ticket, false)\n\t\t\t\t);\n\t\t\t} else finish(ticket, true);\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\t// Out 80 ms, in 160 ms: a label change keeps the OUTGOING text alive for\n\t\t// the out beat as a second, `aria-hidden` layer in the same grid cell,\n\t\t// while the keyed live span fades in after that beat. Derived during render\n\t\t// so the outgoing text is in the very commit that shows the new one.\n\t\t// `prefersReducedMotion()` is read once per swap: under reduced motion\n\t\t// there is no outgoing layer and the text snaps.\n\t\tconst [swap, setSwap] = useState<{\n\t\t\tshown: string;\n\t\t\tleaving: string | null;\n\t\t\tswapped: boolean;\n\t\t}>({ shown: currentLabel, leaving: null, swapped: false });\n\t\tif (swap.shown !== currentLabel) {\n\t\t\tsetSwap({\n\t\t\t\tshown: currentLabel,\n\t\t\t\t// A second swap inside the out beat: `swap.shown` was still in its\n\t\t\t\t// delayed fade-in and never painted, so keep the layer that is on\n\t\t\t\t// screen (and its timer) rather than replacing it with unseen text.\n\t\t\t\tleaving: swap.leaving !== null ? swap.leaving : prefersReducedMotion() ? null : swap.shown,\n\t\t\t\tswapped: true,\n\t\t\t});\n\t\t}\n\t\tuseEffect(() => {\n\t\t\tif (swap.leaving === null) return;\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tsetSwap((current) => ({ ...current, leaving: null }));\n\t\t\t}, LEAVE_MS);\n\t\t\treturn () => clearTimeout(timer);\n\t\t}, [swap.leaving]);\n\n\t\t/*\n\t\t * The glyph is driven, not bound: `state` comes from the machine, so the\n\t\t * morph has no timer of its own (`resetAfter={0}`) and cannot disagree with\n\t\t * the button about whether an attempt is pending. Its `role=\"status\"`\n\t\t * region, which it portals to `document.body` and so never joins the\n\t\t * button's name, is the one live region: silent while loading, assertive on\n\t\t * error, \"name: outcome\".\n\t\t */\n\t\tconst statusIcon = (\n\t\t\t<StatusMorph\n\t\t\t\tstate={morphState(view)}\n\t\t\t\ttone=\"semantic\"\n\t\t\t\tresetAfter={0}\n\t\t\t\thaptic={sound}\n\t\t\t\tlabels={{ loading: \"\", success: \"\", error: announcement(name, errorLabel) }}\n\t\t\t\tidle={glyph(shown)}\n\t\t\t\tstyle={MORPH_STYLE}\n\t\t\t/>\n\t\t);\n\n\t\treturn (\n\t\t\t<Button\n\t\t\t\t{...(restProps as Partial<ButtonProps>)}\n\t\t\t\ttype=\"button\"\n\t\t\t\tref={ref}\n\t\t\t\tvariant={variant ?? (shown ? \"outline\" : \"primary\")}\n\t\t\t\tsize={size}\n\t\t\t\tdisabled={disabled}\n\t\t\t\tlabel={name}\n\t\t\t\tclassName={classes}\n\t\t\t\ticonStart={statusIcon}\n\t\t\t\taria-pressed={shown}\n\t\t\t\taria-busy={view.phase === \"pending\" ? \"true\" : undefined}\n\t\t\t\tdata-state={view.phase}\n\t\t\t\tonClick={handleClick}\n\t\t\t>\n\t\t\t\t{/*\n\t\t\t\t\tThe visible text sits in a one-cell grid stacking four `aria-hidden`\n\t\t\t\t\tghosts (follow, following, unfollow, error) under the live span: the\n\t\t\t\t\tcell is as wide as the widest, in pure CSS, so the button never\n\t\t\t\t\tchanges width when the text swaps and nothing is measured\n\t\t\t\t\t(SSR-safe). The unfollow preview is the same: a second layer in the\n\t\t\t\t\tlive span that a hover or keyboard focus on a fine pointer reveals in\n\t\t\t\t\tCSS, so no state, no listener and no layout shift carry it.\n\t\t\t\t*/}\n\t\t\t\t{iconOnly ? null : (\n\t\t\t\t\t<span className=\"ft-follow__label\">\n\t\t\t\t\t\t<span className=\"ft-follow__ghost\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{resolvedLabels.follow}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"ft-follow__ghost\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{resolvedLabels.following}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"ft-follow__ghost\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{resolvedLabels.unfollow}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"ft-follow__ghost\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t{errorLabel}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t{swap.leaving !== null && (\n\t\t\t\t\t\t\t<span className=\"ft-follow__leave\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t{swap.leaving}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t\t<span\n\t\t\t\t\t\t\tkey={currentLabel}\n\t\t\t\t\t\t\tclassName=\"ft-follow__live\"\n\t\t\t\t\t\t\tdata-swapped={swap.swapped ? \"\" : undefined}\n\t\t\t\t\t\t\tdata-preview={showsPreview ? \"\" : undefined}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"ft-follow__rest\">{currentLabel}</span>\n\t\t\t\t\t\t\t{showsPreview && (\n\t\t\t\t\t\t\t\t<span className=\"ft-follow__preview\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t{resolvedLabels.unfollow}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</Button>\n\t\t);\n\t}\n);\n\nFollowButton.displayName = \"FollowButton\";\n","type":"registry:ui","target":"@ui/fancy/components/follow-button/FollowButton.tsx"},{"path":"react/src/components/follow-button/follow-button.css","content":"/*\n * Label cell. Four `aria-hidden` ghosts (follow, following, unfollow, error)\n * share one grid cell with the live span, so the cell is as wide as the widest\n * label and the button never changes width when the text swaps. Pure CSS,\n * nothing measured.\n */\n.ft-follow__label {\n\tdisplay: inline-grid;\n\ttext-align: start;\n}\n\n.ft-follow__label > * {\n\tgrid-area: 1 / 1;\n\twhite-space: nowrap;\n}\n\n.ft-follow__ghost {\n\tvisibility: hidden;\n\tpointer-events: none;\n\tuser-select: none;\n}\n\n.ft-follow__leave {\n\tpointer-events: none;\n}\n\n.ft-follow__live {\n\tdisplay: inline-grid;\n\ttext-align: start;\n}\n\n.ft-follow__live > * {\n\tgrid-area: 1 / 1;\n}\n\n.ft-follow__preview {\n\tvisibility: hidden;\n}\n\n/*\n * The unfollow preview: a fine pointer over the button, or keyboard focus on\n * it. A touch screen has no hover, so the preview never shows there and the\n * control never carries an affordance only a mouse can reach. Instant, not\n * animated: it is a state read-out, not motion.\n */\n@media (hover: hover) and (pointer: fine) {\n\t.ft-follow:is(:hover, :focus-visible) .ft-follow__live[data-preview] > .ft-follow__rest {\n\t\tvisibility: hidden;\n\t}\n\n\t.ft-follow:is(:hover, :focus-visible) .ft-follow__live[data-preview] > .ft-follow__preview {\n\t\tvisibility: visible;\n\t}\n}\n\n/* transform / opacity only ever run under `no-preference`; under reduce the\n   text snaps and the component never renders the outgoing layer. */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-follow__leave {\n\t\tanimation: ft-follow-out 80ms var(--ft-ease-in, cubic-bezier(0.7, 0, 0.84, 0)) forwards;\n\t}\n\n\t.ft-follow__live[data-swapped] {\n\t\tanimation: ft-follow-in 160ms var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1)) 80ms backwards;\n\t}\n}\n\n@keyframes ft-follow-out {\n\tto {\n\t\topacity: 0;\n\t\ttransform: translateY(-2px);\n\t}\n}\n\n@keyframes ft-follow-in {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(2px);\n\t}\n}\n\n/*\n * The failure skin reads the family's `--ft-status-error` vocabulary with the\n * same fallback pair the toast surface uses, so a failed follow next to a\n * failed toast is one red even in a theme that declares neither. Unlayered\n * author CSS beats Tailwind's utilities layer, so neither variant's hover\n * colours bleed back over it.\n */\n.ft-follow--failed {\n\tborder-color: color-mix(\n\t\tin oklab,\n\t\tvar(--ft-status-error, light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216))) 35%,\n\t\ttransparent\n\t);\n\tbackground-color: color-mix(\n\t\tin oklab,\n\t\tvar(--ft-status-error, light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216))) 10%,\n\t\ttransparent\n\t);\n\tcolor: var(--ft-status-error, light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216)));\n}\n","type":"registry:ui","target":"@ui/fancy/components/follow-button/follow-button.css"},{"path":"react/src/components/follow-button/follow-core.ts","content":"\"use client\";\n\n/**\n * Pure state machine behind FollowButton, shared byte for byte by the Svelte,\n * React and Vue ports. No framework, no timers, no DOM: every function takes a\n * snapshot and returns the next one, so the three components only translate\n * \"a click\", \"a promise settled\" and \"the parent wrote a value\" into calls and\n * paint the result.\n *\n * Three things live here because getting any of them subtly different in one\n * port would be a bug no single port's tests could see:\n *\n * - The attempt ticket. Every press, every parent write and every cancel bumps\n *   `ticket`; a promise that settles under an older ticket changes nothing.\n *   That one rule covers a parent that answers faster than the server, a retry\n *   after a failure, and an unmount with a request still in flight.\n * - The optimistic read. While an attempt is pending the button SHOWS the\n *   value it asked for, while `following` still holds the last value anybody\n *   committed, which is what a rollback returns to.\n * - The label vocabulary (what is shown, what is announced), so a port cannot\n *   drift on which text goes with which state.\n */\n\n/** Where the control is in one attempt. `error` holds until a timer, a retry or the parent clears it. */\nexport type FollowPhase = \"idle\" | \"pending\" | \"error\";\n\nexport interface FollowState {\n\t/** The last committed value: what a rollback returns to. */\n\treadonly following: boolean;\n\treadonly phase: FollowPhase;\n\t/** The value the in-flight attempt asked for. Meaningful only while `phase` is `pending`. */\n\treadonly target: boolean;\n\t/** Identifies the current attempt; bumped by every press, parent write and cancel. */\n\treadonly ticket: number;\n}\n\nexport interface FollowLabels {\n\t/** Visible and default accessible text while not following. */\n\tfollow: string;\n\t/** Visible text while following. */\n\tfollowing: string;\n\t/** Text previewed while following, on hover or keyboard focus of a fine pointer. */\n\tunfollow: string;\n}\n\nexport const DEFAULT_FOLLOW_LABELS: Readonly<FollowLabels> = {\n\tfollow: \"Follow\",\n\tfollowing: \"Following\",\n\tunfollow: \"Unfollow\",\n};\n\n/** The glyph state StatusMorph is driven with. */\nexport type FollowMorphState = \"idle\" | \"loading\" | \"error\";\n\nexport type FollowOutcome = \"commit\" | \"rollback\" | \"stale\";\n\nexport function createFollowState(following: boolean): FollowState {\n\treturn { following, phase: \"idle\", target: following, ticket: 0 };\n}\n\n/** The value the control paints: the requested one while pending, the committed one otherwise. */\nexport function shownFollowing(state: FollowState): boolean {\n\treturn state.phase === \"pending\" ? state.target : state.following;\n}\n\n/**\n * A user activation. Returns the pending state plus the value to hand to\n * `onToggle`, or `null` while an attempt is already in flight (the click is\n * swallowed, never queued). A press out of `error` is the retry.\n */\nexport function press(\n\tstate: FollowState\n): { state: FollowState; ticket: number; next: boolean } | null {\n\tif (state.phase === \"pending\") return null;\n\tconst next = !state.following;\n\tconst ticket = state.ticket + 1;\n\treturn {\n\t\tstate: { following: state.following, phase: \"pending\", target: next, ticket },\n\t\tticket,\n\t\tnext,\n\t};\n}\n\n/**\n * The attempt `ticket` finished. `ok` commits the requested value; failure\n * keeps the committed one and parks the control in `error`. A ticket that is no\n * longer current changes nothing and reports `stale`.\n */\nexport function settle(\n\tstate: FollowState,\n\tticket: number,\n\tok: boolean\n): { state: FollowState; outcome: FollowOutcome } {\n\tif (state.phase !== \"pending\" || ticket !== state.ticket) return { state, outcome: \"stale\" };\n\tif (ok) {\n\t\treturn {\n\t\t\tstate: { following: state.target, phase: \"idle\", target: state.target, ticket: state.ticket },\n\t\t\toutcome: \"commit\",\n\t\t};\n\t}\n\treturn {\n\t\tstate: {\n\t\t\tfollowing: state.following,\n\t\t\tphase: \"error\",\n\t\t\ttarget: state.following,\n\t\t\tticket: state.ticket,\n\t\t},\n\t\toutcome: \"rollback\",\n\t};\n}\n\n/**\n * The parent wrote a value. It always wins: a pending attempt or a standing\n * error is dropped and its ticket invalidated. Re-asserting what is already\n * shown while idle is a no-op, so an echo of our own commit costs nothing.\n */\nexport function adopt(state: FollowState, following: boolean): FollowState {\n\tif (state.phase === \"idle\" && state.following === following) return state;\n\treturn { following, phase: \"idle\", target: following, ticket: state.ticket + 1 };\n}\n\n/** The error window ended. Only clears the error `ticket` armed it for, never a newer attempt. */\nexport function clearError(state: FollowState, ticket: number): FollowState {\n\tif (state.phase !== \"error\" || state.ticket !== ticket) return state;\n\treturn { ...state, phase: \"idle\" };\n}\n\n/** Unmount: nothing in flight may write anything afterwards. */\nexport function cancel(state: FollowState): FollowState {\n\treturn {\n\t\tfollowing: state.following,\n\t\tphase: \"idle\",\n\t\ttarget: state.following,\n\t\tticket: state.ticket + 1,\n\t};\n}\n\nexport function morphState(state: FollowState): FollowMorphState {\n\treturn state.phase === \"pending\" ? \"loading\" : state.phase === \"error\" ? \"error\" : \"idle\";\n}\n\nexport function resolveLabels(labels?: Partial<FollowLabels> | null): FollowLabels {\n\tconst resolved: FollowLabels = { ...DEFAULT_FOLLOW_LABELS };\n\tif (!labels) return resolved;\n\t// `Partial` allows `{ follow: undefined }`; spreading it would blank the default.\n\tfor (const key of Object.keys(DEFAULT_FOLLOW_LABELS) as (keyof FollowLabels)[]) {\n\t\tconst value = labels[key];\n\t\tif (value !== undefined) resolved[key] = value;\n\t}\n\treturn resolved;\n}\n\n/** The visible text for a state. The unfollow preview is a hover/focus overlay, not a state. */\nexport function visibleLabel(state: FollowState, labels: FollowLabels, errorLabel: string): string {\n\tif (state.phase === \"error\") return errorLabel;\n\treturn shownFollowing(state) ? labels.following : labels.follow;\n}\n\n/** `name: outcome`, the single assertive announcement of a failed attempt. */\nexport function announcement(name: string, outcome: string): string {\n\treturn `${name}: ${outcome}`;\n}\n\n/** A value `await` would wait for. A plain return commits synchronously instead. */\nexport function isThenable(value: unknown): value is PromiseLike<unknown> {\n\treturn (\n\t\tvalue !== null &&\n\t\t(typeof value === \"object\" || typeof value === \"function\") &&\n\t\ttypeof (value as { then?: unknown }).then === \"function\"\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/follow-button/follow-core.ts"},{"path":"react/src/components/follow-button/index.ts","content":"\"use client\";\n\nexport { FollowButton } from \"./FollowButton\";\nexport type { FollowButtonProps, FollowLabels } from \"./FollowButton\";\n","type":"registry:ui","target":"@ui/fancy/components/follow-button/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/follow-button?framework=react"}}