{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "portfolio-exhibition",
  "title": "Portfolio exhibition",
  "description": "The actual Portfolio viewport stage, Working on rail, material cards, search and OR technology filters.",
  "dependencies": [
    "@fontsource/geist",
    "@phosphor-icons/react",
    "clsx",
    "motion",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "src/registry/portfolio-exhibition.tsx",
      "content": "\"use client\";\nimport { ArrowRightIcon } from \"@phosphor-icons/react/dist/ssr/ArrowRight\";\nimport { MagnifyingGlassIcon } from \"@phosphor-icons/react/dist/ssr/MagnifyingGlass\";\nimport {\n  type KeyboardEvent,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { PortfolioImage as Image } from \"./portfolio-image\";\nimport { PortfolioTip as Tip } from \"./portfolio-tip\";\nimport { PortfolioProjectCard } from \"./portfolio-project-card\";\nimport { PortfolioTechChip } from \"./portfolio-badges\";\nimport type { PortfolioProject, PortfolioMedia } from \"./portfolio-types\";\nimport { cn } from \"./cn\";\nimport {\n  type FinderKind,\n  type FinderQuery,\n  defaultFinderQuery,\n  finderEmptyMessage,\n  kindFilters,\n  parseFinderQuery,\n  projectMatches,\n  projectStackNames,\n  stagedProject,\n  toggleFinderTech,\n} from \"./portfolio-finder-query\";\nimport { splitWorkingOn } from \"./portfolio-working-on\";\nimport \"./portfolio.css\";\nimport { pluralize } from \"./pluralize\";\nconst authoredLink =\n  \"pf-action text-[var(--pf-brand-blue-light)] focus-visible:outline-[var(--pf-brand-blue-light)] inline-flex items-center gap-1.5 rounded-sm underline decoration-transparent underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-4\";\nfunction readPortfolioQuery(search: string, tech: string[], prefix: string) {\n  const p = new URLSearchParams(search);\n  return parseFinderQuery(\n    {\n      q: p.get(prefix + \"q\") ?? undefined,\n      kind: p.get(prefix + \"kind\") ?? undefined,\n      tech: p.get(prefix + \"tech\") ?? undefined,\n    },\n    tech,\n  );\n}\nfunction writePortfolioQuery(query: FinderQuery, prefix: string) {\n  const url = new URL(window.location.href);\n  for (const [key, value] of [\n    [\"q\", query.q.trim()],\n    [\"kind\", query.kind === \"all\" ? \"\" : query.kind],\n    [\"tech\", query.tech.join(\",\")],\n  ]) {\n    if (value) url.searchParams.set(prefix + key, value);\n    else url.searchParams.delete(prefix + key);\n  }\n  window.history.replaceState(window.history.state, \"\", url);\n}\nfunction nextKindIndex(current: FinderKind, key: string) {\n  const i = kindFilters.findIndex((item) => item.id === current);\n  const last = kindFilters.length - 1;\n  if (key === \"ArrowRight\" || key === \"ArrowDown\") {\n    return i === last ? 0 : i + 1;\n  }\n  if (key === \"ArrowLeft\" || key === \"ArrowUp\") {\n    return i === 0 ? last : i - 1;\n  }\n  if (key === \"Home\") {\n    return 0;\n  }\n  if (key === \"End\") {\n    return last;\n  }\n  return -1;\n}\n\n/**\n * Exhibition stage. Empty query lists Working on, then All projects. From lg\n * the stage is capped to the viewport, with search, kind, and tech on the left\n * and the list scrolling on the right. Below lg the page scrolls once: a capped\n * stage there cut the filters off mid-row behind a second scroll box. The\n * backdrop still covers one viewport, and the section clips sideways so a\n * tooltip near the edge cannot scroll the page. Finding wins.\n */\nexport function PortfolioExhibitionFinder({\n  projects,\n  workingOnIds = [],\n  author,\n  initialQuery = defaultFinderQuery,\n  urlSync,\n}: {\n  projects: PortfolioProject[];\n  workingOnIds?: string[];\n  author: { name: string; href: string };\n  initialQuery?: FinderQuery;\n  urlSync?: { prefix: string };\n}) {\n  const id = useId();\n  const techChips = useMemo(\n    () => [...new Set(projects.flatMap(projectStackNames))],\n    [projects],\n  );\n  const [query, setQuery] = useState(initialQuery);\n  const [techSearch, setTechSearch] = useState(\"\");\n  const [techChoicesScroll, setTechChoicesScroll] = useState(false);\n  const headerRef = useRef<HTMLElement>(null);\n  const techChoicesRef = useRef<HTMLDivElement>(null);\n  const techSearchRef = useRef<HTMLDivElement>(null);\n  const searchableTech = techChips.length > 10 || techChoicesScroll;\n  const matchingTech = techChips.filter((name) =>\n    name.toLowerCase().includes(techSearch.trim().toLowerCase()),\n  );\n\n  // Choice search never changes the project's query or selected technologies.\n  // Subtract its own height so showing it cannot keep a short list searchable.\n  useEffect(() => {\n    if (techSearch.trim()) return;\n    const header = headerRef.current;\n    const choices = techChoicesRef.current;\n    if (!header || !choices) return;\n    const measure = () => {\n      const search = techSearchRef.current;\n      const searchHeight = search\n        ? search.offsetHeight + parseFloat(getComputedStyle(search).marginTop)\n        : 0;\n      setTechChoicesScroll(\n        header.scrollHeight - searchHeight > header.clientHeight + 1,\n      );\n    };\n    const observer = new ResizeObserver(measure);\n    observer.observe(header);\n    observer.observe(choices);\n    measure();\n    return () => observer.disconnect();\n  }, [techChips, techSearch]);\n  const [stagedId, setStagedId] = useState<string | null>(null);\n  const kindRefs = useRef<\n    Partial<Record<FinderKind, HTMLButtonElement | null>>\n  >({});\n\n  useEffect(() => {\n    if (!urlSync) return;\n    const onPop = () => {\n      setQuery(\n        readPortfolioQuery(window.location.search, techChips, urlSync.prefix),\n      );\n    };\n    onPop();\n    window.addEventListener(\"popstate\", onPop);\n    return () => window.removeEventListener(\"popstate\", onPop);\n  }, [urlSync?.prefix, techChips]);\n\n  const setFinderQuery = (next: FinderQuery) => {\n    setQuery(next);\n    if (urlSync) writePortfolioQuery(next, urlSync.prefix);\n  };\n\n  const results = useMemo(\n    () =>\n      projects.filter((project) =>\n        projectMatches(project, query, project.studyLink?.label),\n      ),\n    [projects, query],\n  );\n\n  const empty = results.length ? \"\" : finderEmptyMessage(query);\n  const filtered = Boolean(\n    query.q.trim() || query.kind !== \"all\" || query.tech.length,\n  );\n  const bands = splitWorkingOn(results, workingOnIds, filtered);\n  const staged = stagedProject(bands.stagePool, stagedId);\n  const fieldStill = staged ? staged.images[0] : undefined;\n\n  const onKindKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    const next = nextKindIndex(query.kind, event.key);\n    if (next < 0) {\n      return;\n    }\n    event.preventDefault();\n    const id = kindFilters[next].id;\n    setFinderQuery({ ...query, kind: id });\n    kindRefs.current[id]?.focus();\n  };\n\n  const onTechToggle = (name: string) => {\n    setFinderQuery({ ...query, tech: toggleFinderTech(query.tech, name) });\n  };\n\n  return (\n    <section\n      id={id}\n      data-c=\"ExhibitionFinder\"\n      data-working-on={bands.showBands ? \"on\" : \"off\"}\n      aria-labelledby={`${id}-heading`}\n      className=\"pf-scope relative flex min-w-0 flex-col overflow-x-clip lg:h-[calc(100svh-4rem)] lg:min-h-[32rem] lg:overflow-hidden\"\n    >\n      <a href={`#${id}-list`} className=\"sr-only focus:not-sr-only\">\n        Skip to projects\n      </a>\n      <ExhibitionField file={fieldStill} />\n      <div className=\"relative z-10 mx-auto flex w-full max-w-[128.75rem] flex-col lg:h-full lg:min-h-0 lg:flex-row\">\n        <header\n          ref={headerRef}\n          className=\"flex flex-col gap-5 px-4 pt-6 pb-4 lg:min-h-0 lg:min-w-0 lg:flex-1 lg:justify-end-safe lg:overflow-y-auto lg:pb-6\"\n        >\n          <div className=\"flex flex-col gap-2\">\n            <h1\n              id={`${id}-heading`}\n              className=\"text-[var(--pf-ink-bright)] text-5xl leading-[1.05] font-semibold tracking-tight sm:text-6xl md:text-7xl lg:text-8xl\"\n            >\n              Projects\n            </h1>\n            <p className=\"text-[var(--pf-brand-blue-light)] text-base leading-6\">\n              Authored by{\" \"}\n              <a href={author.href} className={authoredLink}>\n                {author.name}\n                <ArrowRightIcon aria-hidden=\"true\" size={14} weight=\"bold\" />\n              </a>\n            </p>\n          </div>\n          <label className=\"relative w-full min-w-0\">\n            <span className=\"sr-only\">Search projects</span>\n            <MagnifyingGlassIcon\n              aria-hidden=\"true\"\n              size={18}\n              className=\"text-[var(--pf-ink-muted)] pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2\"\n            />\n            <input\n              id={`${id}-search`}\n              type=\"search\"\n              value={query.q}\n              onChange={(event) =>\n                setFinderQuery({ ...query, q: event.target.value })\n              }\n              placeholder=\"Search names, status or technology…\"\n              className=\"pf-input border-[var(--pf-surface-accent)] bg-[var(--pf-surface-raised)] text-[var(--pf-ink)] placeholder:text-[var(--pf-ink-muted)] focus-visible:outline-[var(--pf-brand-blue-light)] h-14 w-full min-w-0 rounded-md border pr-12 pl-11 text-base focus-visible:outline-2\"\n            />\n          </label>\n\n          <div>\n            <p id={`${id}-kind`} className=\"sr-only\">\n              Show\n            </p>\n            <div\n              role=\"radiogroup\"\n              aria-labelledby={`${id}-kind`}\n              className=\"flex flex-wrap gap-2\"\n            >\n              {kindFilters.map((item, index) => (\n                <Tip\n                  key={item.id}\n                  label={`Show ${item.label} projects`}\n                  align={index === 0 ? \"start\" : \"center\"}\n                >\n                  <button\n                    ref={(el) => {\n                      kindRefs.current[item.id] = el;\n                    }}\n                    type=\"button\"\n                    role=\"radio\"\n                    aria-checked={query.kind === item.id}\n                    tabIndex={query.kind === item.id ? 0 : -1}\n                    onClick={() => setFinderQuery({ ...query, kind: item.id })}\n                    onKeyDown={onKindKeyDown}\n                    className={cn(\n                      \"focus-visible:outline-[var(--pf-brand-blue-light)] rounded-md px-3 py-2 text-base transition\",\n                      \"focus-visible:outline-2 focus-visible:outline-offset-2\",\n                      query.kind === item.id\n                        ? \"bg-[var(--pf-surface-raised)] text-[var(--pf-brand-blue-light)] ring-[var(--pf-brand-blue-light)]/50 ring-1\"\n                        : \"text-[var(--pf-ink-muted)] hover:text-[var(--pf-brand-blue-light)]\",\n                    )}\n                  >\n                    {item.label}\n                  </button>\n                </Tip>\n              ))}\n            </div>\n          </div>\n          <details className=\"pf-inset px-3 py-2\" open>\n            <summary className=\"text-[var(--pf-ink)] cursor-pointer text-base\">\n              Tech\n            </summary>\n            {searchableTech && (\n              <div ref={techSearchRef} className=\"mt-3\">\n                <input\n                  type=\"search\"\n                  aria-label=\"Search technologies\"\n                  value={techSearch}\n                  onChange={(event) => setTechSearch(event.target.value)}\n                  onKeyDown={(event) => {\n                    if (event.key === \"Escape\") {\n                      event.preventDefault();\n                      setTechSearch(\"\");\n                    }\n                  }}\n                  placeholder=\"Search technologies…\"\n                  className=\"pf-input border-[var(--pf-surface-accent)] bg-[var(--pf-surface-raised)] text-[var(--pf-ink)] placeholder:text-[var(--pf-ink-muted)] focus-visible:outline-[var(--pf-brand-blue-light)] min-h-11 w-full min-w-0 rounded-md border px-3 text-base focus-visible:outline-2\"\n                />\n              </div>\n            )}\n            <div ref={techChoicesRef} className=\"mt-3 flex flex-wrap gap-2\">\n              {matchingTech.map((chip) => (\n                <PortfolioTechChip\n                  key={chip}\n                  name={chip}\n                  on={query.tech.includes(chip)}\n                  tone=\"name\"\n                  onToggle={onTechToggle}\n                />\n              ))}\n            </div>\n            {matchingTech.length === 0 && (\n              <p\n                role=\"status\"\n                className=\"text-[var(--pf-ink-muted)] mt-3 text-base\"\n              >\n                No technologies match.\n              </p>\n            )}\n            {query.tech.length > 0 && (\n              <button\n                type=\"button\"\n                className=\"text-[var(--pf-brand-blue-light)] focus-visible:outline-[var(--pf-brand-blue-light)] mt-3 rounded-sm text-base underline decoration-transparent underline-offset-4 hover:decoration-current focus-visible:outline-2 focus-visible:outline-offset-4\"\n                onClick={() => setFinderQuery({ ...query, tech: [] })}\n              >\n                Clear tech\n              </button>\n            )}\n          </details>\n          <p className=\"text-[var(--pf-ink-muted)] text-base\">\n            {pluralize(\n              results.length,\n              filtered\n                ? { one: \"matching project\", other: \"matching projects\" }\n                : { one: \"project\", other: \"projects\" },\n            )}\n          </p>\n        </header>\n        <div\n          id={`${id}-list`}\n          className=\"min-w-0 px-4 pt-2 pb-6 lg:min-h-0 lg:w-[28rem] lg:flex-none lg:overflow-y-auto lg:pt-6 lg:pr-4\"\n        >\n          <noscript>\n            <p className=\"text-[var(--pf-ink-muted)] text-base\">\n              Search needs JavaScript. The list below is complete.\n            </p>\n          </noscript>\n          {empty ? (\n            <p className=\"text-[var(--pf-ink)] py-8\">{empty}</p>\n          ) : bands.showBands ? (\n            <>\n              {bands.workingOn.length > 0 ? (\n                <section aria-labelledby={`${id}-working`} className=\"min-w-0\">\n                  <h2\n                    id={`${id}-working`}\n                    className=\"text-[var(--pf-ink-bright)] mb-3 text-xl font-semibold tracking-tight\"\n                  >\n                    Working on\n                  </h2>\n                  <FinderProjectList\n                    projects={bands.workingOn}\n                    stagedId={staged?.id}\n                    onStage={setStagedId}\n                    activeTech={query.tech}\n                    onTechToggle={onTechToggle}\n                    startIndex={0}\n                  />\n                </section>\n              ) : null}\n              {bands.rest.length > 0 ? (\n                <section\n                  aria-labelledby={`${id}-all`}\n                  className={cn(\n                    \"min-w-0\",\n                    bands.workingOn.length > 0 && \"mt-8\",\n                  )}\n                >\n                  <h2\n                    id={`${id}-all`}\n                    className=\"text-[var(--pf-ink-bright)] mb-3 text-xl font-semibold tracking-tight\"\n                  >\n                    All projects\n                  </h2>\n                  <FinderProjectList\n                    projects={bands.rest}\n                    stagedId={staged?.id}\n                    onStage={setStagedId}\n                    activeTech={query.tech}\n                    onTechToggle={onTechToggle}\n                    startIndex={bands.workingOn.length}\n                  />\n                </section>\n              ) : null}\n            </>\n          ) : (\n            <FinderProjectList\n              projects={results}\n              stagedId={staged?.id}\n              onStage={setStagedId}\n              activeTech={query.tech}\n              onTechToggle={onTechToggle}\n              startIndex={0}\n            />\n          )}\n        </div>\n      </div>\n    </section>\n  );\n}\n\nfunction FinderProjectList({\n  projects,\n  stagedId,\n  onStage,\n  activeTech,\n  onTechToggle,\n  startIndex,\n}: {\n  projects: PortfolioProject[];\n  stagedId: string | undefined;\n  onStage: (id: string) => void;\n  activeTech: string[];\n  onTechToggle: (name: string) => void;\n  startIndex: number;\n}) {\n  return (\n    <ul data-c=\"FinderProjectList\" className=\"flex flex-col gap-4\">\n      {projects.map((project, index) => (\n        <li\n          key={project.id}\n          className=\"min-w-0\"\n          onMouseEnter={() => onStage(project.id)}\n          onFocusCapture={() => onStage(project.id)}\n        >\n          <PortfolioProjectCard\n            project={project}\n            loading={startIndex + index === 0 ? \"eager\" : \"lazy\"}\n            activeTech={activeTech}\n            onTechToggle={onTechToggle}\n            className={\n              project.id === stagedId\n                ? \"ring-[var(--pf-brand-blue-light)] ring-2\"\n                : undefined\n            }\n          />\n        </li>\n      ))}\n    </ul>\n  );\n}\nfunction ExhibitionField({ file }: { file: PortfolioMedia | undefined }) {\n  if (!file) return null;\n  return (\n    <div\n      data-c=\"ExhibitionField\"\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-x-0 top-0 h-[calc(100svh-4rem)] max-h-full lg:inset-0 lg:h-auto\"\n    >\n      <Image\n        key={file.id}\n        src={file.kind === \"video\" ? file.posterSrc : file.src}\n        srcSet={\n          file.kind === \"image\"\n            ? (file.backdropSrcSet ?? file.srcSet)\n            : undefined\n        }\n        alt=\"\"\n        fill\n        priority\n        sizes=\"max(100vw, 160svh, 51.2rem)\"\n        className=\"object-cover object-top opacity-40 motion-safe:transition-opacity motion-safe:duration-500 motion-reduce:transition-none\"\n      />\n      <div className=\"pf-backdrop-horizontal absolute inset-0\" />\n      <div className=\"pf-backdrop-vertical absolute inset-0\" />\n    </div>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-exhibition.tsx"
    },
    {
      "path": "src/registry/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\nexport function cn(...values: ClassValue[]) {\n  return twMerge(clsx(values));\n}\n",
      "type": "registry:file",
      "target": "@components/royer/cn.ts"
    },
    {
      "path": "src/registry/portfolio-badges.tsx",
      "content": "\"use client\";\nimport { cn } from \"./cn\";\nimport { PortfolioTip as Tip } from \"./portfolio-tip\";\nimport \"./portfolio.css\";\nexport const filterFillClass =\n  \"[background:var(--pf-chip-fill)] text-[var(--pf-chip-ink)]\";\nexport function PortfolioExhibitionStack({\n  id,\n  names,\n  activeTech,\n  onTechToggle,\n}: {\n  id: string;\n  names: string[];\n  activeTech: string[];\n  onTechToggle: (name: string) => void;\n}) {\n  if (!names.length) {\n    return null;\n  }\n  const labelId = `${id}-stack`;\n  return (\n    <div data-c=\"ExhibitionStack\" className=\"pf-scope flex flex-col gap-1\">\n      <p\n        id={labelId}\n        className=\"text-[var(--pf-ink)] text-base leading-6 font-medium\"\n      >\n        Stack\n      </p>\n      <ul aria-labelledby={labelId} className=\"flex flex-wrap gap-1.5\">\n        {names.map((name) => (\n          <li key={name}>\n            <PortfolioTechChip\n              name={name}\n              on={activeTech.includes(name)}\n              tone=\"pill\"\n              onToggle={onTechToggle}\n            />\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n\nexport function PortfolioTechChip({\n  name,\n  on,\n  tone,\n  onToggle,\n}: {\n  name: string;\n  on: boolean;\n  tone: \"name\" | \"pill\";\n  onToggle: (name: string) => void;\n}) {\n  return (\n    <Tip\n      className=\"pf-scope\"\n      label={on ? `Remove ${name}` : `Filter by ${name}`}\n    >\n      <button\n        data-c=\"TechChip\"\n        type=\"button\"\n        aria-pressed={on}\n        onClick={() => onToggle(name)}\n        className={cn(\n          \"rounded-md px-2 py-0.5 text-base leading-6 transition\",\n          \"focus-visible:outline-[var(--pf-brand-blue-light)] focus-visible:outline-2 focus-visible:outline-offset-2\",\n          \"active:scale-[0.98]\",\n          on\n            ? filterFillClass\n            : tone === \"pill\"\n              ? \"bg-[var(--pf-label-fill)] text-[var(--pf-label-ink)] hover:outline-[var(--pf-brand-blue-light)] hover:outline hover:outline-1\"\n              : \"text-[var(--pf-ink-muted)] hover:text-[var(--pf-ink-bright)] hover:outline-[var(--pf-brand-blue-light)] hover:outline hover:outline-1\",\n        )}\n      >\n        {name}\n      </button>\n    </Tip>\n  );\n}\n\nexport function PortfolioStackList({\n  id,\n  names,\n}: {\n  id: string;\n  names: string[];\n}) {\n  if (!names.length) {\n    return null;\n  }\n  const labelId = `${id}-stack`;\n  return (\n    <div className=\"pf-scope flex flex-col gap-1\">\n      <p\n        id={labelId}\n        className=\"text-[var(--pf-ink)] text-base leading-6 font-medium\"\n      >\n        Stack\n      </p>\n      <ul aria-labelledby={labelId} className=\"flex flex-wrap gap-1.5\">\n        {names.map((name) => (\n          <li\n            key={name}\n            className=\"bg-[var(--pf-label-fill)] text-[var(--pf-label-ink)] px-2 py-0.5 text-base leading-6\"\n          >\n            {name}\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-badges.tsx"
    },
    {
      "path": "src/registry/portfolio-card-frame.tsx",
      "content": "\"use client\";\n\nimport { type MouseEvent, type ReactNode } from \"react\";\nimport {\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\nimport { motion as m } from \"motion/react\";\n\nimport { cn } from \"./cn\";\n\nconst lift = {\n  type: \"spring\" as const,\n  stiffness: 380,\n  damping: 28,\n  mass: 0.65,\n};\n\n/**\n * Finder project tile. The spotlight layer stays in the tree so server HTML\n * matches a reduced-motion client. CSS `motion-reduce:hidden` hides it.\n */\nexport function FinderCard({\n  children,\n  className,\n}: {\n  children: ReactNode;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const spotlight = useMotionTemplate`radial-gradient(28rem circle at ${x}px ${y}px, color-mix(in srgb, var(--pf-brand-blue-light) 16%, transparent), transparent 58%)`;\n\n  const onMove = (event: MouseEvent<HTMLElement>) => {\n    const box = event.currentTarget.getBoundingClientRect();\n    x.set(event.clientX - box.left);\n    y.set(event.clientY - box.top);\n  };\n\n  return (\n    <m.article\n      data-c=\"FinderCard\"\n      className={cn(\n        \"pf-scope group pf-panel relative overflow-hidden\",\n        className,\n      )}\n      onMouseMove={reduce ? undefined : onMove}\n      whileHover={\n        reduce\n          ? undefined\n          : {\n              y: -8,\n              boxShadow: \"0 0.75rem 1.75rem rgba(0, 0, 0, 0.28)\",\n            }\n      }\n      transition={lift}\n    >\n      <m.div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 z-0 opacity-0 mix-blend-screen motion-reduce:hidden\"\n        style={{ background: spotlight }}\n        whileHover={reduce ? undefined : { opacity: 1 }}\n        transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}\n      />\n      <div className=\"relative z-10 flex flex-col\">{children}</div>\n    </m.article>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-card-frame.tsx"
    },
    {
      "path": "src/registry/portfolio-card-stills.ts",
      "content": "export const CARD_STILL_LIMIT = 6;\n\nexport function cardStillPreview<T>(images: T[], overflowHref?: string) {\n  if (!overflowHref || images.length <= CARD_STILL_LIMIT) {\n    return { stills: images, remaining: 0, overflowLanding: undefined };\n  }\n  return {\n    stills: images.slice(0, CARD_STILL_LIMIT),\n    remaining: images.length - CARD_STILL_LIMIT,\n    overflowLanding: `${overflowHref}#still-${CARD_STILL_LIMIT}`,\n  };\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-card-stills.ts"
    },
    {
      "path": "src/registry/portfolio-finder-query.ts",
      "content": "export const FINDER_KINDS = [\"all\", \"work\", \"demos\", \"live\"] as const;\n\nexport type FinderKind = (typeof FINDER_KINDS)[number];\n\nexport type FinderQuery = {\n  q: string;\n  kind: FinderKind;\n  tech: string[];\n};\n\nexport const defaultFinderQuery: FinderQuery = {\n  q: \"\",\n  kind: \"all\",\n  tech: [],\n};\n\nexport const kindFilters: { id: FinderKind; label: string }[] = [\n  { id: \"all\", label: \"All\" },\n  { id: \"work\", label: \"Work\" },\n  { id: \"demos\", label: \"Demos\" },\n  { id: \"live\", label: \"Live tools\" },\n];\n\ntype QueryInput = {\n  q?: string | string[];\n  kind?: string | string[];\n  tech?: string | string[];\n};\n\nfunction first(value: string | string[] | undefined) {\n  if (Array.isArray(value)) {\n    return value[0];\n  }\n  return value;\n}\n\nfunction splitTech(value: string | string[] | undefined, allowed: Set<string>) {\n  const raw = Array.isArray(value) ? value : value ? [value] : [];\n  const names = raw\n    .flatMap((item) => item.split(\",\"))\n    .map((item) => item.trim());\n  return [...new Set(names.filter((item) => item && allowed.has(item)))];\n}\n\nfunction isFinderKind(value: string | undefined): value is FinderKind {\n  return FINDER_KINDS.some((kind) => kind === value);\n}\n\n/** Reads `q`, `kind`, and `tech` from a URL or Next searchParams object. */\nexport function parseFinderQuery(\n  input: QueryInput,\n  allowedTech: string[],\n): FinderQuery {\n  const allowed = new Set(allowedTech);\n  const q = first(input.q)?.trim() ?? \"\";\n  const kindValue = first(input.kind);\n  const kind = isFinderKind(kindValue) ? kindValue : \"all\";\n  return { q, kind, tech: splitTech(input.tech, allowed) };\n}\n\nexport function parseFinderSearchParams(search: string, allowedTech: string[]) {\n  const params = new URLSearchParams(search);\n  return parseFinderQuery(\n    {\n      q: params.get(\"q\") ?? undefined,\n      kind: params.get(\"kind\") ?? undefined,\n      tech: params.get(\"tech\") ?? undefined,\n    },\n    allowedTech,\n  );\n}\n\n/** Omits defaults so a clean finder has no query string. */\nexport function serializeFinderQuery(query: FinderQuery) {\n  const params = new URLSearchParams();\n  if (query.q.trim()) {\n    params.set(\"q\", query.q.trim());\n  }\n  if (query.kind !== \"all\") {\n    params.set(\"kind\", query.kind);\n  }\n  if (query.tech.length) {\n    params.set(\"tech\", query.tech.join(\",\"));\n  }\n  return params.toString();\n}\n\nexport function finderUrl(pathname: string, query: FinderQuery, hash = \"\") {\n  const search = serializeFinderQuery(query);\n  return `${pathname}${search ? `?${search}` : \"\"}${hash}`;\n}\n\n/** Adds `name` to the selected tech list, or removes it when it is already there. */\nexport function toggleFinderTech(tech: string[], name: string) {\n  if (tech.includes(name)) {\n    return tech.filter((item) => item !== name);\n  }\n  return [...tech, name];\n}\n\n/**\n * Project whose still paints the exhibition field. Keeps `stagedId` when that\n * id is still in `results`, otherwise the first result.\n */\nexport function stagedProject<T extends { id: string }>(\n  results: T[],\n  stagedId: string | null | undefined,\n) {\n  if (!results.length) {\n    return null;\n  }\n  return results.find((project) => project.id === stagedId) ?? results[0];\n}\n\nexport function kindLabel(kind: FinderKind) {\n  return kindFilters.find((item) => item.id === kind)?.label ?? \"All\";\n}\n\nexport type SearchableProject = {\n  name: string;\n  kind: string;\n  summary: string;\n  status?: string;\n  tech: string[];\n  stack?: string[];\n};\n\nexport function projectStackNames(project: SearchableProject) {\n  return project.stack ?? project.tech;\n}\n\n/** Kind, tech (any selected chip in stack), and a case-insensitive haystack query. */\nexport function projectMatches(\n  project: SearchableProject,\n  query: FinderQuery,\n  studyTitle?: string,\n) {\n  if (query.kind !== \"all\" && project.kind !== query.kind) {\n    return false;\n  }\n  const stack = projectStackNames(project);\n  if (query.tech.length && !query.tech.some((item) => stack.includes(item))) {\n    return false;\n  }\n  const q = query.q.trim().toLowerCase();\n  if (!q) {\n    return true;\n  }\n  const hay = [\n    project.name,\n    project.kind,\n    project.summary,\n    project.status,\n    ...project.tech,\n    ...stack,\n    studyTitle,\n  ]\n    .join(\" \")\n    .toLowerCase();\n  return hay.includes(q);\n}\n\n/**\n * Empty-index copy. Query wins over tech when both are set. `{techs}` is joined\n * with \"or\". `{kind}` is Work, Demos, or Live tools.\n */\nexport function finderEmptyMessage(query: FinderQuery) {\n  const q = query.q.trim();\n  const techs = query.tech.join(\" or \");\n  const isAll = query.kind === \"all\";\n  const kind = kindLabel(query.kind);\n\n  if (q) {\n    return isAll\n      ? `No projects match “${q}”.`\n      : `No ${kind} projects match “${q}”.`;\n  }\n  if (query.tech.length) {\n    return isAll\n      ? `No projects use ${techs}.`\n      : `No ${kind} projects use ${techs}.`;\n  }\n  return isAll ? \"No projects match.\" : `No ${kind} projects match.`;\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-finder-query.ts"
    },
    {
      "path": "src/registry/portfolio-image.tsx",
      "content": "import type { ComponentProps } from \"react\";\nimport { cn } from \"./cn\";\n// Native image adapter keeps the source's fill geometry without a Next image optimizer.\nexport function PortfolioImage({\n  fill,\n  priority,\n  loading,\n  unoptimized: _unoptimized,\n  className,\n  ...props\n}: ComponentProps<\"img\"> & {\n  fill?: boolean;\n  priority?: boolean;\n  unoptimized?: boolean;\n}) {\n  return (\n    <img\n      {...props}\n      loading={priority ? \"eager\" : (loading ?? \"lazy\")}\n      fetchPriority={priority ? \"high\" : undefined}\n      className={cn(fill && \"absolute inset-0 h-full w-full\", className)}\n    />\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-image.tsx"
    },
    {
      "path": "src/registry/portfolio-media-state.ts",
      "content": "import type { PortfolioMedia } from \"./portfolio-types\";\n\n/**\n * Playback for a well still. A png cannot play or Repeat. A video is paused\n * or playing, and Repeat only exists on those two kinds.\n */\nexport type WellMotion =\n  | { kind: \"still\" }\n  | { kind: \"paused\"; repeat: boolean }\n  | { kind: \"playing\"; repeat: boolean };\n\n/** Where the live well is mounted. Page and lightbox are not both live. */\nexport type WellSurface = \"page\" | \"lightbox\";\n\n/**\n * Initial motion for a still file. Videos start paused with Repeat off.\n */\nexport function wellMotionForFile(\n  file: PortfolioMedia | undefined,\n): WellMotion {\n  if (!file || file.kind !== \"video\") {\n    return { kind: \"still\" };\n  }\n  return { kind: \"paused\", repeat: false };\n}\n\nexport function playWellMotion(motion: WellMotion): WellMotion {\n  if (motion.kind === \"still\") {\n    return motion;\n  }\n  return { kind: \"playing\", repeat: motion.repeat };\n}\n\nexport function pauseWellMotion(motion: WellMotion): WellMotion {\n  if (motion.kind === \"still\") {\n    return motion;\n  }\n  return { kind: \"paused\", repeat: motion.repeat };\n}\n\nexport function toggleWellRepeat(motion: WellMotion): WellMotion {\n  if (motion.kind === \"still\") {\n    return motion;\n  }\n  return { ...motion, repeat: !motion.repeat };\n}\n\n/**\n * Clip ended. Repeat-on stays playing (the element loops). Repeat-off returns to paused.\n */\nexport function endWellMotion(motion: WellMotion): WellMotion {\n  if (motion.kind !== \"playing\" || motion.repeat) {\n    return motion;\n  }\n  return { kind: \"paused\", repeat: false };\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-media-state.ts"
    },
    {
      "path": "src/registry/portfolio-project-card.tsx",
      "content": "\"use client\";\nimport { ArrowRightIcon } from \"@phosphor-icons/react/dist/ssr/ArrowRight\";\nimport { ArrowSquareOutIcon } from \"@phosphor-icons/react/dist/ssr/ArrowSquareOut\";\nimport { LockSimpleIcon } from \"@phosphor-icons/react/dist/ssr/LockSimple\";\nimport { PortfolioProjectStill } from \"./portfolio-project-still\";\nimport { PortfolioExhibitionStack } from \"./portfolio-badges\";\nimport { FinderCard } from \"./portfolio-card-frame\";\nimport type { PortfolioProject } from \"./portfolio-types\";\nimport \"./portfolio.css\";\nimport { cn } from \"./cn\";\nconst quietLink =\n  \"pf-action text-[var(--pf-brand-blue-light)] focus-visible:outline-[var(--pf-brand-blue-light)] inline-flex items-center gap-2 rounded-sm text-base leading-6 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2\";\nexport function PortfolioProjectCard({\n  project,\n  priority,\n  loading,\n  activeTech = [],\n  onTechToggle = () => {},\n  overflowHref,\n  className,\n}: {\n  project: PortfolioProject;\n  priority?: boolean;\n  loading?: \"eager\" | \"lazy\";\n  activeTech?: string[];\n  onTechToggle?: (name: string) => void;\n  overflowHref?: string;\n  className?: string;\n}) {\n  const stack = project.stack ?? project.tech;\n  const destHref = overflowHref ?? project.recordLink?.href;\n  return (\n    <FinderCard\n      className={cn(\n        \"rounded-md border border-[var(--pf-surface-accent)] hover:border-[var(--pf-brand-blue-light)]\",\n        className,\n      )}\n    >\n      <div data-c=\"ExhibitionCard\" className=\"flex min-w-0 flex-col\">\n        <header className=\"flex flex-col gap-2\">\n          <PortfolioProjectStill\n            images={project.images}\n            name={project.name}\n            priority={priority}\n            loading={loading}\n            sizes=\"(min-width: 1024px) 416px, calc(100vw - 32px)\"\n            overflowHref={destHref}\n            enlargeThumbs\n            enlargeWell\n          />\n          <h3 className=\"text-[var(--pf-ink-bright)] px-4 pt-2 text-lg leading-7 font-semibold tracking-tight\">\n            <PrimaryName project={project} />\n          </h3>\n        </header>\n        <div className=\"flex flex-col gap-2 px-4 pt-1 pb-4\">\n          {project.status ? (\n            <p className=\"text-[var(--pf-ink-muted)] text-base leading-6\">\n              {project.status}\n            </p>\n          ) : null}\n          <PortfolioExhibitionStack\n            id={project.id}\n            names={stack}\n            activeTech={activeTech}\n            onTechToggle={onTechToggle}\n          />\n          <p className=\"text-[var(--pf-ink-soft)] text-base leading-7\">\n            {project.summary}\n          </p>\n          {project.achievements?.map((item) => (\n            <p\n              key={item.title}\n              className=\"text-[var(--pf-ink-muted)] text-base leading-7\"\n            >\n              <strong className=\"text-[var(--pf-ink)] font-medium\">\n                {item.title}:\n              </strong>{\" \"}\n              {item.description}\n            </p>\n          ))}\n          <div className=\"flex flex-col gap-2 pt-3\">\n            {project.private ? (\n              <p className=\"text-[var(--pf-ink-muted)] flex items-start gap-2 text-base leading-6\">\n                <LockSimpleIcon\n                  aria-hidden=\"true\"\n                  size={16}\n                  weight=\"fill\"\n                  className=\"mt-0.5 shrink-0\"\n                />\n                <span>Private. Not available to the public.</span>\n              </p>\n            ) : null}\n            <StudyLink project={project} />\n            <DownloadLink project={project} />\n            <RepositoryLink project={project} />\n            <ProjectPageLink project={project} />\n          </div>\n        </div>\n      </div>\n    </FinderCard>\n  );\n}\n\nfunction PrimaryName({ project }: { project: PortfolioProject }) {\n  if (project.href) {\n    return (\n      <a\n        data-c=\"PrimaryName\"\n        href={project.href}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"focus-visible:outline-[var(--pf-brand-blue-light)] inline-flex items-start gap-2 rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-2\"\n      >\n        <span>{project.name}</span>\n        <ArrowSquareOutIcon\n          aria-hidden=\"true\"\n          size={16}\n          weight=\"bold\"\n          className=\"text-[var(--pf-brand-blue-light)] mt-1 shrink-0\"\n        />\n        <span className=\"sr-only\"> (opens in a new tab)</span>\n      </a>\n    );\n  }\n  const studyHref = project.studyLink?.href;\n  if (studyHref) {\n    return (\n      <a\n        data-c=\"PrimaryName\"\n        href={studyHref}\n        className=\"focus-visible:outline-[var(--pf-brand-blue-light)] inline-flex items-start gap-2 rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-2\"\n      >\n        <span>{project.name}</span>\n        <ArrowRightIcon\n          aria-hidden=\"true\"\n          size={16}\n          weight=\"bold\"\n          className=\"text-[var(--pf-brand-blue-light)] mt-1 shrink-0\"\n        />\n      </a>\n    );\n  }\n  return project.name;\n}\n\nfunction StudyLink({ project }: { project: PortfolioProject }) {\n  const href = project.studyLink?.href;\n  if (!href || !project.href) {\n    return null;\n  }\n  return (\n    <a data-c=\"StudyLink\" href={href} className={quietLink}>\n      {project.studyLink?.label}\n      <ArrowRightIcon aria-hidden=\"true\" size={14} weight=\"bold\" />\n    </a>\n  );\n}\n\nfunction DownloadLink({ project }: { project: PortfolioProject }) {\n  if (!project.download) {\n    return null;\n  }\n  return (\n    <a\n      data-c=\"DownloadLink\"\n      href={project.download.href}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={quietLink}\n    >\n      {project.download.label}\n      <ArrowSquareOutIcon aria-hidden=\"true\" size={14} weight=\"bold\" />\n      <span className=\"sr-only\"> (opens in a new tab)</span>\n    </a>\n  );\n}\n\nfunction RepositoryLink({ project }: { project: PortfolioProject }) {\n  if (!project.repositoryLink?.href) {\n    return null;\n  }\n  return (\n    <a\n      data-c=\"RepositoryLink\"\n      href={project.repositoryLink?.href}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={quietLink}\n    >\n      {project.repositoryLink?.label}\n      <ArrowSquareOutIcon aria-hidden=\"true\" size={14} weight=\"bold\" />\n      <span className=\"sr-only\"> (opens in a new tab)</span>\n    </a>\n  );\n}\n\nfunction ProjectPageLink({ project }: { project: PortfolioProject }) {\n  const href = project.recordLink?.href;\n  if (!href) {\n    return null;\n  }\n  return (\n    <a data-c=\"ProjectPageLink\" href={href} className={quietLink}>\n      {project.recordLink?.label}\n      <ArrowRightIcon aria-hidden=\"true\" size={14} weight=\"bold\" />\n    </a>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-project-card.tsx"
    },
    {
      "path": "src/registry/portfolio-project-still.tsx",
      "content": "\"use client\";\n\nimport { PlayIcon } from \"@phosphor-icons/react/dist/ssr/Play\";\nimport { RepeatIcon } from \"@phosphor-icons/react/dist/ssr/Repeat\";\nimport { XIcon } from \"@phosphor-icons/react/dist/ssr/X\";\nimport { PortfolioImage as Image } from \"./portfolio-image\";\nimport type { PortfolioMedia } from \"./portfolio-types\";\nimport \"./portfolio.css\";\nimport { useEffect, useId, useRef, useState } from \"react\";\n\nimport { PortfolioTip as Tip } from \"./portfolio-tip\";\nimport { cardStillPreview } from \"./portfolio-card-stills\";\nimport { cn } from \"./cn\";\nconst isPlayableVideo = (file: PortfolioMedia) => file.kind === \"video\";\nconst motionPosterFile = (file: PortfolioMedia) =>\n  file.kind === \"video\" ? file.posterSrc : undefined;\nconst stillThumbFile = (file: PortfolioMedia) =>\n  file.thumbnailSrc ?? (file.kind === \"video\" ? file.posterSrc : file.src);\nconst moreStillsLabel = ({\n  name,\n  remaining,\n}: {\n  name: string;\n  remaining: number;\n}) =>\n  `${name}: ${pluralize(remaining, { one: \"more still\", other: \"more stills\" })}`;\nconst showingStillLabel = ({ n, total }: { n: number; total: number }) =>\n  `Showing still ${n} of ${total}`;\n\nimport {\n  endWellMotion,\n  pauseWellMotion,\n  playWellMotion,\n  toggleWellRepeat,\n  type WellMotion,\n  type WellSurface,\n  wellMotionForFile,\n} from \"./portfolio-media-state\";\nimport { pluralize } from \"./pluralize\";\n\nconst enlargedSizes =\n  \"(min-width: 1088px) 1024px, (min-width: 768px) calc(100vw - 64px), calc(100vw - 32px)\";\n\nconst thumbClass = cn(\n  \"relative isolate block aspect-[16/10] h-10 w-16 overflow-hidden bg-white\",\n  \"cursor-pointer\",\n  \"focus-visible:outline-[var(--pf-brand-blue-light)] focus-visible:outline-2 focus-visible:outline-offset-2\",\n);\n\nfunction thumbSelected(selected: boolean) {\n  return selected\n    ? \"outline-[var(--pf-brand-blue-light)] outline-2 outline-offset-2\"\n    : \"outline-transparent\";\n}\n\nfunction stillSrc(file: PortfolioMedia) {\n  return file.src;\n}\n\nfunction PlayMark() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-0 z-10 flex items-center justify-center\"\n    >\n      <span className=\"flex size-8 items-center justify-center rounded-full bg-black/55\">\n        <PlayIcon size={16} weight=\"fill\" className=\"text-white\" />\n      </span>\n    </span>\n  );\n}\n\nfunction ThumbMedia({ file }: { file: PortfolioMedia }) {\n  const thumb = stillThumbFile(file);\n  const showPlay = isPlayableVideo(file);\n  return (\n    <>\n      <Image\n        src={thumb}\n        alt=\"\"\n        fill\n        sizes=\"64px\"\n        className=\"object-cover object-top\"\n      />\n      {showPlay ? <PlayMark /> : null}\n    </>\n  );\n}\n\n/**\n * Framed 16/10 video. Paused until play. `loop` follows Repeat. The caller supplies a poster URL.\n */\nfunction StillVideo({\n  file,\n  alt,\n  playing,\n  repeat,\n  onEnded,\n}: {\n  file: PortfolioMedia;\n  alt: string;\n  playing: boolean;\n  repeat: boolean;\n  onEnded: () => void;\n}) {\n  const ref = useRef<HTMLVideoElement>(null);\n  const src = stillSrc(file);\n  const posterFile = motionPosterFile(file);\n  const poster = posterFile;\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) {\n      return;\n    }\n    if (playing) {\n      void el.play().catch(onEnded);\n      return;\n    }\n    el.pause();\n  }, [playing]);\n\n  return (\n    <video\n      ref={ref}\n      src={src}\n      poster={poster}\n      muted\n      playsInline\n      loop={repeat}\n      preload=\"metadata\"\n      aria-label={alt || undefined}\n      onEnded={onEnded}\n      className=\"absolute inset-0 h-full w-full object-cover object-top\"\n    />\n  );\n}\n\nfunction StillPhoto({\n  file,\n  alt,\n  sizes,\n  priority,\n  loading,\n}: {\n  file: PortfolioMedia;\n  alt: string;\n  sizes: string;\n  priority: boolean;\n  loading?: \"eager\" | \"lazy\";\n}) {\n  return (\n    <Image\n      src={stillSrc(file)}\n      srcSet={file.kind === \"image\" ? file.srcSet : undefined}\n      alt={file.alt || alt}\n      fill\n      sizes={sizes}\n      priority={priority}\n      loading={loading}\n      className=\"object-cover object-top\"\n    />\n  );\n}\n\nfunction WellFrame({\n  file,\n  name,\n  sizes,\n  priority,\n  loading,\n  motion,\n  wellEnlarges,\n  onEnlarge,\n  triggerId,\n  onPlay,\n  onToggleRepeat,\n  onEnded,\n}: {\n  file: PortfolioMedia;\n  name: string;\n  sizes: string;\n  priority: boolean;\n  loading?: \"eager\" | \"lazy\";\n  motion: WellMotion;\n  wellEnlarges: boolean;\n  onEnlarge: () => void;\n  triggerId?: string;\n  onPlay: () => void;\n  onToggleRepeat: () => void;\n  onEnded: () => void;\n}) {\n  const isVideo = motion.kind !== \"still\";\n  const playing = motion.kind === \"playing\";\n  const repeat = motion.kind === \"still\" ? false : motion.repeat;\n  return (\n    <div className=\"relative aspect-[16/10] w-full overflow-hidden bg-white\">\n      {isVideo ? (\n        <StillVideo\n          file={file}\n          alt={name}\n          playing={playing}\n          repeat={repeat}\n          onEnded={onEnded}\n        />\n      ) : (\n        <StillPhoto\n          file={file}\n          alt={name}\n          sizes={sizes}\n          priority={priority}\n          loading={loading}\n        />\n      )}\n      {wellEnlarges ? (\n        <button\n          type=\"button\"\n          id={triggerId}\n          aria-label={name}\n          onClick={onEnlarge}\n          className=\"focus-visible:outline-[var(--pf-brand-blue-light)] absolute inset-0 z-10 cursor-zoom-in focus-visible:outline-2 focus-visible:outline-offset-2\"\n        />\n      ) : null}\n      {isVideo && !playing ? (\n        <button\n          type=\"button\"\n          aria-label={`Play ${name}`}\n          onClick={(event) => {\n            event.stopPropagation();\n            onPlay();\n          }}\n          className=\"absolute top-1/2 left-1/2 z-20 flex size-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white\"\n        >\n          <PlayIcon aria-hidden=\"true\" size={22} weight=\"fill\" />\n        </button>\n      ) : null}\n      {isVideo ? (\n        <button\n          type=\"button\"\n          aria-pressed={repeat}\n          aria-label=\"Repeat\"\n          onClick={(event) => {\n            event.stopPropagation();\n            onToggleRepeat();\n          }}\n          className={cn(\n            \"absolute right-2 bottom-2 z-20 flex size-8 items-center justify-center rounded-full\",\n            repeat\n              ? \"bg-[var(--pf-brand-blue-light)] text-[var(--pf-surface)]\"\n              : \"bg-black/55 text-white\",\n          )}\n        >\n          <RepeatIcon aria-hidden=\"true\" size={16} weight=\"bold\" />\n        </button>\n      ) : null}\n    </div>\n  );\n}\n\n/**\n * 16/10 still well with thumbs under it when a record has more than one capture.\n * Hover or focus a thumb to switch the well. Click the well or a still thumb\n * to enlarge, except the leftover `{n}+` thumb, which leaves for the project page.\n * Playable video starts paused, with a play mark on the thumb and on the well.\n * Repeat is opt-in and only loops after play.\n */\nexport function PortfolioProjectStill({\n  images,\n  name,\n  priority = false,\n  loading,\n  overflowHref,\n  enlargeThumbs = false,\n  enlargeWell = false,\n  focusStill,\n  defaultOpen = false,\n  stillIds = false,\n  sizes = \"100vw\",\n}: {\n  images: PortfolioMedia[];\n  name: string;\n  priority?: boolean;\n  loading?: \"eager\" | \"lazy\";\n  overflowHref?: string;\n  enlargeThumbs?: boolean;\n  enlargeWell?: boolean;\n  focusStill?: number;\n  defaultOpen?: boolean;\n  stillIds?: boolean;\n  sizes?: string;\n}) {\n  const { stills, remaining, overflowLanding } = cardStillPreview(\n    images,\n    overflowHref,\n  );\n  const [index, setIndex] = useState(() =>\n    focusStill && focusStill > 0 && focusStill <= stills.length\n      ? focusStill - 1\n      : 0,\n  );\n  const [motion, setMotion] = useState<WellMotion>(() =>\n    wellMotionForFile(stills[index]),\n  );\n  const [motionFile, setMotionFile] = useState(stills[index]);\n  const [surface, setSurface] = useState<WellSurface>(\"page\");\n  const [seenFocus, setSeenFocus] = useState(focusStill);\n  if (focusStill !== seenFocus) {\n    setSeenFocus(focusStill);\n    if (focusStill != null) {\n      const next = focusStill - 1;\n      if (next >= 0 && next < stills.length) {\n        setIndex(next);\n      }\n    }\n  }\n  const dialogRef = useRef<HTMLDialogElement>(null);\n  const mediaId = useId();\n  const returnFocusId = useRef(\"\");\n  const current = stills[index] ?? stills[0];\n  const dialogName = `${name} stills`;\n  const canEnlarge = enlargeThumbs || enlargeWell;\n\n  if (current !== motionFile) {\n    setMotionFile(current);\n    setMotion(wellMotionForFile(current));\n  }\n\n  useEffect(() => {\n    const dialog = dialogRef.current;\n    if (!dialog || !defaultOpen || !canEnlarge) {\n      return;\n    }\n    if (!dialog.open) {\n      dialog.showModal();\n      setSurface(\"lightbox\");\n    }\n  }, [canEnlarge, defaultOpen]);\n\n  if (!images.length) {\n    return (\n      <div\n        className=\"pf-scope bg-[var(--pf-surface-accent)] aspect-[16/10] w-full\"\n        role=\"img\"\n        aria-label={`${name}: no still yet`}\n      />\n    );\n  }\n\n  const openEnlarge = () => {\n    returnFocusId.current = document.activeElement?.id ?? \"\";\n    const y = window.scrollY;\n    dialogRef.current?.showModal();\n    setSurface(\"lightbox\");\n    window.scrollTo(0, y);\n  };\n\n  const closeEnlarge = () => {\n    dialogRef.current?.close();\n  };\n\n  const liveGallery = (onPage: boolean) => {\n    if (!current) {\n      return null;\n    }\n    const wellEnlarges = onPage && enlargeWell;\n    return (\n      <div className=\"flex flex-col gap-2\">\n        <WellFrame\n          file={current}\n          name={name}\n          sizes={onPage ? sizes : enlargedSizes}\n          priority={priority && onPage && index === 0}\n          loading={onPage && index === 0 ? loading : undefined}\n          motion={motion}\n          wellEnlarges={wellEnlarges}\n          onEnlarge={openEnlarge}\n          triggerId={`${mediaId}-well`}\n          onPlay={() => setMotion(playWellMotion)}\n          onToggleRepeat={() => setMotion(toggleWellRepeat)}\n          onEnded={() => setMotion(endWellMotion)}\n        />\n        {stills.length > 1 ? (\n          <div role=\"group\" aria-label={dialogName}>\n            <ul className=\"flex flex-wrap gap-2\">\n              {stills.map((file, imageIndex) => {\n                const selected = imageIndex === index;\n                const isOverflow =\n                  onPage &&\n                  Boolean(overflowLanding) &&\n                  imageIndex === stills.length - 1;\n                if (isOverflow && overflowLanding) {\n                  return (\n                    <li key={file.id}>\n                      <a\n                        href={overflowLanding}\n                        onPointerEnter={() => setIndex(imageIndex)}\n                        onFocus={() => setIndex(imageIndex)}\n                        aria-current={selected ? \"true\" : undefined}\n                        aria-label={moreStillsLabel({ name, remaining })}\n                        className={cn(thumbClass, thumbSelected(selected))}\n                      >\n                        <ThumbMedia file={file} />\n                        <span\n                          aria-hidden=\"true\"\n                          className=\"absolute inset-0 z-10 flex items-center justify-center bg-black/55 text-xl leading-none font-bold text-white\"\n                        >\n                          {remaining}+\n                        </span>\n                      </a>\n                    </li>\n                  );\n                }\n                const show = () => setIndex(imageIndex);\n                const openThis = () => {\n                  setIndex(imageIndex);\n                  if (onPage && enlargeThumbs) {\n                    openEnlarge();\n                  }\n                };\n                const id =\n                  onPage && stillIds ? `still-${imageIndex + 1}` : undefined;\n                return (\n                  <li key={file.id} id={id}>\n                    <button\n                      type=\"button\"\n                      id={`${mediaId}-${onPage ? \"page\" : \"dialog\"}-thumb-${imageIndex}`}\n                      aria-pressed={selected}\n                      aria-label={`${name} still ${imageIndex + 1} of ${images.length}`}\n                      onPointerEnter={show}\n                      onFocus={show}\n                      onClick={openThis}\n                      className={cn(thumbClass, thumbSelected(selected))}\n                    >\n                      <ThumbMedia file={file} />\n                    </button>\n                  </li>\n                );\n              })}\n            </ul>\n          </div>\n        ) : null}\n        <span className=\"sr-only\">\n          {showingStillLabel({ n: index + 1, total: images.length })}\n        </span>\n      </div>\n    );\n  };\n\n  const pageLive = surface === \"page\";\n  const lightboxLive = surface === \"lightbox\";\n\n  return (\n    <div className=\"pf-scope\">\n      {pageLive ? (\n        liveGallery(true)\n      ) : (\n        <div className=\"flex flex-col gap-2\" aria-hidden=\"true\">\n          <div className=\"aspect-[16/10] w-full\" />\n          {stills.length > 1 ? <div className=\"h-10\" /> : null}\n        </div>\n      )}\n      {canEnlarge ? (\n        <dialog\n          ref={dialogRef}\n          aria-label={dialogName}\n          className=\"relative m-0 h-full max-h-none w-full max-w-none border-0 bg-transparent p-4 backdrop:bg-black/60 open:flex open:flex-col open:items-center open:justify-center md:p-8\"\n          onClose={() => {\n            setSurface(\"page\");\n            setMotion(pauseWellMotion);\n            requestAnimationFrame(() =>\n              document.getElementById(returnFocusId.current)?.focus(),\n            );\n          }}\n          onClick={(event) => {\n            if (event.target === dialogRef.current) {\n              closeEnlarge();\n            }\n          }}\n        >\n          <Tip\n            label=\"Close, Esc\"\n            align=\"end\"\n            className=\"absolute top-4 right-4 z-20\"\n          >\n            <button\n              type=\"button\"\n              onClick={closeEnlarge}\n              className=\"pf-action pf-panel text-[var(--pf-ink-bright)] focus-visible:outline-[var(--pf-brand-blue-light)] flex size-11 items-center justify-center rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4\"\n            >\n              <XIcon aria-hidden=\"true\" size={22} weight=\"bold\" />\n              <span className=\"sr-only\">Close</span>\n            </button>\n          </Tip>\n          <div\n            className=\"mx-auto w-full max-w-5xl\"\n            onClick={(event) => event.stopPropagation()}\n          >\n            {lightboxLive ? liveGallery(false) : null}\n          </div>\n        </dialog>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-project-still.tsx"
    },
    {
      "path": "src/registry/portfolio-tip.tsx",
      "content": "import { cn } from \"./cn\";\n\ntype TipProps = {\n  label: string;\n  children: React.ReactNode;\n  className?: string;\n  align?: \"start\" | \"center\" | \"end\";\n};\n\nconst alignClass = {\n  start: \"left-0\",\n  center: \"left-1/2 -translate-x-1/2\",\n  end: \"right-0\",\n};\n\n/**\n * Visible name for an icon-only or terse control. Shows on hover and on focus,\n * so it is not a hover-only cue.\n */\nexport function PortfolioTip({\n  label,\n  children,\n  className,\n  align = \"center\",\n}: TipProps) {\n  return (\n    <span className={cn(\"group/tip relative inline-flex\", className)}>\n      {children}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"bg-[var(--pf-label-fill)] text-[var(--pf-label-ink)] pointer-events-none absolute top-full z-30 mt-2\",\n          \"w-max max-w-[min(12rem,calc(100vw-2rem))] rounded-sm px-2 py-1 text-base leading-6\",\n          alignClass[align],\n          \"opacity-0 transition-opacity group-focus-within/tip:opacity-100 group-hover/tip:opacity-100\",\n        )}\n      >\n        {label}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-tip.tsx"
    },
    {
      "path": "src/registry/portfolio-types.ts",
      "content": "export type PortfolioMedia = {\n  id: string;\n  src: string;\n  alt: string;\n  thumbnailSrc?: string;\n} & (\n  | { kind: \"image\"; srcSet?: string; backdropSrcSet?: string }\n  | { kind: \"video\"; posterSrc: string }\n);\nexport type PortfolioLink = { href: string; label: string; external?: boolean };\nexport type PortfolioProject = {\n  id: string;\n  name: string;\n  kind: \"work\" | \"demos\" | \"live\";\n  tech: string[];\n  stack?: string[];\n  summary: string;\n  status?: string;\n  images: PortfolioMedia[];\n  href?: string;\n  recordLink?: PortfolioLink;\n  studyLink?: PortfolioLink;\n  repositoryLink?: PortfolioLink;\n  download?: PortfolioLink;\n  private?: boolean;\n  achievements?: { title: string; description: string }[];\n};\nexport type PortfolioHistoryPost = {\n  id: string;\n  title: string;\n  date?: string;\n  why: string;\n  changes: { kind: string; text: string }[];\n  stills: PortfolioMedia[];\n  studyLink?: PortfolioLink;\n  open?: PortfolioLink;\n};\nexport type PortfolioRecord = {\n  stills: PortfolioMedia[];\n  story: { role?: string; what: string; why: string; how: string };\n  studies: PortfolioLink[];\n  history: PortfolioHistoryPost[];\n};\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-types.ts"
    },
    {
      "path": "src/registry/portfolio-working-on.ts",
      "content": "export type WorkingOnBands<T extends { id: string }> = {\n  showBands: boolean;\n  workingOn: T[];\n  rest: T[];\n  stagePool: T[];\n};\n\nexport function splitWorkingOn<T extends { id: string }>(\n  results: T[],\n  workingOnIds: readonly string[] | undefined,\n  filtered: boolean,\n): WorkingOnBands<T> {\n  if (!workingOnIds?.length || filtered) {\n    return {\n      showBands: false,\n      workingOn: [],\n      rest: results,\n      stagePool: results,\n    };\n  }\n\n  const workingOn = workingOnIds\n    .map((id) => results.find((project) => project.id === id))\n    .filter((project): project is T => Boolean(project));\n  const idSet = new Set(workingOnIds);\n  const rest = results.filter((project) => !idSet.has(project.id));\n\n  return {\n    showBands: workingOn.length > 0 || rest.length > 0,\n    workingOn,\n    rest,\n    stagePool: [...workingOn, ...rest],\n  };\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio-working-on.ts"
    },
    {
      "path": "src/registry/portfolio.css",
      "content": "@import \"./foundation.css\";\n@import \"@fontsource/geist/latin-400.css\";\n@import \"@fontsource/geist/latin-500.css\";\n@import \"@fontsource/geist/latin-600.css\";\n@import \"@fontsource/geist/latin-700.css\";\n\n/* PR22 source materials. Geometry remains in the authored component markup. */\n.pf-scope {\n  font-family: Geist, \"Helvetica Neue\", sans-serif;\n  color: var(--pf-ink);\n  --pf-label-fill: var(--rui-portfolio-surface-accent, var(--rui-soft));\n  --pf-label-ink: var(--rui-portfolio-label-ink, var(--rui-ink));\n  --pf-chip-fill: var(--rui-portfolio-chip-fill, var(--rui-brand));\n  --pf-chip-ink: var(--rui-portfolio-chip-ink, var(--rui-on-brand));\n  --pf-surface: var(--rui-canvas);\n  --pf-surface-raised: var(--rui-surface);\n  --pf-surface-accent: var(\n    --rui-portfolio-surface-accent,\n    var(--rui-control-line)\n  );\n  --pf-ink-bright: var(--rui-portfolio-ink-bright, var(--rui-ink));\n  --pf-ink: var(--rui-ink);\n  --pf-ink-soft: var(--rui-ink);\n  --pf-ink-muted: var(--rui-muted);\n  --pf-brand-blue: var(--rui-portfolio-blue, var(--rui-brand));\n  --pf-brand-blue-light: var(--rui-portfolio-blue-light, var(--rui-brand));\n  --pf-brand-purple: var(--rui-portfolio-purple, var(--rui-brand));\n  --pf-brand-purple-light: var(--rui-portfolio-purple-light, var(--rui-brand));\n}\n.pf-scope [id] {\n  scroll-margin-top: 5rem;\n}\n.pf-scope .pf-panel,\n.pf-scope.pf-panel {\n  background: var(\n    --rui-portfolio-panel,\n    linear-gradient(155deg, var(--rui-soft), var(--rui-surface))\n  );\n  box-shadow: var(--rui-portfolio-shadow, 0 16px 32px #0002);\n}\n.pf-scope .pf-input {\n  background: var(--rui-portfolio-input, var(--rui-surface));\n  box-shadow: var(--rui-portfolio-input-shadow, 0 12px 28px #0001);\n}\n.pf-scope .pf-inset {\n  background: var(--rui-portfolio-inset, var(--rui-soft));\n  box-shadow: var(--rui-portfolio-inset-shadow, inset 0 2px 8px #0001);\n}\n.pf-scope .pf-reading {\n  background: var(--rui-portfolio-reading, var(--rui-surface));\n}\n.pf-scope .pf-action {\n  text-underline-offset: 4px;\n  transition:\n    translate 180ms ease-out,\n    color 180ms ease-out;\n}\n.pf-scope .pf-action:is(:hover, :focus-visible) {\n  color: var(--rui-portfolio-link-hover, var(--rui-brand));\n}\n.pf-scope .pf-action:focus-visible {\n  outline: 3px solid var(--pf-brand-blue-light);\n  outline-offset: 5px;\n}\n.pf-scope .pf-backdrop-horizontal {\n  background: var(\n    --rui-portfolio-backdrop-horizontal,\n    linear-gradient(to right, var(--rui-canvas), transparent)\n  );\n}\n.pf-scope .pf-backdrop-vertical {\n  background: var(\n    --rui-portfolio-backdrop-vertical,\n    linear-gradient(to bottom, transparent, var(--rui-canvas))\n  );\n}\n@media (prefers-reduced-motion: no-preference) {\n  .pf-scope .pf-action:is(:hover, :focus-visible) {\n    translate: 0 -2px;\n  }\n  .pf-scope .pf-action:active {\n    translate: 0;\n  }\n}\n@media (prefers-reduced-motion: reduce) {\n  .pf-scope .pf-action {\n    transition: none;\n  }\n}\n\n.pf-scope.pf-field {\n  background-color: var(--pf-surface);\n  background-image: var(--rui-portfolio-field, none);\n}\n\n/* Restore the source utility colors when a host has an unlayered anchor reset. */\n.pf-scope a {\n  color: revert-layer;\n}\n",
      "type": "registry:file",
      "target": "@components/royer/portfolio.css"
    },
    {
      "path": "src/registry/pluralize.tsx",
      "content": "// Counts with nouns, through the locale's own plural rules.\n// English needs two forms (one, other); Polish needs four (one, few, many,\n// other); Arabic uses zero and two as well. A ternary on count === 1 is only\n// right for some languages, and never for fractions (\"1.5 files\").\nexport type PluralCategory = Intl.LDMLPluralRule;\n// `other` is the one form every locale uses, so it is required.\nexport type PluralForms = Partial<Record<PluralCategory, string>> & {\n  other: string;\n};\nexport type PluralizeOptions = {\n  // Pass the page's locale. Defaults to \"en-US\", never the runtime default,\n  // so the server and the browser print the same text.\n  locale?: string;\n  // Text for exactly zero, e.g. \"No comments yet\". Without it, zero uses\n  // the locale's rule (\"0 comments\" in English, the zero form in Arabic).\n  none?: string;\n  // Number formatting, e.g. { maximumFractionDigits: 1 } or compact notation.\n  numberFormat?: Intl.NumberFormatOptions;\n  // false returns only the noun form, for layouts that style the number apart.\n  includeCount?: boolean;\n};\n\nconst cache = new Map<\n  string,\n  { numbers: Intl.NumberFormat; rules: Intl.PluralRules }\n>();\n// One number format and matching plural rules per locale and options. The\n// rules round like the printed number, so 1.0004 shown as \"1\" reads \"1 file\".\nfunction formatters(locale: string, options?: Intl.NumberFormatOptions) {\n  const key = `${locale}|${JSON.stringify(options ?? {})}`;\n  let entry = cache.get(key);\n  if (!entry) {\n    const numbers = new Intl.NumberFormat(locale, options);\n    entry = {\n      numbers,\n      rules: new Intl.PluralRules(locale, numbers.resolvedOptions()),\n    };\n    cache.set(key, entry);\n  }\n  return entry;\n}\n\n// The number as the reader's locale writes it: 1,234 in en-US, 1.234 in de-DE.\nexport function formatCount(\n  count: number,\n  {\n    locale = \"en-US\",\n    numberFormat,\n  }: Pick<PluralizeOptions, \"locale\" | \"numberFormat\"> = {},\n) {\n  return formatters(locale, numberFormat).numbers.format(count);\n}\n\n// The plural category for a count, e.g. \"one\", \"few\", \"many\" or \"other\".\nexport function pluralCategory(\n  count: number,\n  {\n    locale = \"en-US\",\n    numberFormat,\n  }: Pick<PluralizeOptions, \"locale\" | \"numberFormat\"> = {},\n): PluralCategory {\n  return formatters(locale, numberFormat).rules.select(count);\n}\n\n// pluralize(3, { one: \"comment\", other: \"comments\" }) → \"3 comments\".\n// A form may place the number itself with \"#\": { one: \"# Datei\", other: \"# Dateien\" }.\nexport function pluralize(\n  count: number,\n  forms: PluralForms,\n  {\n    locale = \"en-US\",\n    none,\n    numberFormat,\n    includeCount = true,\n  }: PluralizeOptions = {},\n): string {\n  if (!Number.isFinite(count))\n    throw new RangeError(`pluralize needs a finite count, got ${count}.`);\n  if (count === 0 && none !== undefined) return none;\n  const category = pluralCategory(count, { locale, numberFormat });\n  const form = forms[category] ?? forms.other;\n  if (!includeCount) return form.replace(/#\\s?/, \"\").trim();\n  const number = formatCount(count, { locale, numberFormat });\n  return form.includes(\"#\") ? form.replace(\"#\", number) : `${number} ${form}`;\n}\n\n// The same rules as an element, for JSX: <Plural count={n} forms={…} />.\n// It renders plain inline text, so it inherits the surrounding type.\nexport function Plural({\n  count,\n  forms,\n  locale,\n  none,\n  numberFormat,\n  includeCount,\n}: { count: number; forms: PluralForms } & PluralizeOptions) {\n  return (\n    <span>\n      {pluralize(count, forms, { locale, none, numberFormat, includeCount })}\n    </span>\n  );\n}\n",
      "type": "registry:file",
      "target": "@components/royer/pluralize.tsx"
    },
    {
      "path": "src/registry/foundation.css",
      "content": "/* Generated from library-source.json. Only prefixed tokens and control classes. */\n:where(:root) {\n  --rui-canvas: #eef5f3;\n  --rui-surface: #ffffff;\n  --rui-ink: #122221;\n  --rui-muted: #536563;\n  --rui-line: #c4d4cf;\n  --rui-control-line: #71847e;\n  --rui-brand: #075d58;\n  --rui-on-brand: #ffffff;\n  --rui-soft: #dcecea;\n  --rui-danger: #a43d32;\n  --rui-on-danger: #ffffff;\n  --rui-success: #176347;\n  --rui-success-soft: #e0f1e8;\n  --rui-warning: #805311;\n  --rui-warning-soft: #fff0cd;\n  --rui-radius: 0.625rem;\n}\n:where(.dark, [data-mode=\"dark\"]) {\n  --rui-canvas: #101817;\n  --rui-surface: #182422;\n  --rui-ink: #f1f7f5;\n  --rui-muted: #b7c7c4;\n  --rui-line: #536a64;\n  --rui-control-line: #71847e;\n  --rui-brand: #63c8bf;\n  --rui-on-brand: #000000;\n  --rui-soft: #253e3b;\n  --rui-danger: #ff9e91;\n  --rui-on-danger: #000000;\n  --rui-success: #a0dfbc;\n  --rui-success-soft: #203d2b;\n  --rui-warning: #f4d080;\n  --rui-warning-soft: #41341a;\n  --rui-radius: 0.625rem;\n}\n\n.rui-control { font-family: inherit; cursor: pointer; }\n.rui-control:focus-visible { outline: 3px solid var(--rui-brand); outline-offset: 3px; }\ninput.rui-control, textarea.rui-control { cursor: text; }\n.rui-control:disabled { cursor: not-allowed; }\n.rui-field { background: var(--rui-field-background, var(--rui-surface)); box-shadow: var(--rui-field-shadow, none); }\n@media (prefers-reduced-motion: reduce) { .rui-control { transition: none; } }\n",
      "type": "registry:file",
      "target": "@components/royer/foundation.css"
    }
  ],
  "meta": {
    "version": "0.2.1",
    "testedWith": {
      "@fontsource/geist": "^5.3.0",
      "@phosphor-icons/react": "^2.1.10",
      "clsx": "^2.1.1",
      "motion": "^14.1.0",
      "tailwind-merge": "^3.3.1"
    },
    "origin": "Portfolio",
    "extraction": "source-port",
    "sources": [
      {
        "id": "portfolio-src-app-globals-css",
        "projectId": "portfolio",
        "repository": "royeradames-web-v3",
        "revision": "ae2696c4756513c4371456980a1360ce12f1fda3",
        "path": "src/app/globals.css",
        "sha256": "9a4ee0bab63bcf99c813342c338a56744f339e2a395316315a15a44bf03626c9",
        "captured": "2026-10-07T03:34:31.091902+00:00",
        "note": "Exact local CSS bytes; observed palettes are preserved separately from semantic-role adaptations.",
        "url": "https://github.com/royeradames/royeradames-web-v3/blob/ae2696c4756513c4371456980a1360ce12f1fda3/src/app/globals.css"
      },
      {
        "id": "portfolio-src-components-finder-exhibition-finder-tsx",
        "projectId": "portfolio",
        "repository": "royeradames-web-v3",
        "revision": "ae2696c4756513c4371456980a1360ce12f1fda3",
        "path": "src/components/finder/exhibition-finder.tsx",
        "sha256": "a403a2464c3e0e78ee2080dbe4d1f0c6d6eec1b0d3c7fd21ae44821a943cd029",
        "captured": "2026-10-07T03:34:31.091902+00:00",
        "note": "Current-source component/control candidate. No private fixture records or artwork copied.",
        "url": "https://github.com/royeradames/royeradames-web-v3/blob/ae2696c4756513c4371456980a1360ce12f1fda3/src/components/finder/exhibition-finder.tsx"
      },
      {
        "id": "portfolio-src-lib-finder-query-ts",
        "projectId": "portfolio",
        "repository": "royeradames/royeradames-web-v3",
        "revision": "ae2696c4756513c4371456980a1360ce12f1fda3",
        "path": "src/lib/finder-query.ts",
        "sha256": "a23cf534de40cbba03b2c7981a0317a088f2d99bcd061c81396a4cca6b5d3eda",
        "captured": "2026-10-07",
        "note": "Pinned Portfolio PR22 source for faithful composition extraction; caller data, scoped materials and browser adapters are documented in docs/portfolio-extraction.md.",
        "url": "https://github.com/royeradames/royeradames-web-v3/blob/ae2696c4756513c4371456980a1360ce12f1fda3/src/lib/finder-query.ts"
      },
      {
        "id": "portfolio-src-lib-working-on-ts",
        "projectId": "portfolio",
        "repository": "royeradames/royeradames-web-v3",
        "revision": "ae2696c4756513c4371456980a1360ce12f1fda3",
        "path": "src/lib/working-on.ts",
        "sha256": "42690ba2b5b9ecdfb9940c01294621503904604aef279fb0d2f3f46adbcc10d2",
        "captured": "2026-10-07",
        "note": "Pinned Portfolio PR22 source for faithful composition extraction; caller data, scoped materials and browser adapters are documented in docs/portfolio-extraction.md.",
        "url": "https://github.com/royeradames/royeradames-web-v3/blob/ae2696c4756513c4371456980a1360ce12f1fda3/src/lib/working-on.ts"
      }
    ],
    "kind": "block",
    "pageFamily": null,
    "useCases": [],
    "composedControls": [],
    "tradeoffs": [],
    "audit": {
      "tier": "block",
      "category": "Content",
      "sources": [
        "portfolio"
      ],
      "inspiredBy": null,
      "variantOf": null,
      "composes": [
        "pluralize",
        "portfolio-badges",
        "portfolio-project-card"
      ],
      "tokens": [
        "--rui-brand",
        "--rui-canvas",
        "--rui-control-line",
        "--rui-ink",
        "--rui-muted",
        "--rui-on-brand",
        "--rui-portfolio-backdrop-horizontal",
        "--rui-portfolio-backdrop-vertical",
        "--rui-portfolio-blue",
        "--rui-portfolio-blue-light",
        "--rui-portfolio-chip-fill",
        "--rui-portfolio-chip-ink",
        "--rui-portfolio-field",
        "--rui-portfolio-ink-bright",
        "--rui-portfolio-input",
        "--rui-portfolio-input-shadow",
        "--rui-portfolio-inset",
        "--rui-portfolio-inset-shadow",
        "--rui-portfolio-label-ink",
        "--rui-portfolio-link-hover",
        "--rui-portfolio-panel",
        "--rui-portfolio-purple",
        "--rui-portfolio-purple-light",
        "--rui-portfolio-reading",
        "--rui-portfolio-shadow",
        "--rui-portfolio-surface-accent",
        "--rui-soft",
        "--rui-surface"
      ],
      "states": [
        "idle"
      ],
      "pitfalls": [
        "I1"
      ],
      "useCases": [
        "block",
        "content",
        "portfolio"
      ]
    },
    "props": [],
    "states": [],
    "pitfalls": [
      {
        "id": "I1",
        "name": "Wrong plural for a count of one (projects)",
        "prevention": "The project count uses pluralize."
      }
    ],
    "provenance": {
      "kind": "adapted",
      "note": "Source identity is recorded in the project source manifest."
    }
  },
  "type": "registry:block"
}