{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-metadata","type":"registry:component","title":"Morphing Metadata","description":"A record that grows from compact summary into structured detail.","dependencies":[],"categories":["systems","metadata","progressive-disclosure","view-transitions"],"files":[{"path":"components/lab/morphing-metadata/morphing-metadata.tsx","target":"components/lab/morphing-metadata/morphing-metadata.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useId, useState, type CSSProperties } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport \"./morphing-metadata.css\";\n\nexport type MetadataRecord = {\n  id: string;\n  title: string;\n  kind: string;\n  summary: Record<string, string>;\n  detail: { label: string; value: string }[];\n};\n\nexport type MorphingMetadataProps = {\n  /** The record to render. */\n  record: MetadataRecord;\n  /** Controlled expansion; omit to let the record toggle itself. */\n  expanded?: boolean;\n  /** Initial state in uncontrolled use. */\n  defaultExpanded?: boolean;\n  /**\n   * `auto` morphs with the View Transitions API where it exists and\n   * settles with a CSS row animation otherwise. `view-transition` asks\n   * for the API only; `none` swaps states instantly.\n   */\n  transition?: \"auto\" | \"view-transition\" | \"none\";\n  /** Notifies the host every time the record opens or closes. */\n  onExpandedChange?: (expanded: boolean) => void;\n  className?: string;\n};\n\ntype Row = { key: string; label: string; value: string };\n\nfunction slug(value: string): string {\n  const clean = value\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\");\n  return clean || \"x\";\n}\n\nfunction prefersReducedMotion(): boolean {\n  return (\n    typeof window !== \"undefined\" &&\n    typeof window.matchMedia === \"function\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n\n/**\n * Merge detail over summary, case-insensitively by label: every summary\n * pair keeps its place, but when a detail row shares its label the detail\n * row replaces the pair instead of duplicating it; the remaining detail\n * rows follow. Both states render the same values — expansion only adds.\n */\nfunction buildRows(record: MetadataRecord): Row[] {\n  const detailByLabel = new Map<string, { label: string; value: string }>();\n  for (const row of record.detail) {\n    const key = row.label.trim().toLowerCase();\n    if (!detailByLabel.has(key)) detailByLabel.set(key, row);\n  }\n\n  const rows: Row[] = [];\n  const seen = new Set<string>();\n  for (const [label, value] of Object.entries(record.summary)) {\n    const key = label.trim().toLowerCase();\n    if (seen.has(key)) continue;\n    seen.add(key);\n    const match = detailByLabel.get(key);\n    rows.push(\n      match\n        ? { key, label: match.label, value: match.value }\n        : { key, label, value },\n    );\n  }\n  for (const row of record.detail) {\n    const key = row.label.trim().toLowerCase();\n    if (seen.has(key)) continue;\n    seen.add(key);\n    rows.push({ key, label: row.label, value: row.value });\n  }\n  return rows;\n}\n\n/**\n * A view-transition-name may appear only once in a document per capture,\n * so names are computed from the labels present in the current state and\n * duplicates (two labels slugifying alike) are skipped silently.\n */\nfunction namesFor(labels: string[], base: string): (string | undefined)[] {\n  const seen = new Set<string>();\n  return labels.map((label) => {\n    const name = `${base}-${slug(label)}`;\n    if (seen.has(name)) return undefined;\n    seen.add(name);\n    return name;\n  });\n}\n\nfunction nameStyle(name: string | undefined): CSSProperties | undefined {\n  return name ? { viewTransitionName: name } : undefined;\n}\n\nfunction RecordGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" width=\"15\" height=\"15\" aria-hidden=\"true\" focusable=\"false\">\n      <path\n        d=\"M3.6 1.9h5l3.8 3.8v8.1a.8.8 0 0 1-.8.8H3.6a.8.8 0 0 1-.8-.8V2.7a.8.8 0 0 1 .8-.8Z\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.2\"\n      />\n      <path d=\"M8.6 2v3.7h3.7\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.2\" />\n    </svg>\n  );\n}\n\n/**\n * MORPHING METADATA — one surface, two densities.\n *\n * Compact is a single row: glyph, title, kind and the summary pairs as\n * chips. Expanded keeps that row in place and turns the same values into\n * a definition list; values shared between summary and detail are named\n * with the same `view-transition-name`, so a supporting browser morphs\n * them from chip to row instead of opening a panel underneath. The\n * View Transition is skipped under prefers-reduced-motion or without the\n * API, where `auto` falls back to a short CSS settle on the new rows.\n */\nexport function MorphingMetadata({\n  record,\n  expanded,\n  defaultExpanded = false,\n  transition = \"auto\",\n  onExpandedChange,\n  className,\n}: MorphingMetadataProps) {\n  const uid = useId();\n  const [internal, setInternal] = useState(defaultExpanded);\n  const [cssMorph, setCssMorph] = useState(false);\n\n  const isControlled = expanded !== undefined;\n  const isExpanded = isControlled ? expanded : internal;\n\n  const rows = buildRows(record);\n  const summaryEntries = Object.entries(record.summary);\n  const namesOn = transition !== \"none\";\n  const vtBase = `mm-${slug(record.id)}`;\n  const compactNames = namesOn\n    ? namesFor(\n        summaryEntries.map(([label]) => label),\n        vtBase,\n      )\n    : [];\n  const detailNames = namesOn ? namesFor(rows.map((row) => row.label), vtBase) : [];\n\n  const bodyId = `${uid}-body`;\n\n  const commit = (next: boolean) => {\n    if (!isControlled) setInternal(next);\n    onExpandedChange?.(next);\n  };\n\n  const handleToggle = () => {\n    const next = !isExpanded;\n    if (transition !== \"none\") {\n      const reduced = prefersReducedMotion();\n      if (\n        !reduced &&\n        typeof document !== \"undefined\" &&\n        typeof document.startViewTransition === \"function\"\n      ) {\n        let updated = false;\n        try {\n          /* flushSync keeps the React commit inside the capture window,\n             which is what makes the shared value names actually morph. */\n          document.startViewTransition(() => {\n            updated = true;\n            flushSync(() => commit(next));\n          });\n          return;\n        } catch {\n          if (updated) return;\n        }\n      }\n      if (transition === \"auto\" && !reduced) setCssMorph(true);\n    }\n    commit(next);\n  };\n\n  return (\n    <div\n      className={`mm-root${className ? ` ${className}` : \"\"}`}\n      data-expanded={isExpanded ? \"true\" : \"false\"}\n      data-transition={transition}\n      data-css-morph={cssMorph ? \"true\" : undefined}\n      style={namesOn ? { viewTransitionName: vtBase } : undefined}\n    >\n      <div className=\"mm-head\">\n        <span className=\"mm-glyph\" aria-hidden=\"true\">\n          <RecordGlyph />\n        </span>\n\n        <span className=\"mm-heading\">\n          <span className=\"mm-title\">{record.title}</span>\n          <span className=\"mm-kind\">{record.kind}</span>\n        </span>\n\n        {!isExpanded && (\n          <div className=\"mm-chips\" id={bodyId}>\n            {summaryEntries.map(([label, value], index) => (\n              <span className=\"mm-chip\" key={`${label}-${index}`}>\n                <span className=\"mm-chip-label\">{label}</span>\n                <span className=\"mm-chip-value\" style={nameStyle(compactNames[index])}>\n                  {value}\n                </span>\n              </span>\n            ))}\n          </div>\n        )}\n\n        <button\n          type=\"button\"\n          className=\"mm-toggle\"\n          aria-expanded={isExpanded}\n          aria-controls={bodyId}\n          aria-label={`${isExpanded ? \"Hide\" : \"Show\"} details for ${record.title}`}\n          onClick={handleToggle}\n        >\n          <span>{isExpanded ? \"Hide details\" : \"Show details\"}</span>\n          <svg className=\"mm-caret\" viewBox=\"0 0 10 10\" width=\"9\" height=\"9\" aria-hidden=\"true\" focusable=\"false\">\n            <path\n              d=\"M1.7 3.4 5 6.7l3.3-3.3\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.4\"\n              strokeLinecap=\"round\"\n            />\n          </svg>\n        </button>\n      </div>\n\n      {isExpanded && (\n        <dl className=\"mm-detail\" id={bodyId}>\n          {rows.map((row, index) => (\n            <div className=\"mm-row\" key={row.key}>\n              <dt className=\"mm-term\">{row.label}</dt>\n              <dd className=\"mm-value\" style={nameStyle(detailNames[index])}>\n                {row.value}\n              </dd>\n            </div>\n          ))}\n        </dl>\n      )}\n    </div>\n  );\n}\n"},{"path":"components/lab/morphing-metadata/morphing-metadata.css","target":"components/lab/morphing-metadata/morphing-metadata.css","type":"registry:file","content":"/* ============================================================\n   MORPHING METADATA — self-contained styles.\n   Prefix .mm-  ·  every token is scoped on .mm-root with a fallback,\n   so the component works in a codebase with no Lab stylesheet.\n   ============================================================ */\n\n.mm-root {\n  --mm-ink: #141418;\n  --mm-muted: #5f5f68;\n  --mm-faint: #8c8c94;\n  --mm-line: rgba(20, 20, 24, 0.1);\n  --mm-line-soft: rgba(20, 20, 24, 0.06);\n  --mm-surface: #ffffff;\n  --mm-sunken: rgba(20, 20, 24, 0.045);\n  --mm-accent: #4f27e0;\n  --mm-ease: cubic-bezier(0.22, 1, 0.36, 1);\n  --mm-duration: 320ms;\n\n  display: block;\n  color: var(--mm-ink, #141418);\n  background: var(--mm-surface, #ffffff);\n  border: 1px solid var(--mm-line, rgba(20, 20, 24, 0.1));\n  border-radius: 12px;\n  font-size: 0.8125rem;\n  line-height: 1.5;\n}\n\n/* ---------- compact header: glyph · heading · chips · toggle ---------- */\n\n.mm-head {\n  display: grid;\n  grid-template-columns: auto auto minmax(0, 1fr) auto;\n  grid-template-areas: \"glyph heading chips toggle\";\n  align-items: center;\n  column-gap: 10px;\n  row-gap: 7px;\n  padding: 10px 12px;\n}\n\n.mm-glyph {\n  grid-area: glyph;\n  display: inline-flex;\n  align-items: center;\n  color: var(--mm-faint, #8c8c94);\n}\n\n.mm-glyph svg {\n  display: block;\n}\n\n.mm-heading {\n  grid-area: heading;\n  display: flex;\n  flex-wrap: wrap;\n  align-items: baseline;\n  gap: 1px 8px;\n  min-width: 0;\n}\n\n.mm-title {\n  font-size: 0.9rem;\n  font-weight: 500;\n  letter-spacing: -0.01em;\n  overflow-wrap: anywhere;\n}\n\n.mm-kind {\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.625rem;\n  letter-spacing: 0.08em;\n  text-transform: uppercase;\n  color: var(--mm-faint, #8c8c94);\n}\n\n/* ---------- compact: summary chips ---------- */\n\n.mm-chips {\n  grid-area: chips;\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 6px;\n  min-width: 0;\n}\n\n.mm-chip {\n  display: inline-flex;\n  align-items: baseline;\n  gap: 5px;\n  max-width: 100%;\n  padding: 3px 8px;\n  border-radius: 999px;\n  background: var(--mm-sunken, rgba(20, 20, 24, 0.045));\n}\n\n.mm-chip-label {\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.59375rem;\n  letter-spacing: 0.09em;\n  text-transform: uppercase;\n  color: var(--mm-muted, #5f5f68);\n}\n\n.mm-chip-value {\n  font-size: 0.75rem;\n  font-weight: 500;\n  overflow-wrap: anywhere;\n}\n\n/* ---------- expanded: the same values as a definition list ---------- */\n\n.mm-detail {\n  margin: 0;\n  padding: 2px 12px 10px;\n}\n\n.mm-row {\n  display: flex;\n  align-items: baseline;\n  justify-content: space-between;\n  gap: 14px;\n  padding: 7px 0;\n  border-top: 1px solid var(--mm-line-soft, rgba(20, 20, 24, 0.06));\n}\n\n.mm-row:first-child {\n  border-top-color: var(--mm-line, rgba(20, 20, 24, 0.1));\n}\n\n.mm-term {\n  flex: 0 0 auto;\n  margin: 0;\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.59375rem;\n  letter-spacing: 0.09em;\n  text-transform: uppercase;\n  color: var(--mm-muted, #5f5f68);\n  padding-top: 2px;\n}\n\n.mm-value {\n  margin: 0;\n  min-width: 0;\n  text-align: right;\n  font-size: 0.8125rem;\n  font-weight: 500;\n  overflow-wrap: anywhere;\n}\n\n/* ---------- toggle ---------- */\n\n.mm-toggle {\n  grid-area: toggle;\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 4px 10px;\n  border: 1px solid var(--mm-line, rgba(20, 20, 24, 0.1));\n  border-radius: 999px;\n  background: transparent;\n  color: var(--mm-muted, #5f5f68);\n  font: inherit;\n  font-size: 0.6875rem;\n  font-weight: 500;\n  line-height: 1.3;\n  white-space: nowrap;\n  cursor: pointer;\n  transition:\n    background-color 150ms ease,\n    color 150ms ease,\n    border-color 150ms ease;\n}\n\n.mm-toggle:hover {\n  background: var(--mm-sunken, rgba(20, 20, 24, 0.045));\n  color: var(--mm-ink, #141418);\n}\n\n.mm-toggle:focus-visible {\n  outline: 2px solid var(--mm-accent, #4f27e0);\n  outline-offset: 2px;\n}\n\n.mm-caret {\n  flex: none;\n  transition: transform var(--mm-duration, 320ms) var(--mm-ease, cubic-bezier(0.22, 1, 0.36, 1));\n}\n\n.mm-root[data-expanded=\"true\"] .mm-caret {\n  transform: rotate(180deg);\n}\n\n/* ---------- degradation: CSS settle when no View Transition API ---------- */\n\n@media (prefers-reduced-motion: no-preference) {\n  .mm-root[data-transition=\"auto\"][data-css-morph=\"true\"] .mm-chip,\n  .mm-root[data-transition=\"auto\"][data-css-morph=\"true\"] .mm-row {\n    animation: mm-settle var(--mm-duration, 320ms)\n      var(--mm-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;\n  }\n}\n\n@keyframes mm-settle {\n  from {\n    opacity: 0;\n    transform: translateY(4px);\n  }\n  to {\n    opacity: 1;\n    transform: none;\n  }\n}\n\n/* ---------- narrow viewports ---------- */\n\n@media (max-width: 30rem) {\n  .mm-head {\n    grid-template-columns: auto minmax(0, 1fr) auto;\n    grid-template-areas:\n      \"glyph heading toggle\"\n      \"chips chips chips\";\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .mm-caret {\n    transition: none;\n  }\n}\n"}]}