{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"code-block","type":"registry:ui","title":"Code block","description":"Present code with legible hierarchy and copy access.","categories":["data"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json","https://ui.teamofsilicons.com/r/copy-button.json"],"files":[{"path":"registry/components/code-block/code-block.tsx","type":"registry:component","target":"@components/silicon-ui/code-block/code-block.tsx","content":"\"use client\";\n\nimport { Fragment, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { ChevronDown, FileCode2 } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { CopyButton } from \"../copy-button/copy-button\";\nimport styles from \"./code-block.module.css\";\n\nexport interface CodeBlockProps {\n  /** Source shown in the block and copied by the action. */\n  code: string;\n  /** File name displayed in the header. */\n  filename?: string;\n  /** Used for the header label and lightweight syntax highlighting. */\n  language?: string;\n  /** Collapse longer sources to this many lines, with a toggle that expands the rest in place. */\n  maxLines?: number;\n}\n\ntype TokenKind = \"comment\" | \"string\" | \"number\" | \"keyword\" | \"type\" | \"function\" | \"property\" | \"tag\" | \"punctuation\";\n\nconst keywordPattern = /^(?:abstract|as|async|await|break|case|catch|class|const|continue|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|of|private|protected|public|readonly|return|set|static|switch|throw|try|type|typeof|var|void|while|with|yield|SELECT|FROM|WHERE|INSERT|UPDATE|DELETE)$/;\nconst literalPattern = /^(?:true|false|null|undefined|NaN|Infinity)$/;\nconst typePattern = /^(?:Array|Boolean|Date|Error|Map|Number|Promise|Record|Set|String|ReactNode|HTMLElement|HTMLButtonElement|Event|unknown|never|void|any|boolean|number|string|object)$/;\nconst tokenPattern = /\\/\\*[\\s\\S]*?\\*\\/|\\/\\/[^\\n]*|<!--[\\s\\S]*?-->|`(?:\\\\.|[^`\\\\])*`|\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|[^'\\\\])*'|\\b\\d+(?:\\.\\d+)?\\b|\\b[A-Za-z_$][\\w$-]*\\b|[{}[\\]();,.<>:=+*/!?|&-]/g;\n\nfunction normaliseLanguage(language: string) {\n  return language.toLowerCase().replace(/^\\./, \"\");\n}\n\nfunction tokenKind(value: string, source: string, index: number, language: string): TokenKind | undefined {\n  if (value.startsWith(\"//\") || value.startsWith(\"/*\") || value.startsWith(\"<!--\")) return \"comment\";\n  if (value.startsWith(\"\\\"\") || value.startsWith(\"'\") || value.startsWith(\"`\")) {\n    return language === \"json\" && /^\\s*:/.test(source.slice(index + value.length)) ? \"property\" : \"string\";\n  }\n  if (/^\\d/.test(value)) return \"number\";\n  if (keywordPattern.test(value) || literalPattern.test(value)) return \"keyword\";\n  if (typePattern.test(value)) return \"type\";\n  if (/^[{}[\\]();,.<>:=+*/!?|&-]$/.test(value)) return \"punctuation\";\n\n  const before = source.slice(0, index);\n  const after = source.slice(index + value.length);\n  if ((language === \"tsx\" || language === \"jsx\" || language === \"html\" || language === \"vue\") && /<\\/?$/.test(before)) return \"tag\";\n  if (/^\\s*\\(/.test(after) && language !== \"json\") return \"function\";\n  if ((language === \"css\" || language === \"tsx\" || language === \"jsx\") && /^\\s*[:=]/.test(after)) return \"property\";\n  return undefined;\n}\n\nfunction highlight(code: string, language: string): ReactNode[] {\n  const parts: ReactNode[] = [];\n  let cursor = 0;\n\n  for (const match of code.matchAll(tokenPattern)) {\n    const value = match[0];\n    const index = match.index ?? 0;\n    if (cursor < index) parts.push(code.slice(cursor, index));\n\n    const kind = tokenKind(value, code, index, language);\n    parts.push(kind ? <span className={styles[kind]} key={`${index}-${value}`}>{value}</span> : <Fragment key={`${index}-${value}`}>{value}</Fragment>);\n    cursor = index + value.length;\n  }\n\n  if (cursor < code.length) parts.push(code.slice(cursor));\n  return parts;\n}\n\nconst textEnter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } as const;\nconst textExit = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } as const;\n\n/** Text that changes in place: the new words rise in with a soft blur while the old ones leave upward, faster. */\nfunction SwapText({ value, reduced }: { value: string; reduced: boolean }) {\n  return <AnimatePresence initial={false}>\n    <motion.span key={value} className={styles.swapText} initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: textExit }} transition={reduced ? { duration: 0 } : textEnter}>{value}</motion.span>\n  </AnimatePresence>;\n}\n\n/** A new file name swaps in while its box follows the new width on a spring, then returns to auto so it can still truncate. */\nfunction FileName({ name, reduced }: { name: string; reduced: boolean }) {\n  const sizer = useRef<HTMLSpanElement>(null);\n  const rest = useRef(0);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = sizer.current;\n    if (!node) return;\n    const observer = new ResizeObserver(() => { rest.current = node.offsetWidth; });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  useLayoutEffect(() => {\n    const moving = width.get(), to = sizer.current?.offsetWidth ?? 0;\n    const from = typeof moving === \"number\" ? moving : rest.current;\n    rest.current = to;\n    if (reduced || !from || !to || Math.abs(from - to) < 1) { width.jump(\"auto\"); return; }\n    width.jump(from);\n    const controls = animate(width, to, { ...motionTokens.spring.morph, onComplete: () => width.jump(\"auto\") });\n    return () => controls.stop();\n  }, [name, reduced, width]);\n  return <motion.span className={`${styles.swap} ${styles.filename}`} style={{ width }}>\n    <span ref={sizer} className={styles.sizer} aria-hidden=\"true\">{name}</span>\n    <span className={styles.swapStack} aria-hidden=\"true\"><SwapText value={name} reduced={reduced} /></span>\n    <span className={styles.srOnly}>{name}</span>\n  </motion.span>;\n}\n\nexport function CodeBlock({ code, filename, language = \"tsx\", maxLines }: CodeBlockProps) {\n  const displayLanguage = normaliseLanguage(language);\n  const reduced = useReducedMotion() ?? false;\n  const preId = useId();\n  const lineCount = code.split(\"\\n\").length;\n  const collapsible = maxLines != null && maxLines > 0 && lineCount > maxLines;\n  const [expanded, setExpanded] = useState(false);\n  const open = !collapsible || expanded;\n  const preRef = useRef<HTMLPreElement>(null);\n  const measured = useRef<{ full: number; collapsed: number } | null>(null);\n  // Before the first measurement the clip height comes from CSS, so the server render is already collapsed.\n  const height = useMotionValue<number | string>(open ? \"auto\" : \"var(--code-collapsed)\");\n  const target = useRef({ open, reduced });\n  const settleRef = useRef<(instant: boolean) => void>(() => {});\n\n  // The source area follows its content on a spring: expanding, collapsing, and new code all resize smoothly.\n  useLayoutEffect(() => {\n    const pre = preRef.current;\n    if (!pre) return;\n    const settle = (instant: boolean) => {\n      const sizes = measured.current;\n      if (!sizes) return;\n      const next = target.current.open ? sizes.full : sizes.collapsed;\n      if (instant || target.current.reduced || typeof height.get() !== \"number\") height.jump(next);\n      else animate(height, next, motionTokens.spring.smooth);\n    };\n    const observer = new ResizeObserver(() => {\n      const style = getComputedStyle(pre);\n      const full = pre.offsetHeight;\n      const collapsed = maxLines ? Math.min(full, Math.round(maxLines * parseFloat(style.lineHeight) + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom))) : full;\n      const first = !measured.current;\n      measured.current = { full, collapsed };\n      settle(first);\n    });\n    observer.observe(pre);\n    settleRef.current = settle;\n    return () => observer.disconnect();\n  }, [height, maxLines]);\n  useLayoutEffect(() => { target.current = { open, reduced }; settleRef.current(false); }, [open, reduced]);\n  const expandLabels = [`Show all ${lineCount} lines`, \"Show fewer lines\"] as const;\n\n  return (\n    <section className={styles.block} aria-label={filename ? `${filename} source code` : `${displayLanguage} source code`} style={maxLines ? { \"--code-lines\": maxLines } as CSSProperties : undefined}>\n      <header className={styles.header}>\n        <div className={styles.file}>\n          <FileCode2 size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          <FileName name={filename ?? \"Source code\"} reduced={reduced} />\n          <span className={styles.language}>{displayLanguage}</span>\n        </div>\n        <CopyButton value={code} label=\"Copy code\" />\n      </header>\n      <motion.div className={styles.viewport} style={{ height }}>\n        <pre ref={preRef} id={preId} className={styles.pre} tabIndex={0} aria-label=\"Selectable source code\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.code key={code} className={styles.code} initial={reduced ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: textExit }} transition={reduced ? { duration: 0 } : textEnter}>{highlight(code, displayLanguage)}</motion.code>\n          </AnimatePresence>\n        </pre>\n      </motion.div>\n      {collapsible && <button type=\"button\" className={styles.expand} aria-expanded={expanded} aria-controls={preId} onClick={() => setExpanded(value => !value)}>\n        {/* Both labels reserve the cell, so the chevron never moves when the words change. */}\n        <span className={styles.swap} aria-hidden=\"true\">{expandLabels.map(text => <span key={text} className={styles.reserve}>{text}</span>)}<span className={styles.swapStack}><SwapText value={expandLabels[expanded ? 1 : 0]} reduced={reduced} /></span></span>\n        <span className={styles.srOnly}>{expandLabels[expanded ? 1 : 0]}</span>\n        <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: expanded ? 180 : 0 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}><ChevronDown size={16} strokeWidth={1.75} /></motion.span>\n      </button>}\n    </section>\n  );\n}\n"},{"path":"registry/components/code-block/code-block.module.css","type":"registry:component","target":"@components/silicon-ui/code-block/code-block.module.css","content":".block { width: 100%; min-width: 0; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface); }\n.header { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: var(--space-group); border-bottom: 1px solid var(--border-subtle); padding: var(--space-related) var(--space-group) var(--space-related) var(--space-inset); background: var(--surface-raised); }\n/* Baseline alignment keeps the smaller mono language label on the file name's baseline; the icon centres on its own. */\n.file { display: flex; min-width: 0; align-items: baseline; gap: var(--space-related); color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); }\n.file > svg { flex: none; align-self: center; color: var(--text-muted); }\n.filename { position: relative; min-width: 0; overflow: hidden; color: var(--foreground); }\n/* Changing text stacks old and new words in one cell; reserve spans hold the widest label so the cell never snaps. */\n.swap { display: inline-grid; min-width: 0; white-space: nowrap; }\n.swap > span, .swapStack > span { grid-area: 1 / 1; min-width: 0; }\n.swapStack { display: grid; min-width: 0; }\n.swapText { display: block; overflow: hidden; text-overflow: ellipsis; }\n.reserve { visibility: hidden; }\n.sizer { position: absolute; top: 0; left: 0; white-space: nowrap; visibility: hidden; pointer-events: none; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.language { flex: none; color: var(--text-muted); font-family: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, monospace; font-size: var(--text-xs); font-weight: 400; }\n.language::before { content: \"·\"; margin-right: var(--space-related); color: var(--border-strong); }\n/* The source area follows its content height on a spring. Before it measures, a collapsed block clips from CSS. */\n.viewport { --code-collapsed: calc(var(--code-lines, 0) * var(--text-sm) * 1.7 + var(--space-comfort) * 2); overflow: hidden; }\n.pre { position: relative; overflow: auto; margin: 0; padding: var(--space-comfort); color: var(--foreground); font-family: ui-monospace, \"SFMono-Regular\", Menlo, Consolas, \"Liberation Mono\", monospace; font-size: var(--text-sm); font-variant-ligatures: none; line-height: 1.7; tab-size: 2; white-space: pre; }\n/* Block so new source can rise in with a transform; inline code ignores transforms. */\n.code { display: block; }\n.expand { display: flex; width: 100%; min-height: 44px; align-items: center; justify-content: center; gap: var(--space-related); border: 0; border-top: 1px solid var(--border-subtle); padding: 0 var(--space-inset); background: var(--surface-raised); color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.chevron { display: grid; flex: none; place-items: center; color: var(--text-muted); }\n/* Each role has contrast in both themes; color supports, but never replaces, source text. */\n.comment { color: var(--text-muted); font-style: italic; }\n.string { color: var(--syntax-string); }\n.number { color: var(--syntax-number); }\n.keyword { color: var(--syntax-keyword); }\n.type { color: var(--syntax-type); }\n.function { color: var(--syntax-function); }\n.property { color: var(--syntax-property); }\n.tag { color: var(--syntax-type); }\n.punctuation { color: var(--text-secondary); }\n@media (hover: hover) and (pointer: fine) { .expand:hover { background: var(--surface-muted); color: var(--foreground); } .expand:hover .chevron { color: var(--foreground); } }\n@media (max-width: 420px) { .header { padding-left: var(--space-group); } .header :global(button) { width: var(--control-height-sm); padding-inline: 0; } .header :global(button) > span:last-of-type { display: none; } .viewport { --code-collapsed: calc(var(--code-lines, 0) * var(--text-xs) * 1.7 + var(--space-inset) * 2); } .pre { padding: var(--space-inset); font-size: var(--text-xs); } }\n@media (prefers-reduced-motion: reduce) { .expand { transition: none; } }\n"},{"path":"registry/components/copy-button/copy-button.tsx","type":"registry:component","target":"@components/silicon-ui/copy-button/copy-button.tsx","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type TargetAndTransition, type Transition } from \"motion/react\";\nimport { CircleAlert, Copy } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { useCopyFeedback } from \"../lib/use-copy-feedback\";\nimport styles from \"./copy-button.module.css\";\n\nexport interface CopyButtonProps {\n  value: string;\n  label?: string;\n  className?: string;\n  iconOnly?: boolean;\n  variant?: \"outline\" | \"plain\";\n  disabled?: boolean;\n  onCopied?: () => void;\n}\n\n/** Copy feedback is deliberately unhurried: a slow, almost critically damped spring and long, soft crossfades read as calm, never busy.\n *  The same motion plays in reverse when the confirmation hands back to idle, so nothing ever snaps. */\nconst settle = { type: \"spring\", visualDuration: motionTokens.spring.slow.visualDuration, bounce: .06 } as const;\nconst enter = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } as const;\nconst leave = { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } as const;\nconst instant = { duration: motionTokens.duration.instant } as const;\nconst soft = `blur(${motionTokens.blur.soft}px)`;\nconst rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" };\nconst fadeIn: TargetAndTransition = { ...rest, opacity: 0 };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: instant };\n/** Icons trade places in one soft breath: the old glyph shrinks into a blur while the new one grows out of it on the slow spring. */\nconst iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: soft };\nconst iconOut: TargetAndTransition = { opacity: 0, scale: .6, filter: soft, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } };\nconst iconEnter: Transition = { scale: settle, opacity: { ...enter, delay: .03 }, filter: { ...enter, delay: .03 } };\n/** Letters rise about .3em out of a soft blur; the outgoing ones lift away a little faster. */\nconst glyphIn: TargetAndTransition = { opacity: 0, y: 4, filter: soft };\nconst glyphOut: TargetAndTransition = { opacity: 0, y: -3, filter: soft, transition: leave };\n\n/** The success tick draws itself from its short stroke, the way a hand would write it: quick to start, then easing into place while the icon settles. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return <svg width={16} height={16} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.75} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <motion.path d=\"M4 12l5 5L20 6\" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: .5, ease: [...motionTokens.ease.standard], delay: .05 }, opacity: { duration: .01, delay: .05 } }} />\n  </svg>;\n}\n\ntype Glyph = { id: string; char: string; order: number };\nconst toGlyphs = (chars: string[], seq: number): Glyph[] => chars.map((char, order) => ({ id: `${seq}:${order}`, char, order }));\n\n/** Shared leading and trailing characters keep their identity, so \"Copy\" to \"Copied\" only replaces the changed letters. */\nfunction useGlyphs(text: string) {\n  const [state, setState] = useState(() => ({ text, seq: 0, glyphs: toGlyphs([...text], 0) }));\n  if (state.text === text) return state.glyphs;\n  const prev = [...state.text], next = [...text];\n  let start = 0, end = 0;\n  while (start < prev.length && start < next.length && prev[start] === next[start]) start++;\n  while (end < prev.length - start && end < next.length - start && prev[prev.length - 1 - end] === next[next.length - 1 - end]) end++;\n  if (start < 2) start = 0;\n  if (end < 2) end = 0;\n  const seq = state.seq + 1;\n  const glyphs = [...state.glyphs.slice(0, start), ...toGlyphs(next.slice(start, next.length - end), seq), ...state.glyphs.slice(state.glyphs.length - end)];\n  setState({ text, seq, glyphs });\n  return glyphs;\n}\n\n/**\n * The label cell already reserves its widest state, so the row stays put and only the letters move. It is a layout root:\n * the letters measure their glide inside the label only, so when the button itself moves (a page entering, a panel\n * sliding, a list reflowing) they ride along instead of flying in from where the button used to be.\n */\nfunction MorphText({ text, reduced }: { text: string; reduced: boolean }) {\n  const glyphs = useGlyphs(text);\n  return <motion.span className={styles.glyphs} layoutRoot>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      {glyphs.map(glyph => <motion.span key={glyph.id} className={styles.glyph} layout={reduced ? false : \"position\"} layoutDependency={text} initial={reduced ? fadeIn : glyphIn} animate={rest} exit={reduced ? fadeOut : glyphOut} transition={reduced ? instant : { ...enter, delay: Math.min(glyph.order * .02, .12), layout: settle }}>{glyph.char}</motion.span>)}\n    </AnimatePresence>\n  </motion.span>;\n}\n\nexport function CopyButton({ value, label = \"Copy\", className, iconOnly = false, variant = \"outline\", disabled, onCopied }: CopyButtonProps) {\n  const { state, copy } = useCopyFeedback();\n  const reduced = useReducedMotion() ?? false;\n  const text = state === \"copied\" ? \"Copied\" : state === \"error\" ? \"Failed\" : label;\n\n  async function handleCopy() {\n    if (await copy(value)) onCopied?.();\n  }\n\n  return <><button\n    type=\"button\"\n    className={[styles.button, iconOnly && styles.iconOnly, variant === \"plain\" && styles.plain, className].filter(Boolean).join(\" \")}\n    onClick={() => void handleCopy()}\n    aria-label={label}\n    data-copy-state={state}\n    disabled={disabled}\n  >\n    <span className={styles.icon} aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        <motion.span key={state} className={styles.iconInner} data-state={state} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? instant : iconEnter}>\n          {state === \"copied\" ? <DrawnCheck reduced={reduced} /> : state === \"error\" ? <CircleAlert size={16} strokeWidth={1.75} /> : <Copy size={16} strokeWidth={1.75} />}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n    {!iconOnly && <span className={styles.label} aria-hidden=\"true\">\n      <span className={styles.measure}>{label}</span><span className={styles.measure}>Copied</span><span className={styles.measure}>Failed</span>\n      <MorphText text={text} reduced={reduced} />\n    </span>}\n  </button><span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{state === \"idle\" ? \"\" : state === \"error\" ? `${label}: Could not copy` : `${label}: Copied`}</span></>;\n}\n"},{"path":"registry/components/copy-button/copy-button.module.css","type":"registry:component","target":"@components/silicon-ui/copy-button/copy-button.module.css","content":"/* Width never changes: the label cell reserves its widest state while icon and letters crossfade inside it. Press releases on the spring curve. */\n.button { display: inline-flex; width: max-content; max-width: 100%; min-height: var(--control-height-sm); align-items: center; justify-content: center; gap: var(--space-related); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-group); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.button:active:not(:disabled) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-instant); transition-timing-function: var(--ease-standard); }\n.iconOnly:active:not(:disabled) { transform: scale(.96); }\n@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { background: var(--surface-muted); border-color: var(--border-strong); } .plain:hover:not(:disabled) { border-color: transparent; background: var(--surface-muted); color: var(--foreground); } }\n.button:disabled { cursor: not-allowed; opacity: .5; }\n/* Each icon carries its own color, so the outgoing glyph never tints on its way out. */\n.icon { position: relative; display: grid; width: 16px; height: 16px; flex: none; }\n.iconInner { position: absolute; inset: 0; display: grid; place-items: center; }\n.iconInner[data-state=\"copied\"] { color: var(--success); }\n.iconInner[data-state=\"error\"] { color: var(--danger); }\n/* The inline padding gives blurred edge letters room before the horizontal clip; the negative margin keeps the box the same size. */\n.label { display: grid; min-width: 0; margin-inline: calc(var(--space-tight) * -1); padding-inline: var(--space-tight); overflow-x: clip; text-align: left; white-space: nowrap; }\n.measure, .glyphs { grid-area: 1 / 1; }\n.measure { visibility: hidden; }\n.glyphs { position: relative; display: inline-flex; justify-self: start; white-space: pre; }\n.glyph { display: inline-block; }\n.iconOnly { width: var(--control-height-sm); padding: 0; }\n.plain { border-color: transparent; background: transparent; color: var(--text-secondary); }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } .button:active:not(:disabled) { transform: none; } }\n"},{"path":"lib/use-copy-feedback.ts","type":"registry:lib","target":"@components/silicon-ui/lib/use-copy-feedback.ts","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type CopyFeedbackState = \"idle\" | \"copied\" | \"error\";\n\n/** Shared clipboard state for actions that render their own button or menu. */\nexport function useCopyFeedback(duration = 1900) {\n  const [state, setState] = useState<CopyFeedbackState>(\"idle\");\n  const [activeKey, setActiveKey] = useState<string | null>(null);\n  const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const reset = useCallback(() => {\n    if (timeout.current) clearTimeout(timeout.current);\n    timeout.current = null;\n    setState(\"idle\");\n    setActiveKey(null);\n  }, []);\n\n  useEffect(() => () => {\n    if (timeout.current) clearTimeout(timeout.current);\n  }, []);\n\n  const copy = useCallback(async (value: string, key = \"default\") => {\n    if (timeout.current) clearTimeout(timeout.current);\n    setActiveKey(key);\n    try {\n      await navigator.clipboard.writeText(value);\n      setState(\"copied\");\n      timeout.current = setTimeout(reset, duration);\n      return true;\n    } catch {\n      setState(\"error\");\n      timeout.current = setTimeout(reset, duration);\n      return false;\n    }\n  }, [duration, reset]);\n\n  return { state, activeKey, copy, reset };\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/code-block","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/code-block","markdown":"https://ui.teamofsilicons.com/code-block.md","tags":["content","developer"],"status":"stable"}}