21st.dev 원본

Unchecked Group · Fluid Functionalism

섹션 · MIT

섹션 더 보기
ORIGINAL PREVIEW
Unchecked Group · Fluid Functionalism 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭실행 HTML 저장

저장한 HTML에서는 갤러리의 재생 설정이 적용되지 않아요.

SOURCE FILES

원본 코드 읽기

37개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다. 실행 HTML에는 미리보기를 위한 실행 환경이 들어 있습니다.

author-example.tsx
파일 저장

"use client";

import {
  useRef,
  useState,
  useEffect,
  createContext,
  useContext,
  forwardRef,
  type ReactNode,
  type HTMLAttributes,
} from "react";
import { motion, AnimatePresence } from "framer-motion";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
import { fontWeights } from "@/lib/font-weight";
import { useFluidHover, useRegisterFluidHoverItem } from "@/hooks/use-fluid-hover";
import { useMergeSplitBlocks, SelectionBackgrounds } from "@/hooks/use-merge-split";
import { useShape } from "@/lib/shape-context";
import { SizeProvider, useSize, type SizeVariant } from "@/lib/size-context";
import { FluidHoverHighlight } from "@/components/ui/fluid-hover-highlight";

interface CheckboxGroupContextValue {
  registerItem: (index: number, element: HTMLElement | null) => void;
  activeIndex: number | null;
}

const CheckboxGroupContext = createContext<CheckboxGroupContextValue | null>(
  null
);

function useCheckboxGroup() {
  const ctx = useContext(CheckboxGroupContext);
  if (!ctx)
    throw new Error("useCheckboxGroup must be used within a CheckboxGroup");
  return ctx;
}

interface CheckboxGroupProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
  checkedIndices: Set<number>;
  /** Pins the group's rows to one step of the size ladder (default 36px,
   *  compact 28px — see /docs/sizes). Omitted, it follows the surrounding
   *  SizeProvider. */
  size?: SizeVariant;
}

const CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(
  ({ children, checkedIndices, size, className, ...props }, ref) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const groupIdCounter = useRef(0);
    const prevGroupMap = useRef(new Map<number, number>());

    const hover = useFluidHover(containerRef);
    const {
      activeIndex,
      setActiveIndex,
      itemRects,
      handlers,
      registerItem,
    } = hover;

    // Group contiguous checked indices into runs with stable IDs
    const runs: { start: number; end: number }[] = [];
    const sortedChecked = [...checkedIndices].sort((a, b) => a - b);
    for (const idx of sortedChecked) {
      const last = runs[runs.length - 1];
      if (last && idx === last.end + 1) {
        last.end = idx;
      } else {
        runs.push({ start: idx, end: idx });
      }
    }

    // Assign stable IDs: reuse previous ID if any member overlaps
    const usedIds = new Set<number>();
    const newGroupMap = new Map<number, number>();
    const checkedGroups = runs.map((run) => {
      let stableId: number | null = null;
      for (let i = run.start; i <= run.end; i++) {
        const prevId = prevGroupMap.current.get(i);
        if (prevId !== undefined && !usedIds.has(prevId)) {
          stableId = prevId;
          break;
        }
      }
      const id = stableId ?? ++groupIdCounter.current;
      usedIds.add(id);
      for (let i = run.start; i <= run.end; i++) {
        newGroupMap.set(i, id);
      }
      return { ...run, id };
    });
    prevGroupMap.current = newGroupMap;

    const [focusedIndex, setFocusedIndex] = useState<number | null>(null);

    const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;
    const shape = useShape();

    // Selected backgrounds, with the merge/split boundary animation when one
    // unchecked row bridges or splits two checked runs.
    const blocks = useMergeSplitBlocks(checkedGroups, itemRects, shape.mergedRadius);

    const group = (
      <CheckboxGroupContext.Provider value={{ registerItem, activeIndex }}>
        <div
          ref={(node) => {
            (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
            if (typeof ref === "function") ref(node);
            else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
          }}
          onMouseEnter={handlers.onMouseEnter}
          onMouseMove={handlers.onMouseMove}
          onMouseLeave={handlers.onMouseLeave}
          onClick={handlers.onClick}
          onFocus={(e) => {
            const indexAttr = (e.target as HTMLElement)
              .closest("[data-fluid-hover-index]")
              ?.getAttribute("data-fluid-hover-index");
            if (indexAttr != null) {
              const idx = Number(indexAttr);
              setActiveIndex(idx);
              setFocusedIndex(
                (e.target as HTMLElement).matches(":focus-visible") ? idx : null
              );
            }
          }}
          onBlur={(e) => {
            // Don't clear hover when focus moves to another item within the group
            if (containerRef.current?.contains(e.relatedTarget as Node)) return;
            setFocusedIndex(null);
            setActiveIndex(null);
          }}
          onKeyDown={(e) => {
            // Scope to row wrappers only. The inner checkbox primitive also
            // carries role="checkbox", so a bare [role="checkbox"] selector
            // matches twice per row and arrows skip onto the hidden control.
            const items = Array.from(
              containerRef.current?.querySelectorAll("[data-fluid-hover-index]") ?? []
            ) as HTMLElement[];
            const currentIdx = items.indexOf(e.target as HTMLElement);
            if (currentIdx === -1) return;

            if (["ArrowDown", "ArrowUp"].includes(e.key)) {
              e.preventDefault();
              const next = e.key === "ArrowDown"
                ? (currentIdx + 1) % items.length
                : (currentIdx - 1 + items.length) % items.length;
              items[next].focus();
            } else if (e.key === "Home") {
              e.preventDefault();
              items[0]?.focus();
            } else if (e.key === "End") {
              e.preventDefault();
              items[items.length - 1]?.focus();
            }
          }}
          role="group"
          className={cn(
            "relative flex flex-col w-72 max-w-full select-none",
            className
          )}
          {...props}
        >
          {/* Selected backgrounds (merged for contiguous checked items).
              A run is normally one block; mid merge/split it is drawn as two
              abutting halves — see useMergeSplitBlocks. */}
          <SelectionBackgrounds blocks={blocks} />

          {/* Hover background */}
          <FluidHoverHighlight
            hover={hover}
            className={shape.bg}
          />

          {/* Focus ring */}
          <AnimatePresence>
            {focusRect && (
              <motion.div
                className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[color:var(--focus-ring,#6B97FF)]`}
                initial={false}
                animate={{
                  left: focusRect.left - 2,
                  top: focusRect.top - 2,
                  width: focusRect.width + 4,
                  height: focusRect.height + 4,
                }}
                exit={{ opacity: 0, transition: spring.fast.exit }}
                transition={{
                  ...spring.fast,
                  opacity: { duration: 0.08 },
                }}
              />
            )}
          </AnimatePresence>

          {children}
        </div>
      </CheckboxGroupContext.Provider>
    );

    // A size prop pins every row in the group to one ladder step.
    return size ? <SizeProvider size={size}>{group}</SizeProvider> : group;
  }
);

CheckboxGroup.displayName = "CheckboxGroup";

interface CheckboxItemProps extends HTMLAttributes<HTMLDivElement> {
  label: string;
  index: number;
  checked: boolean;
  onToggle: () => void;
}

const CheckboxItem = forwardRef<HTMLDivElement, CheckboxItemProps>(
  ({ label, index, checked, onToggle, className, ...props }, ref) => {
    const internalRef = useRef<HTMLDivElement>(null);
    const hasMounted = useRef(false);
    const { registerItem, activeIndex } = useCheckboxGroup();

    useRegisterFluidHoverItem(registerItem, index, internalRef);

    useEffect(() => {
      hasMounted.current = true;
    }, []);

    const isActive = activeIndex === index;
    const skipAnimation = !hasMounted.current;
    const shape = useShape();
    const sizeClasses = useSize();
    const compact = sizeClasses.variant === "compact";

    return (
      <div
        ref={(node) => {
          (internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
          if (typeof ref === "function") ref(node);
          else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
        }}
        data-fluid-hover-index={index}
        tabIndex={0}
        role="checkbox"
        aria-checked={checked}
        aria-label={label}
        onClick={onToggle}
        onMouseDown={(e) => {
          // Clicking the 15px checkbox square would natively focus the hidden
          // primitive (nearest focusable ancestor of the click target), after
          // which arrow-key nav dead-zones: the group keydown handler can't
          // find the target among the row wrappers. Prevent the native focus
          // move (click still fires) and land focus on the row instead. Skip
          // genuinely interactive children so we don't hijack their focus.
          const interactive = (e.target as HTMLElement).closest(
            'button:not([tabindex="-1"]), a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          );
          if (interactive && interactive !== e.currentTarget) return;
          e.preventDefault();
          e.currentTarget.focus();
        }}
        onKeyDown={(e) => {
          if (e.key === " " || e.key === "Enter") {
            e.preventDefault();
            onToggle();
          }
        }}
        className={cn(
          // Fixed height (was py-1.5 around a 19.5px line box ≈ 31.5px) so the
          // text-box trim on the label doesn't shrink the row.
          `relative z-10 flex ${sizeClasses.control} items-center ${sizeClasses.gap} ${shape.item} ${sizeClasses.px} cursor-pointer outline-none`,
          className
        )}
        {...props}
      >
        {/* Checkbox — Radix primitive for accessibility */}
        <CheckboxPrimitive.Root
          checked={checked}
          onCheckedChange={() => onToggle()}
          tabIndex={-1}
          aria-hidden
          className={cn(
            "relative shrink-0 appearance-none bg-transparent p-0 border-0 outline-none cursor-pointer",
            compact ? "w-[14px] h-[14px]" : "w-[16px] h-[16px]"
          )}
          onClick={(e) => e.stopPropagation()}
        >
          {/* Border */}
          <div
            className={cn(
              "absolute inset-0 border-solid transition-all duration-80",
              compact ? "rounded-[4px]" : "rounded-[5px]",
              checked
                ? "border-[1.5px] border-transparent"
                : isActive
                ? "border-[1.5px] border-neutral-400 dark:border-neutral-500"
                : "border-[1.5px] border-border"
            )}
          />
          {/* Check mark */}
          <AnimatePresence>
            {checked && (
              <CheckboxPrimitive.Indicator forceMount asChild>
                <motion.svg
                  width={compact ? 16 : 18}
                  height={compact ? 16 : 18}
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-foreground"
                  initial={{ opacity: 1 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 1 }}
                >
                  <motion.path
                    d="M6 12L10 16L18 8"
                    initial={{
                      pathLength: skipAnimation ? 1 : 0,
                    }}
                    animate={{
                      pathLength: 1,
                      transition: {
                        duration: 0.08,
                        ease: "easeOut",
                      },
                    }}
                    exit={{
                      pathLength: 0,
                      transition: {
                        duration: 0.04,
                        ease: "easeIn",
                      },
                    }}
                  />
                </motion.svg>
              </CheckboxPrimitive.Indicator>
            )}
          </AnimatePresence>
        </CheckboxPrimitive.Root>

        {/* Label */}
        {/* Both stacked spans carry the text-box trim so the invisible bold
            sizer and the visible label keep identical boxes. */}
        <span className={cn("inline-grid", sizeClasses.text)}>
          <span
            className="col-start-1 row-start-1 invisible [text-box:trim-both_cap_alphabetic]"
            style={{ fontVariationSettings: fontWeights.semibold }}
            aria-hidden="true"
          >
            {label}
          </span>
          <span
            className={cn(
              "col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80 [text-box:trim-both_cap_alphabetic]",
              checked || isActive
                ? "text-foreground"
                : "text-muted-foreground"
            )}
            style={{
              fontVariationSettings: checked
                ? fontWeights.semibold
                : fontWeights.normal,
            }}
          >
            {label}
          </span>
        </span>
      </div>
    );
  }
);

CheckboxItem.displayName = "CheckboxItem";

export { CheckboxGroup, CheckboxItem };
export default CheckboxGroup;
LICENSE
파일 저장

MIT License

Copyright (c) 2026 Micka Touillaud

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
함께 쓰는 파일 35개 보기
upstream/app/docs/checkbox-group/page.tsx
파일 저장

"use client";

import { useState } from "react";
import { CheckboxGroup, CheckboxItem } from "@/registry/radix/checkbox-group";
import { ComponentPreview } from "@/lib/docs/ComponentPreview";
import { PropsTable, type PropDef } from "@/lib/docs/PropsTable";
import { DocPage, DocSection } from "@/lib/docs/DocPage";

const basicCode = `import { CheckboxGroup, CheckboxItem } from "./components";
import { useState } from "react";

const items = ["Apples", "Bananas", "Cherries", "Dates"];
const [checked, setChecked] = useState<Set<number>>(new Set([0]));

<CheckboxGroup checkedIndices={checked}>
  {items.map((label, i) => (
    <CheckboxItem
      key={label}
      index={i}
      label={label}
      checked={checked.has(i)}
      onToggle={() => {
        setChecked((prev) => {
          const next = new Set(prev);
          if (next.has(i)) next.delete(i);
          else next.add(i);
          return next;
        });
      }}
    />
  ))}
</CheckboxGroup>`;

const groupProps: PropDef[] = [
  { name: "checkedIndices", type: "Set<number>", description: "Set of checked item indices. Used for merged background rendering." },
  { name: "children", type: "ReactNode", description: "CheckboxItem children." },
];

const itemProps: PropDef[] = [
  { name: "label", type: "string", description: "Text label for the checkbox." },
  { name: "index", type: "number", description: "Position index within the group." },
  { name: "checked", type: "boolean", description: "Whether this item is checked." },
  { name: "onToggle", type: "() => void", description: "Called when this item is toggled." },
];

export default function CheckboxGroupDoc() {
  const items = ["Apples", "Bananas", "Cherries", "Dates"];
  const [checked, setChecked] = useState<Set<number>>(new Set([0]));

  return (
    <DocPage
      title="CheckboxGroup"
      slug="checkbox-group"
      description="Checkbox group with merged backgrounds for contiguous selections."
    >
      <DocSection title="Basic">
        <ComponentPreview code={basicCode}>
          <CheckboxGroup checkedIndices={checked}>
            {items.map((label, i) => (
              <CheckboxItem
                key={label}
                index={i}
                label={label}
                checked={checked.has(i)}
                onToggle={() => {
                  setChecked((prev) => {
                    const next = new Set(prev);
                    if (next.has(i)) next.delete(i);
                    else next.add(i);
                    return next;
                  });
                }}
              />
            ))}
          </CheckboxGroup>
        </ComponentPreview>
      </DocSection>

      <DocSection title="API Reference — CheckboxGroup">
        <PropsTable props={groupProps} />
      </DocSection>

      <DocSection title="API Reference — CheckboxItem">
        <PropsTable props={itemProps} />
      </DocSection>
    </DocPage>
  );
}
upstream/public/r/checkbox-group.json
파일 저장

{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "checkbox-group",
  "title": "Checkbox Group",
  "description": "Animated checkbox group with fluid hover, contiguous selection merging, and spring-animated check marks. Built on Radix UI Checkbox.",
  "dependencies": [
    "framer-motion",
    "@radix-ui/react-checkbox"
  ],
  "registryDependencies": [
    "utils",
    "https://www.fluidfunctionalism.com/r/springs.json",
    "https://www.fluidfunctionalism.com/r/font-weight.json",
    "https://www.fluidfunctionalism.com/r/shape-context.json",
    "https://www.fluidfunctionalism.com/r/use-fluid-hover.json",
    "https://www.fluidfunctionalism.com/r/use-merge-split.json",
    "https://www.fluidfunctionalism.com/r/size-context.json",
    "https://www.fluidfunctionalism.com/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/radix/checkbox-group.tsx",
      "content": "\"use client\";\n\nimport {\n  useRef,\n  useState,\n  useEffect,\n  createContext,\n  useContext,\n  forwardRef,\n  type ReactNode,\n  type HTMLAttributes,\n} from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\";\nimport { cn } from \"@/lib/utils\";\nimport { spring } from \"@/lib/springs\";\nimport { fontWeights } from \"@/lib/font-weight\";\nimport { useFluidHover, useRegisterFluidHoverItem } from \"@/hooks/use-fluid-hover\";\nimport { useMergeSplitBlocks, SelectionBackgrounds } from \"@/hooks/use-merge-split\";\nimport { useShape } from \"@/lib/shape-context\";\nimport { SizeProvider, useSize, type SizeVariant } from \"@/lib/size-context\";\nimport { FluidHoverHighlight } from \"@/components/ui/fluid-hover-highlight\";\n\ninterface CheckboxGroupContextValue {\n  registerItem: (index: number, element: HTMLElement | null) => void;\n  activeIndex: number | null;\n}\n\nconst CheckboxGroupContext = createContext<CheckboxGroupContextValue | null>(\n  null\n);\n\nfunction useCheckboxGroup() {\n  const ctx = useContext(CheckboxGroupContext);\n  if (!ctx)\n    throw new Error(\"useCheckboxGroup must be used within a CheckboxGroup\");\n  return ctx;\n}\n\ninterface CheckboxGroupProps extends HTMLAttributes<HTMLDivElement> {\n  children: ReactNode;\n  checkedIndices: Set<number>;\n  /** Pins the group's rows to one step of the size ladder (default 36px,\n   *  compact 28px — see /docs/sizes). Omitted, it follows the surrounding\n   *  SizeProvider. */\n  size?: SizeVariant;\n}\n\nconst CheckboxGroup = forwardRef<HTMLDivElement, CheckboxGroupProps>(\n  ({ children, checkedIndices, size, className, ...props }, ref) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const groupIdCounter = useRef(0);\n    const prevGroupMap = useRef(new Map<number, number>());\n\n    const hover = useFluidHover(containerRef);\n    const {\n      activeIndex,\n      setActiveIndex,\n      itemRects,\n      handlers,\n      registerItem,\n    } = hover;\n\n    // Group contiguous checked indices into runs with stable IDs\n    const runs: { start: number; end: number }[] = [];\n    const sortedChecked = [...checkedIndices].sort((a, b) => a - b);\n    for (const idx of sortedChecked) {\n      const last = runs[runs.length - 1];\n      if (last && idx === last.end + 1) {\n        last.end = idx;\n      } else {\n        runs.push({ start: idx, end: idx });\n      }\n    }\n\n    // Assign stable IDs: reuse previous ID if any member overlaps\n    const usedIds = new Set<number>();\n    const newGroupMap = new Map<number, number>();\n    const checkedGroups = runs.map((run) => {\n      let stableId: number | null = null;\n      for (let i = run.start; i <= run.end; i++) {\n        const prevId = prevGroupMap.current.get(i);\n        if (prevId !== undefined && !usedIds.has(prevId)) {\n          stableId = prevId;\n          break;\n        }\n      }\n      const id = stableId ?? ++groupIdCounter.current;\n      usedIds.add(id);\n      for (let i = run.start; i <= run.end; i++) {\n        newGroupMap.set(i, id);\n      }\n      return { ...run, id };\n    });\n    prevGroupMap.current = newGroupMap;\n\n    const [focusedIndex, setFocusedIndex] = useState<number | null>(null);\n\n    const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n    const shape = useShape();\n\n    // Selected backgrounds, with the merge/split boundary animation when one\n    // unchecked row bridges or splits two checked runs.\n    const blocks = useMergeSplitBlocks(checkedGroups, itemRects, shape.mergedRadius);\n\n    const group = (\n      <CheckboxGroupContext.Provider value={{ registerItem, activeIndex }}>\n        <div\n          ref={(node) => {\n            (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n          }}\n          onMouseEnter={handlers.onMouseEnter}\n          onMouseMove={handlers.onMouseMove}\n          onMouseLeave={handlers.onMouseLeave}\n          onClick={handlers.onClick}\n          onFocus={(e) => {\n            const indexAttr = (e.target as HTMLElement)\n              .closest(\"[data-fluid-hover-index]\")\n              ?.getAttribute(\"data-fluid-hover-index\");\n            if (indexAttr != null) {\n              const idx = Number(indexAttr);\n              setActiveIndex(idx);\n              setFocusedIndex(\n                (e.target as HTMLElement).matches(\":focus-visible\") ? idx : null\n              );\n            }\n          }}\n          onBlur={(e) => {\n            // Don't clear hover when focus moves to another item within the group\n            if (containerRef.current?.contains(e.relatedTarget as Node)) return;\n            setFocusedIndex(null);\n            setActiveIndex(null);\n          }}\n          onKeyDown={(e) => {\n            // Scope to row wrappers only. The inner checkbox primitive also\n            // carries role=\"checkbox\", so a bare [role=\"checkbox\"] selector\n            // matches twice per row and arrows skip onto the hidden control.\n            const items = Array.from(\n              containerRef.current?.querySelectorAll(\"[data-fluid-hover-index]\") ?? []\n            ) as HTMLElement[];\n            const currentIdx = items.indexOf(e.target as HTMLElement);\n            if (currentIdx === -1) return;\n\n            if ([\"ArrowDown\", \"ArrowUp\"].includes(e.key)) {\n              e.preventDefault();\n              const next = e.key === \"ArrowDown\"\n                ? (currentIdx + 1) % items.length\n                : (currentIdx - 1 + items.length) % items.length;\n              items[next].focus();\n            } else if (e.key === \"Home\") {\n              e.preventDefault();\n              items[0]?.focus();\n            } else if (e.key === \"End\") {\n              e.preventDefault();\n              items[items.length - 1]?.focus();\n            }\n          }}\n          role=\"group\"\n          className={cn(\n            \"relative flex flex-col w-72 max-w-full select-none\",\n            className\n          )}\n          {...props}\n        >\n          {/* Selected backgrounds (merged for contiguous checked items).\n              A run is normally one block; mid merge/split it is drawn as two\n              abutting halves — see useMergeSplitBlocks. */}\n          <SelectionBackgrounds blocks={blocks} />\n\n          {/* Hover background */}\n          <FluidHoverHighlight\n            hover={hover}\n            className={shape.bg}\n          />\n\n          {/* Focus ring */}\n          <AnimatePresence>\n            {focusRect && (\n              <motion.div\n                className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[color:var(--focus-ring,#6B97FF)]`}\n                initial={false}\n                animate={{\n                  left: focusRect.left - 2,\n                  top: focusRect.top - 2,\n                  width: focusRect.width + 4,\n                  height: focusRect.height + 4,\n                }}\n                exit={{ opacity: 0, transition: spring.fast.exit }}\n                transition={{\n                  ...spring.fast,\n                  opacity: { duration: 0.08 },\n                }}\n              />\n            )}\n          </AnimatePresence>\n\n          {children}\n        </div>\n      </CheckboxGroupContext.Provider>\n    );\n\n    // A size prop pins every row in the group to one ladder step.\n    return size ? <SizeProvider size={size}>{group}</SizeProvider> : group;\n  }\n);\n\nCheckboxGroup.displayName = \"CheckboxGroup\";\n\ninterface CheckboxItemProps extends HTMLAttributes<HTMLDivElement> {\n  label: string;\n  index: number;\n  checked: boolean;\n  onToggle: () => void;\n}\n\nconst CheckboxItem = forwardRef<HTMLDivElement, CheckboxItemProps>(\n  ({ label, index, checked, onToggle, className, ...props }, ref) => {\n    const internalRef = useRef<HTMLDivElement>(null);\n    const hasMounted = useRef(false);\n    const { registerItem, activeIndex } = useCheckboxGroup();\n\n    useRegisterFluidHoverItem(registerItem, index, internalRef);\n\n    useEffect(() => {\n      hasMounted.current = true;\n    }, []);\n\n    const isActive = activeIndex === index;\n    const skipAnimation = !hasMounted.current;\n    const shape = useShape();\n    const sizeClasses = useSize();\n    const compact = sizeClasses.variant === \"compact\";\n\n    return (\n      <div\n        ref={(node) => {\n          (internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n        }}\n        data-fluid-hover-index={index}\n        tabIndex={0}\n        role=\"checkbox\"\n        aria-checked={checked}\n        aria-label={label}\n        onClick={onToggle}\n        onMouseDown={(e) => {\n          // Clicking the 15px checkbox square would natively focus the hidden\n          // primitive (nearest focusable ancestor of the click target), after\n          // which arrow-key nav dead-zones: the group keydown handler can't\n          // find the target among the row wrappers. Prevent the native focus\n          // move (click still fires) and land focus on the row instead. Skip\n          // genuinely interactive children so we don't hijack their focus.\n          const interactive = (e.target as HTMLElement).closest(\n            'button:not([tabindex=\"-1\"]), a[href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n          );\n          if (interactive && interactive !== e.currentTarget) return;\n          e.preventDefault();\n          e.currentTarget.focus();\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \" \" || e.key === \"Enter\") {\n            e.preventDefault();\n            onToggle();\n          }\n        }}\n        className={cn(\n          // Fixed height (was py-1.5 around a 19.5px line box ≈ 31.5px) so the\n          // text-box trim on the label doesn't shrink the row.\n          `relative z-10 flex ${sizeClasses.control} items-center ${sizeClasses.gap} ${shape.item} ${sizeClasses.px} cursor-pointer outline-none`,\n          className\n        )}\n        {...props}\n      >\n        {/* Checkbox — Radix primitive for accessibility */}\n        <CheckboxPrimitive.Root\n          checked={checked}\n          onCheckedChange={() => onToggle()}\n          tabIndex={-1}\n          aria-hidden\n          className={cn(\n            \"relative shrink-0 appearance-none bg-transparent p-0 border-0 outline-none cursor-pointer\",\n            compact ? \"w-[14px] h-[14px]\" : \"w-[16px] h-[16px]\"\n          )}\n          onClick={(e) => e.stopPropagation()}\n        >\n          {/* Border */}\n          <div\n            className={cn(\n              \"absolute inset-0 border-solid transition-all duration-80\",\n              compact ? \"rounded-[4px]\" : \"rounded-[5px]\",\n              checked\n                ? \"border-[1.5px] border-transparent\"\n                : isActive\n                ? \"border-[1.5px] border-neutral-400 dark:border-neutral-500\"\n                : \"border-[1.5px] border-border\"\n            )}\n          />\n          {/* Check mark */}\n          <AnimatePresence>\n            {checked && (\n              <CheckboxPrimitive.Indicator forceMount asChild>\n                <motion.svg\n                  width={compact ? 16 : 18}\n                  height={compact ? 16 : 18}\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={2}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  className=\"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-foreground\"\n                  initial={{ opacity: 1 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 1 }}\n                >\n                  <motion.path\n                    d=\"M6 12L10 16L18 8\"\n                    initial={{\n                      pathLength: skipAnimation ? 1 : 0,\n                    }}\n                    animate={{\n                      pathLength: 1,\n                      transition: {\n                        duration: 0.08,\n                        ease: \"easeOut\",\n                      },\n                    }}\n                    exit={{\n                      pathLength: 0,\n                      transition: {\n                        duration: 0.04,\n                        ease: \"easeIn\",\n                      },\n                    }}\n                  />\n                </motion.svg>\n              </CheckboxPrimitive.Indicator>\n            )}\n          </AnimatePresence>\n        </CheckboxPrimitive.Root>\n\n        {/* Label */}\n        {/* Both stacked spans carry the text-box trim so the invisible bold\n            sizer and the visible label keep identical boxes. */}\n        <span className={cn(\"inline-grid\", sizeClasses.text)}>\n          <span\n            className=\"col-start-1 row-start-1 invisible [text-box:trim-both_cap_alphabetic]\"\n            style={{ fontVariationSettings: fontWeights.semibold }}\n            aria-hidden=\"true\"\n          >\n            {label}\n          </span>\n          <span\n            className={cn(\n              \"col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80 [text-box:trim-both_cap_alphabetic]\",\n              checked || isActive\n                ? \"text-foreground\"\n                : \"text-muted-foreground\"\n            )}\n            style={{\n              fontVariationSettings: checked\n                ? fontWeights.semibold\n                : fontWeights.normal,\n            }}\n          >\n            {label}\n          </span>\n        </span>\n      </div>\n    );\n  }\n);\n\nCheckboxItem.displayName = \"CheckboxItem\";\n\nexport { CheckboxGroup, CheckboxItem };\nexport default CheckboxGroup;\n",
      "type": "registry:ui",
      "target": "components/ui/checkbox-group.tsx"
    }
  ],
  "docs": "Docs & live playground: https://www.fluidfunctionalism.com/docs/checkbox-group.",
  "categories": [
    "components"
  ],
  "type": "registry:ui"
}
upstream/public/r/tokens.json
파일 저장

{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tokens",
  "title": "Interaction Tokens",
  "description": "Shared interaction-state tokens: surface-relative hover/active overlays (bg-hover, bg-active), the selected fill, the destructive-light ground, the --overlay RGB triplet, the --focus-ring color, and the base-layer :focus-visible fallback ring.",
  "files": [],
  "cssVars": {
    "theme": {
      "color-hover": "var(--hover)",
      "color-active": "var(--active)",
      "color-selected": "var(--selected)",
      "color-destructive-light": "var(--destructive-light)"
    },
    "light": {
      "hover": "rgb(0 0 0 / 0.04)",
      "active": "rgb(0 0 0 / 0.07)",
      "selected": "#D4D4D4",
      "destructive-light": "#FEF2F2",
      "overlay": "0 0 0",
      "focus-ring": "#6B97FF"
    },
    "dark": {
      "hover": "rgb(255 255 255 / 0.06)",
      "active": "rgb(255 255 255 / 0.1)",
      "selected": "#525252",
      "destructive-light": "#450A0A",
      "overlay": "255 255 255"
    }
  },
  "css": {
    "@layer base": {
      ":focus-visible": {
        "outline": "1px solid var(--focus-ring, #6B97FF)",
        "outline-offset": "2px",
        "border-radius": "var(--shape-input-radius, 8px)"
      },
      "a:focus-visible": {
        "border-radius": "2px"
      }
    }
  },
  "docs": "Docs & live playground: https://www.fluidfunctionalism.com/docs/surfaces.",
  "categories": [
    "theme"
  ],
  "type": "registry:theme"
}
upstream/README.md
파일 저장

```text





───────────────────────────────────────
F L U I D   F U N C T I O N A L I S M
───────────────────────────────────────





```

# Fluid Functionalism

Refined UI components with satisfying hover.

A [shadcn/ui](https://ui.shadcn.com) registry of components, the systems they share, and blocks that compose them. Every transition exists to make a state change legible: springs instead of durations, one hover highlight that glides to the item nearest your cursor, and labels that get heavier without shifting the layout. Components that touch a primitive ship in two flavors, [Radix](https://www.radix-ui.com) and [Base UI](https://base-ui.com), with the same API on both.

[Docs and demos](https://www.fluidfunctionalism.com) · [Browse components](https://www.fluidfunctionalism.com/docs) · [Compare with shadcn/ui](https://www.fluidfunctionalism.com/compare)

## Install

Add the registry to your project once:

```bash
npx shadcn@latest registry add @fluid
```

Then install any component, system, or block by its registry name:

```bash
npx shadcn@latest add @fluid/button
```

Or install straight from the URL, without adding the registry:

```bash
npx shadcn@latest add https://www.fluidfunctionalism.com/r/button.json
```

Dependencies, shared libs, and hooks resolve on their own.

### Pick a flavor

Every component that touches a primitive has both a Radix and a Base UI flavor. The bare name installs Radix. Put `base/` in front of it for Base UI:

```bash
npx shadcn@latest add @fluid/base/button
```

The URL form is `https://www.fluidfunctionalism.com/r/base/button.json`. Dependencies follow the flavor you pick, so a Base UI dialog pulls in the Base UI button. Components built on top of a flavored one (AskUserQuestions, ColorPicker, CommandMenu, InputCopy, InputMessage, and the blocks) accept `base/` too. Everything else has one source and installs from the bare name in either kind of project. The tables below list the names.

### Overwrite the stock files

A stock shadcn project already has `button.tsx`, `dialog.tsx`, `tooltip.tsx`, and friends. This library installs under the same names, so pass `--overwrite` to replace them:

```bash
npx shadcn@latest add @fluid/dialog --overwrite
```

Without the flag the CLI asks per existing file, and a non-interactive shell (a coding agent, a CI job) exits at the first question.

### Load Inter with its optical size axis

Font weight animations use Inter's `wght` and `opsz` axes together: the heavier weight widens a label, a tighter optical size pulls it back, so text changes weight without moving its neighbours. Load the variable font with both axes.

Self-hosted, the way the docs site does it:

```css
@font-face {
  font-family: "Inter";
  src: url("/fonts/InterVariable.ttf") format("truetype");
  font-weight: 100 900;
  font-display: swap;
}
```

With `next/font/google`, ask for the axis: `Inter({ subsets: ["latin"], axes: ["opsz"] })`.

## With an AI coding agent

Every doc page and playground has a **Copy prompt** button. The prompt is a self-contained brief: the install command, a usage snippet, the props, and the docs URL. Paste it into your agent and it wires the component in without fetching anything.

## Components

Install with `npx shadcn@latest add @fluid/<name>`. A second name means the component has a Base UI flavor.

| Component | Registry name | What it does |
|---|---|---|
| [Accordion](https://www.fluidfunctionalism.com/docs/accordion) | `accordion` · `base/accordion` | Collapsible sections with animated expand/collapse and fluid hover in grouped mode |
| [AskUserQuestions](https://www.fluidfunctionalism.com/docs/ask-user-questions) | `ask-user-questions` · `base/ask-user-questions` | Stepped question flow with single/multi-select, an inline "other" input, skip, and multi-question navigation |
| [Badge](https://www.fluidfunctionalism.com/docs/badge) | `badge` | Compact label with solid and dot variants, the Tailwind color palette, and 2 sizes |
| [Button](https://www.fluidfunctionalism.com/docs/button) | `button` · `base/button` | Variants, sizes, loading state, icon slots, and a weight shift on hover |
| [Card](https://www.fluidfunctionalism.com/docs/card) | `card` | shadcn's compositional card with stacked, inline, and grid layouts, borderless dividers, media/logo/feature slots, and 2-D fluid hover |
| [ChatMessage](https://www.fluidfunctionalism.com/docs/chat-message) | `chat-message` | Chat transcript bubble with baked-in motion, user/assistant alignment, and file attachments |
| [CheckboxGroup](https://www.fluidfunctionalism.com/docs/checkbox-group) | `checkbox-group` · `base/checkbox-group` | Checkbox group with merged backgrounds for contiguous selections |
| [ColorPicker](https://www.fluidfunctionalism.com/docs/color-picker) | `color-picker` · `base/color-picker` | HEX, RGB, HSL, and OKLCH formats with alpha, swatches, and eyedropper, inline or in a popover |
| [Combobox](https://www.fluidfunctionalism.com/docs/combobox) | `combobox` · `base/combobox` | Type-to-filter field with keyboard highlight, fluid hover, chips for multiple selection, and a create-from-query row |
| [CommandMenu](https://www.fluidfunctionalism.com/docs/command-menu) | `command-menu` · `base/command-menu` | Type to filter a list of actions, arrow through them, press Enter: groups, shortcut caps, suggestions, and a dialog shell on ⌘K |
| [Dialog](https://www.fluidfunctionalism.com/docs/dialog) | `dialog` · `base/dialog` | Modal with spring enter/exit and overlay in 3 widths, the largest a canvas for a sidebar |
| [Dropdown](https://www.fluidfunctionalism.com/docs/dropdown) | `dropdown` · `base/dropdown` | Menu-style dropdown with fluid hover, animated selection, and an optional search field in the popup |
| [InputCopy](https://www.fluidfunctionalism.com/docs/input-copy) | `input-copy` · `base/input-copy` | Read-only input with copy-to-clipboard and animated feedback |
| [InputGroup](https://www.fluidfunctionalism.com/docs/input-group) | `input-group` | Input fields with fluid hover, animated labels, and validation |
| [InputMessage](https://www.fluidfunctionalism.com/docs/input-message) | `input-message` · `base/input-message` | Chat-style composer with auto-resizing textarea, file drop, action slots, and a built-in send button |
| [RadioGroup](https://www.fluidfunctionalism.com/docs/radio-group) | `radio-group` · `base/radio-group` | Radio buttons with fluid hover and animated selection |
| [Select](https://www.fluidfunctionalism.com/docs/select) | `select` · `base/select` | Animated select with bordered/borderless variants, typeahead, and optional icons |
| [Sidebar](https://www.fluidfunctionalism.com/docs/sidebar) | `sidebar` · `base/sidebar` | Composable app sidebar: drag its edge to resize, collapse it away or press `[` or `]`, and a drawer on mobile |
| [Slider](https://www.fluidfunctionalism.com/docs/slider) | `slider` · `base/slider` | Spring-snapped thumb, step dots, range mode, and a click-to-edit value |
| [Switch](https://www.fluidfunctionalism.com/docs/switch) | `switch` · `base/switch` | Toggle with animated thumb and label |
| [Table](https://www.fluidfunctionalism.com/docs/table) | `table` | Data table with fluid hover on rows and semantic markup |
| [Tabs](https://www.fluidfunctionalism.com/docs/tabs) | `tabs` · `base/tabs` | Segmented control with sliding indicator and fluid hover |
| [TabsSubtle](https://www.fluidfunctionalism.com/docs/tabs-subtle) | `tabs-subtle` · `base/tabs-subtle` | Tab navigation with an animated pill indicator |
| [ThinkingIndicator](https://www.fluidfunctionalism.com/docs/thinking-indicator) | `thinking-indicator` | Animated status indicator with morphing SVG and cycling text |
| [ThinkingSteps](https://www.fluidfunctionalism.com/docs/thinking-steps) | `thinking-steps` · `base/thinking-steps` | Chain-of-thought display with sequential animation and collapsible steps |
| [Tooltip](https://www.fluidfunctionalism.com/docs/tooltip) | `tooltip` · `base/tooltip` | Spring-based floating tooltip with configurable placement |

## Systems

The systems every component shares. Each installs as code, the same way.

| System | Registry name | What it does |
|---|---|---|
| [Fluid Hover](https://www.fluidfunctionalism.com/docs/fluid-hover) | `use-fluid-hover` | One hook and one highlight per list. The highlight glides to the item nearest your cursor and never blinks off between rows |
| [Motion](https://www.fluidfunctionalism.com/docs/motion) | `springs` | 3 spring speeds, fast, moderate, and slow, each with an exit one tier quicker than its entrance |
| [Scrollbars](https://www.fluidfunctionalism.com/docs/scrollbars) | `scroll-area` · `base/scroll-area` | A scrollbar that stays out of the way but never disappears, with native scroll on touch |
| [Sizes](https://www.fluidfunctionalism.com/docs/sizes) | `size-context` | 2 sizes, a 36px default and a 28px compact, shared by buttons, inputs, selects, tabs, and rows |
| [Surfaces](https://www.fluidfunctionalism.com/docs/surfaces) | `elevated` | 8 elevation levels so popovers, dropdowns, and dialogs stay visible at any depth, in light and dark |

## Blocks

Compositions that install as one item each, with every component they use.

| Block | Registry name | What it is |
|---|---|---|
| [App Sidebar](https://www.fluidfunctionalism.com/docs/sidebar) | `sidebar-app` · `base/sidebar-app` | A complete app shell: workspace header, search field, collapsible sections with badges, user footer, and an inset topbar |
| [Settings Dialog](https://www.fluidfunctionalism.com/docs/dialog) | `dialog-sidebar` · `base/dialog-sidebar` | The xl Dialog as a canvas, a Sidebar of sections down its left edge, and a scrolling panel of controls |
| [Queued message stack](https://www.fluidfunctionalism.com/docs/input-message) | `queued-stack` · `base/queued-stack` | Sonner-style stack of queued composer messages: fan out on hover, drag to reorder, morph into the sent message |

## Presets

The Sidebar, Card, InputMessage, AskUserQuestions, Dropdown, Combobox, and CommandMenu playgrounds encode the configuration you build in the rail into a short code. Nothing is stored: the code is the configuration itself. Install it as one block that composes the component with the options you picked:

```bash
npx shadcn@latest add https://www.fluidfunctionalism.com/r/preset/<code>.json
```

Add `?preset=<code>` to the doc page URL to reopen the playground as you left it, or to share it.

## Icons

Components render icons through named slots with [Lucide](https://lucide.dev) defaults, so `lucide-react` is the only icon dependency an install adds. To use another icon library, wrap your app in the installed `IconProvider` and override any slot. Names you leave out keep their Lucide default:

```tsx
import { IconProvider } from "@/lib/icon-context";
import { CaretRight, MagnifyingGlass } from "@phosphor-icons/react";

<IconProvider icons={{ "chevron-right": CaretRight, "search": MagnifyingGlass }}>
  <App />
</IconProvider>
```

The icon-library switcher on the docs site is a preview tool only. None of that machinery ships with installed components.

## What makes these different

- **Motion as information.** Transitions make state changes legible. Nothing moves for decoration.
- **Hover as preview.** One highlight per list glides to the item nearest your cursor, so you see where a click lands before you click.
- **Springs, not durations.** 3 spring speeds cover the library. Interrupt an animation and it reverses from where it is instead of finishing first.
- **Weight without reflow.** Selected and hovered labels get heavier. An optical-size axis compensates the width and a ghost span reserves it, so nothing shifts.
- **Two flavors, one API.** Radix or Base UI, whichever your project already uses.
- **Drop-in compatible.** Your shadcn theme tokens (colors, radii, fonts) apply as they are.

## Tech stack

- [Next.js](https://nextjs.org) 15 + React 19 (docs site)
- [Tailwind CSS](https://tailwindcss.com) v4
- [Framer Motion](https://www.framer.com/motion/)
- [Radix UI](https://www.radix-ui.com) and [Base UI](https://base-ui.com) primitives
- [shadcn/ui](https://ui.shadcn.com) registry protocol, shadcn CLI 3

## Development

```bash
npm install
npm run dev              # docs site on http://localhost:3000
npm test                 # vitest
npm run lint
npm run registry:build   # shadcn build + scripts/postbuild-registry.mjs, writes public/r
```

Sources live in `registry/`: `radix/` and `base/` hold the two flavors, `default/` the single-source components, hooks, and libs, `blocks/` the compositions. `public/r` is the built output users install from, and it is committed. CI rebuilds it and fails when it drifts from the sources, so run `npm run registry:build` and commit the result with any registry change.

Guides in the repo: [motion-guidelines.md](motion-guidelines.md), [component-documentation-guidelines.md](component-documentation-guidelines.md), [preset-guidelines.md](preset-guidelines.md), [tone-of-voice.md](tone-of-voice.md), and [README-guidelines.md](README-guidelines.md) for what this file carries and what it defers.

## Contributing

Open an issue before a pull request, so the direction is agreed before the work starts. Contributions then follow [component-documentation-guidelines.md](component-documentation-guidelines.md) and the Development section above.

## License

[MIT](LICENSE) © Micka Touillaud
author/lib/utils.ts
파일 저장

export { cn } from "@/registry/default/lib/utils";
author/lib/springs.ts
파일 저장

export { spring, exitFallbackMs } from "@/registry/default/lib/springs";
author/lib/font-weight.ts
파일 저장

export { fontWeights } from "@/registry/default/lib/font-weight";
author/hooks/use-fluid-hover.ts
파일 저장

export {
  useFluidHover,
  useRegisterFluidHoverItem,
  ACTIVE_ATTR,
  ACTIVE_INDEX_ATTR,
  pickNearest,
} from "@/registry/default/hooks/use-fluid-hover";
export type { ItemRect, PickNearestInput, UseFluidHoverReturn, UseFluidHoverOptions } from "@/registry/default/hooks/use-fluid-hover";
author/hooks/use-merge-split.ts
파일 저장

export {
  useMergeSplitBlocks,
  useSelectionRuns,
  SelectionBackgrounds,
} from "@/registry/default/hooks/use-merge-split";
export type { Run, SelBlock } from "@/registry/default/hooks/use-merge-split";
author/lib/shape-context.tsx
파일 저장

export {
  ShapeProvider,
  useShape,
  useShapeContext,
  shapeMap,
} from "@/registry/default/lib/shape-context";
export type { ShapeVariant, ShapeClasses } from "@/registry/default/lib/shape-context";
author/lib/size-context.tsx
파일 저장

export {
  SizeProvider,
  useSize,
  useSizeVariant,
  useSizeContext,
  useTypeScale,
  sizeMap,
  typeScale,
} from "@/registry/default/lib/size-context";
export type {
  SizeVariant,
  SizeClasses,
  TypeScaleRole,
  TypeScaleStep,
} from "@/registry/default/lib/size-context";
author/components/ui/fluid-hover-highlight.tsx
파일 저장

export {
  FluidHoverHighlight,
  resolveHighlightTransition,
} from "@/registry/default/fluid-hover-highlight";
export type {
  FluidHoverHighlightProps,
  FluidHoverSource,
} from "@/registry/default/fluid-hover-highlight";
author/registry/default/lib/utils.ts
파일 저장

import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";

// The type-scale role utilities (see /docs/sizes) are font sizes, but
// tailwind-merge can't know that for custom classes — by default anything
// text-<word> it doesn't recognize is treated as a text *color*, so
// cn("text-body", "text-muted-foreground") would silently drop the size.
const twMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      "font-size": [
        "text-display",
        "text-title",
        "text-subtitle",
        "text-body",
        "text-caption",
      ],
    },
  },
});

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
author/registry/default/lib/springs.ts
파일 저장

// Motion tokens. Each tier's value is the ENTER transition — a critically
// damped spring, except the largest tier which keeps a little bounce. Its
// `.exit` is the matching EXIT transition — a plain tween, no bounce, one tier
// quicker — so a dismissal reads as crisp and final rather than replaying the
// entrance in reverse.
//
//   transition={spring.fast}                              // enter
//   exit={{ opacity: 0, transition: spring.fast.exit }}   // leave
//
// The bigger the thing that moves, the slower the spring. Never hand-write a
// duration — always reach for a tier.
export const spring = {
  fast: {
    type: "spring" as const,
    duration: 0.08,
    bounce: 0,
    exit: { duration: 0.06 },
  },
  // Critically damped: same perceived speed as a bouncier tier, but lands
  // exactly with no overshoot — for short travel and panels/sheets that must
  // settle precisely (dropdowns, tabs, drawers, merged selection backgrounds).
  moderate: {
    type: "spring" as const,
    duration: 0.16,
    bounce: 0,
    exit: { duration: 0.12 },
  },
  slow: {
    type: "spring" as const,
    duration: 0.24,
    bounce: 0.12,
    exit: { duration: 0.16 },
  },
} as const;

// Fallback delay (ms) for deferred-unmount timers that guard an exit tween:
// popups keep their portal mounted until onAnimationComplete fires, but a
// throttled/background tab can stall the animation, so a timer force-unmounts
// after the tier's exit duration plus a safety buffer. Deriving it here keeps
// the timers in step with the tokens above.
export const exitFallbackMs = (tier: { exit: { duration: number } }) =>
  Math.round(tier.exit.duration * 1000) + 100;
author/registry/default/lib/font-weight.ts
파일 저장

// Inter (variable) weight tokens for `fontVariationSettings`.
//
// Each weight is paired with an optical-size (`opsz`) value so that animating
// between weights keeps the text's advance width nearly constant: a heavier
// `wght` widens the text, and a tighter (higher) `opsz` pulls it back.
//
// The compensation is per-glyph, so any residual multiplies with label
// length — calibrate against a CORPUS of realistic strings (4–40 chars,
// mixed/caps/lowercase-heavy at 13px), minimizing the worst-case |delta|
// centered on zero, never against a single baseline string. Measured against
// the 400/opsz-14 baseline: medium and semibold hold every corpus string
// within ±0.4px, bold within ±0.7px. Semibold was re-measured 2026-08-24
// down from opsz 20, which over-corrected and visibly SHRANK long labels
// (−1.6px at 38 chars) on selection.
//
// Setting `opsz` explicitly here overrides `font-optical-sizing: auto`, which
// is intended — we want weight, not font-size, to drive optical size.
export const fontWeights = {
  normal: "'wght' 400, 'opsz' 14",
  medium: "'wght' 450, 'opsz' 15",
  semibold: "'wght' 550, 'opsz' 18",
  bold: "'wght' 700, 'opsz' 25",
} as const;
author/registry/default/hooks/use-fluid-hover.ts
파일 저장

"use client";

import {
  useRef,
  useState,
  useCallback,
  useEffect,
  type Dispatch,
  type RefObject,
  type SetStateAction,
} from "react";

export interface ItemRect {
  top: number;
  height: number;
  left: number;
  width: number;
}

export interface UseFluidHoverOptions {
  /**
   * Which direction to resolve the nearest item along.
   *   "y"  — vertical lists (default): closest by top/height
   *   "x"  — horizontal strips: closest by left/width
   *   "xy" — 2-D grids: closest card across both rows AND columns,
   *          measured by Euclidean distance to each item's center
   */
  axis?: "x" | "y" | "xy";
  /**
   * Makes an item invisible to hit-testing without unregistering it — for
   * rows that stay mounted while clipped away (a collapsed sub-tree).
   * Unregistering would invalidate every measurement; a skipped item keeps
   * the set stable. Consulted per mouse move, so keep it cheap.
   */
  isItemDisabled?: (element: HTMLElement) => boolean;
  /**
   * Whether a click that lands between items (a gap, the padding, past the
   * last row) is routed to the highlighted item, so what is lit is what a
   * click hits. On by default: in a menu or a list the highlight is a
   * promise about the click. Pass `false` where empty space should stay
   * inert (rows with destructive actions, generous whitespace), or
   * `{ maxDistance }` to route only clicks within that many pixels of the
   * highlighted item's edge.
   */
  gapClick?: boolean | { maxDistance?: number };
}

export interface UseFluidHoverReturn {
  activeIndex: number | null;
  setActiveIndex: Dispatch<SetStateAction<number | null>>;
  itemRects: ItemRect[];
  /**
   * True once every registered item has been measured and no remeasure is
   * pending, i.e. `itemRects` describes the current item set. Gate absolutely
   * positioned overlays on it: an overlay that mounts against a rect a later
   * pass still corrects animates from the wrong place to the right one, which
   * reads as the highlight sliding in from another row.
   */
  isMeasured: boolean;
  sessionRef: RefObject<number>;
  handlers: {
    onMouseMove: (e: React.MouseEvent) => void;
    onMouseEnter: () => void;
    onMouseLeave: () => void;
    /**
     * Routes a click that lands between items (a gap, the padding, past the
     * last row) to the highlighted item, so the highlight and the click agree:
     * what is lit is what a click hits. A click inside an item is left to the
     * item. Disabled items (`isItemDisabled`) are never activated.
     */
    onClick: (e: React.MouseEvent) => void;
  };
  registerItem: (index: number, element: HTMLElement | null) => void;
  /**
   * Invalidates the published rects and runs the hook's coalesced measurement
   * pass again, holding `isMeasured` false until it settles. Reach for it when
   * the rects may be wrong and showing an overlay against them would misplace
   * it: a popup that stays mounted between opens keeps its items registered,
   * so nothing else would notice that its rects were taken while it was
   * hidden. Registration, item resize, and container resize already trigger
   * a pass; do not call this on `children` changes.
   */
  remeasure: () => void;
  /**
   * Re-reads the rects synchronously, keeping `isMeasured` as it is. Only for
   * layout that moves the rows under a visible overlay frame by frame (the
   * accordion re-measures inside its height animation). Everything else
   * wants `remeasure`, or nothing.
   */
  measureItems: () => void;
}

export interface PickNearestInput {
  axis: "x" | "y" | "xy";
  /** The pointer, in viewport coordinates. */
  point: { x: number; y: number };
  /** Item rects in the container's layout space (sparse: unregistered slots
   *  are undefined). */
  rects: readonly (ItemRect | undefined)[];
  /** The container's bounding rect and live scroll / border offsets, which
   *  map layout rects into the pointer's viewport space. */
  containerRect: { left: number; top: number; width: number; height: number };
  scroll: { x: number; y: number };
  border: { x: number; y: number };
  /** Layout size of the container, so a cumulative ancestor `transform:
   *  scale` (a popup mid scale-in) can be factored out per axis. */
  layoutSize: { width: number; height: number };
  /** Skips an item without unregistering it. */
  isDisabled?: (index: number) => boolean;
}

/**
 * The rule, as one pure function: an item the pointer is inside wins;
 * otherwise the item whose center is nearest does, so a pointer in a gap, in
 * the padding, or past the last row still lands. `y` and `x` measure one
 * coordinate; `xy` measures the straight line to each center. Ties keep the
 * first item. The hook calls this once per animation frame; the docs page
 * times it.
 */
export function pickNearest({
  axis,
  point,
  rects,
  containerRect,
  scroll,
  border,
  layoutSize,
  isDisabled,
}: PickNearestInput): number | null {
  const scaleX = layoutSize.width > 0 ? containerRect.width / layoutSize.width : 1;
  const scaleY = layoutSize.height > 0 ? containerRect.height / layoutSize.height : 1;
  let closestIndex: number | null = null;
  let closestDistance = Infinity;
  let containingIndex: number | null = null;

  for (let index = 0; index < rects.length; index++) {
    const r = rects[index];
    if (!r) continue;
    if (isDisabled?.(index)) continue;

    if (axis === "xy") {
      const left = containerRect.left + (border.x + r.left - scroll.x) * scaleX;
      const top = containerRect.top + (border.y + r.top - scroll.y) * scaleY;
      const width = r.width * scaleX;
      const height = r.height * scaleY;
      if (
        point.x >= left &&
        point.x <= left + width &&
        point.y >= top &&
        point.y <= top + height
      ) {
        containingIndex = index;
      }
      const distance = Math.hypot(point.x - (left + width / 2), point.y - (top + height / 2));
      if (distance < closestDistance) {
        closestDistance = distance;
        closestIndex = index;
      }
      continue;
    }

    const horizontal = axis === "x";
    const mousePos = horizontal ? point.x : point.y;
    const scale = horizontal ? scaleX : scaleY;
    const itemStart =
      (horizontal ? containerRect.left : containerRect.top) +
      ((horizontal ? border.x : border.y) +
        (horizontal ? r.left : r.top) -
        (horizontal ? scroll.x : scroll.y)) *
        scale;
    const itemSize = (horizontal ? r.width : r.height) * scale;
    if (mousePos >= itemStart && mousePos <= itemStart + itemSize) {
      containingIndex = index;
    }
    const distance = Math.abs(mousePos - (itemStart + itemSize / 2));
    if (distance < closestDistance) {
      closestDistance = distance;
      closestIndex = index;
    }
  }

  return containingIndex ?? closestIndex;
}

/** Set on the highlighted item (boolean attribute). */
export const ACTIVE_ATTR = "data-fluid-hover-active";
/** Set on the container: the highlighted index, or absent. */
export const ACTIVE_INDEX_ATTR = "data-fluid-hover-active-index";

const ACTIVATOR_SELECTOR =
  "a[href], button, [role='menuitem'], [role='menuitemradio'], [role='menuitemcheckbox'], [role='option'], [role='radio'], [role='checkbox'], [role='tab'], [role='link'], [role='button']";

/**
 * The element a routed click should land on. A registered item is usually
 * the interactive row itself; when it is only a box around one (a sidebar
 * row around its button, a card around its link), the first control inside
 * is what a real click on the row would have reached.
 */
function resolveActivator(element: HTMLElement): HTMLElement {
  if (element.matches(ACTIVATOR_SELECTOR) || element.hasAttribute("tabindex")) {
    return element;
  }
  return element.querySelector<HTMLElement>(ACTIVATOR_SELECTOR) ?? element;
}

/**
 * How many frames the coalesced remeasure retries while the registered items
 * still have no layout box. A popup can be in the DOM one frame before it is
 * laid out; retrying beats publishing zeroed rects, and the cap keeps a list
 * that stays hidden for good from spinning frames forever.
 */
const measurementAttempts = 3;

export function useFluidHover<T extends HTMLElement>(
  containerRef: RefObject<T | null>,
  options: UseFluidHoverOptions = {}
): UseFluidHoverReturn {
  const { axis = "y", isItemDisabled, gapClick = true } = options;
  const gapClickMaxDistance =
    typeof gapClick === "object" ? (gapClick.maxDistance ?? Infinity) : Infinity;
  const itemsRef = useRef(new Map<number, HTMLElement>());
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  // Mirrored for handlers that read it outside a render (the gap click).
  const activeIndexRef = useRef<number | null>(null);
  activeIndexRef.current = activeIndex;

  // The state, in the DOM: `data-fluid-hover-active` on the highlighted item
  // and `data-fluid-hover-active-index` on the container. Devtools shows it
  // and a test asserts on it without waiting for a frame. React does not
  // manage these attributes, so it never clobbers them.
  useEffect(() => {
    const container = containerRef.current;
    if (activeIndex === null) container?.removeAttribute(ACTIVE_INDEX_ATTR);
    else container?.setAttribute(ACTIVE_INDEX_ATTR, String(activeIndex));
    const active = activeIndex === null ? undefined : itemsRef.current.get(activeIndex);
    active?.setAttribute(ACTIVE_ATTR, "");
    return () => {
      active?.removeAttribute(ACTIVE_ATTR);
      // A row that re-registered under this index while it was highlighted
      // (a remount under a new key) was marked by registerItem, not by this
      // effect: drop the mark from whatever element holds the index now.
      if (activeIndex !== null) itemsRef.current.get(activeIndex)?.removeAttribute(ACTIVE_ATTR);
    };
  }, [activeIndex, containerRef]);
  const [itemRects, setItemRects] = useState<ItemRect[]>([]);
  const [isMeasured, setIsMeasured] = useState(false);
  const itemRectsRef = useRef<ItemRect[]>([]);
  const sessionRef = useRef(0);
  const rafIdRef = useRef<number | null>(null);
  const remeasureRafIdRef = useRef<number | null>(null);

  /**
   * Publishes a rect for every registered item. Returns false when the
   * measurement could not be completed (no container, or an item without a
   * layout box) — nothing is published in that case, so the last complete
   * measurement stands instead of being overwritten with zeroes.
   */
  const runMeasurement = useCallback(() => {
    const container = containerRef.current;
    if (!container) return false;
    const rects: ItemRect[] = [];
    let everyItemHasLayout = true;
    itemsRef.current.forEach((element, index) => {
      // An element inside a display:none / not-yet-laid-out popup has no
      // offsetParent and reports every offset as 0. Publishing that would pin
      // overlays to the top of the list, so treat the whole pass as
      // incomplete. A boxless element is the only case: `position: fixed`
      // items also have no offsetParent but do have a size.
      const hasLayoutBox =
        element.offsetParent !== null ||
        element.offsetWidth > 0 ||
        element.offsetHeight > 0;
      if (!hasLayoutBox) {
        everyItemHasLayout = false;
        return;
      }
      // Use offset* instead of getBoundingClientRect so measurements are
      // unaffected by CSS transforms (e.g. scaleY animation on the parent
      // motion.div). offsetTop/offsetLeft are layout values relative to the
      // offsetParent (the scroll container), matching the coordinate space
      // used by `position: absolute` children. Items nested inside positioned
      // descendants of the container (a sidebar sub-menu's rows live inside a
      // positioned row) accumulate those ancestors' offsets, so every rect
      // lands in the container's own coordinate space; for a flat list the
      // loop never runs and this is exactly the plain offsetTop/offsetLeft.
      let top = element.offsetTop;
      let left = element.offsetLeft;
      let ancestor = element.offsetParent as HTMLElement | null;
      while (ancestor && ancestor !== container && container.contains(ancestor)) {
        top += ancestor.offsetTop + ancestor.clientTop;
        left += ancestor.offsetLeft + ancestor.clientLeft;
        ancestor = ancestor.offsetParent as HTMLElement | null;
      }
      rects[index] = {
        top,
        height: element.offsetHeight,
        left,
        width: element.offsetWidth,
      };
    });
    if (!everyItemHasLayout) return false;
    // Skip the state update when nothing moved (a cheap top/left/width/height
    // compare) so redundant remeasures don't churn re-renders.
    const prev = itemRectsRef.current;
    let changed = prev.length !== rects.length;
    for (let i = 0; !changed && i < rects.length; i++) {
      const p = prev[i];
      const r = rects[i];
      if (p === r) continue; // both undefined (sparse slot)
      changed =
        !p ||
        !r ||
        p.top !== r.top ||
        p.left !== r.left ||
        p.width !== r.width ||
        p.height !== r.height;
    }
    if (changed) {
      itemRectsRef.current = rects;
      setItemRects(rects);
    }
    return true;
  }, [containerRef]);

  const measureItems = useCallback(() => {
    runMeasurement();
  }, [runMeasurement]);

  /**
   * The hook's single measurement pass: coalesces every trigger (item
   * registration, container resize) into one remeasure on the next frame and
   * is the only place readiness is reported, so `isMeasured` can never turn
   * true while another pass is still queued.
   */
  const scheduleMeasurement = useCallback(
    (attemptsLeft: number) => {
      if (remeasureRafIdRef.current !== null) {
        cancelAnimationFrame(remeasureRafIdRef.current);
      }
      remeasureRafIdRef.current = requestAnimationFrame(() => {
        remeasureRafIdRef.current = null;
        if (runMeasurement()) {
          setIsMeasured(true);
        } else if (attemptsLeft > 1) {
          scheduleMeasurement(attemptsLeft - 1);
        }
      });
    },
    [runMeasurement]
  );

  const remeasure = useCallback(() => {
    // Readiness drops first: until the pass below settles, the published rects
    // may not describe what is on screen, and an overlay positioned from them
    // would be corrected after mounting — which animates as a slide.
    setIsMeasured(false);
    scheduleMeasurement(measurementAttempts);
  }, [scheduleMeasurement]);

  // Observes the registered items themselves (not just the container): rows
  // that change size in place — e.g. the site-wide size step flipping while a
  // selection background is up — must invalidate the published rects even when
  // the container the effect below captured has since been remounted and the
  // ref points at a different element than the one being observed.
  const itemRoRef = useRef<ResizeObserver | null>(null);
  const getItemRo = useCallback(() => {
    if (itemRoRef.current === null && typeof ResizeObserver !== "undefined") {
      itemRoRef.current = new ResizeObserver(() =>
        scheduleMeasurement(measurementAttempts)
      );
    }
    return itemRoRef.current;
  }, [scheduleMeasurement]);

  const registerItem = useCallback(
    (index: number, element: HTMLElement | null) => {
      if (element) {
        itemsRef.current.set(index, element);
        getItemRo()?.observe(element);
        if (index === activeIndexRef.current) element.setAttribute(ACTIVE_ATTR, "");
      } else {
        const previous = itemsRef.current.get(index);
        if (previous) itemRoRef.current?.unobserve(previous);
        // The mark leaves with the element: a row that only moved to another
        // index (a filtering list re-ordering) must not carry it there.
        previous?.removeAttribute(ACTIVE_ATTR);
        itemsRef.current.delete(index);
        // The highlighted row is gone: nothing should stay lit or receive a
        // routed click until the pointer picks again. Decided when the
        // update applies, after this commit's registrations, so a row that
        // only moved index hands the highlight to the row now under it.
        if (index === activeIndexRef.current) {
          setActiveIndex((current) =>
            current === index && !itemsRef.current.has(index) ? null : current
          );
        }
      }
      // Coalesce rapid register/unregister calls (e.g. when an AnimatePresence
      // remounts a list of rows) into a single remeasure on the next frame,
      // so consumers don't have to manually call measureItems after the
      // container's children swap.
      remeasure();
    },
    [remeasure, getItemRo]
  );

  const handleMouseMove = useCallback(
    (e: React.MouseEvent) => {
      const mouseX = e.clientX;
      const mouseY = e.clientY;

      if (rafIdRef.current !== null) {
        cancelAnimationFrame(rafIdRef.current);
      }

      rafIdRef.current = requestAnimationFrame(() => {
        rafIdRef.current = null;
        const container = containerRef.current;
        if (!container) return;
        setActiveIndex(
          pickNearest({
            axis,
            point: { x: mouseX, y: mouseY },
            rects: itemRectsRef.current,
            containerRect: container.getBoundingClientRect(),
            scroll: { x: container.scrollLeft, y: container.scrollTop },
            border: { x: container.clientLeft, y: container.clientTop },
            layoutSize: { width: container.offsetWidth, height: container.offsetHeight },
            isDisabled: isItemDisabled
              ? (index) => {
                  const el = itemsRef.current.get(index);
                  return !!el && isItemDisabled(el);
                }
              : undefined,
          })
        );
      });
    },
    [axis, containerRef, isItemDisabled]
  );

  const handleMouseEnter = useCallback(() => {
    sessionRef.current += 1;
  }, []);

  const handleMouseLeave = useCallback(() => {
    if (rafIdRef.current !== null) {
      cancelAnimationFrame(rafIdRef.current);
      rafIdRef.current = null;
    }
    setActiveIndex(null);
  }, []);

  const handleClick = useCallback(
    (e: React.MouseEvent) => {
      const target = e.target as Node | null;
      if (!target) return;
      // Inside an item: the item owns the click.
      for (const element of itemsRef.current.values()) {
        if (element.contains(target)) return;
      }
      // A row that unmounted while its own click was still bubbling (a pick
      // whose primitive re-renders the list synchronously, like a "create"
      // row that becomes a real item) already landed; it is not a gap.
      if (!target.isConnected) return;
      // A control that sits between the rows (a search field at the top of
      // a menu, a footer button) keeps its own click too.
      const control = (target as Element).closest?.(
        "input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']"
      );
      if (control) return;
      if (gapClick === false) return;
      const index = activeIndexRef.current;
      if (index === null) return;
      const element = itemsRef.current.get(index);
      if (!element || isItemDisabled?.(element)) return;
      if (gapClickMaxDistance !== Infinity) {
        const r = element.getBoundingClientRect();
        const dx = Math.max(r.left - e.clientX, 0, e.clientX - r.right);
        const dy = Math.max(r.top - e.clientY, 0, e.clientY - r.bottom);
        if (Math.hypot(dx, dy) > gapClickMaxDistance) return;
      }
      // A real DOM click on the item, so its own handlers (and the primitive
      // wrapping it, if any) run exactly as if the pointer had been inside.
      resolveActivator(element).click();
    },
    [isItemDisabled, gapClick, gapClickMaxDistance]
  );

  // Remeasure when the container resizes — a reflow moves items even though
  // the registered set is unchanged, which would otherwise leave itemRects
  // stale. Coalesced through the same rAF as register/unregister. Readiness is
  // deliberately not dropped: the item set is unchanged, so the published rects
  // stay usable, and hiding overlays on every reflow would flicker them.
  useEffect(() => {
    const container = containerRef.current;
    if (!container || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => scheduleMeasurement(measurementAttempts));
    ro.observe(container);
    return () => ro.disconnect();
  }, [containerRef, scheduleMeasurement]);

  // Clean up rAF and the item observer on unmount
  useEffect(() => {
    return () => {
      if (rafIdRef.current !== null) {
        cancelAnimationFrame(rafIdRef.current);
      }
      if (remeasureRafIdRef.current !== null) {
        cancelAnimationFrame(remeasureRafIdRef.current);
      }
      itemRoRef.current?.disconnect();
      itemRoRef.current = null;
    };
  }, []);

  return {
    activeIndex,
    setActiveIndex,
    itemRects,
    isMeasured,
    sessionRef,
    handlers: {
      onMouseMove: handleMouseMove,
      onMouseEnter: handleMouseEnter,
      onMouseLeave: handleMouseLeave,
      onClick: handleClick,
    },
    registerItem,
    remeasure,
    measureItems,
  };
}

/**
 * Registers an item's element with its list for as long as it is mounted.
 * The one way rows join a list: pass the hook's `registerItem` (or the copy
 * a context hands down), the row's index, and its ref. Either may be
 * missing for a row rendered outside a list (a standalone card, an accordion
 * item that is not grouped); then nothing is registered.
 */
export function useRegisterFluidHoverItem(
  registerItem: ((index: number, element: HTMLElement | null) => void) | undefined,
  index: number | undefined,
  ref: RefObject<HTMLElement | null>
) {
  useEffect(() => {
    if (!registerItem || index === undefined) return;
    registerItem(index, ref.current);
    return () => registerItem(index, null);
  }, [index, registerItem, ref]);
}
author/registry/default/hooks/use-merge-split.tsx
파일 저장

"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { spring } from "@/lib/springs";
import type { ItemRect } from "@/hooks/use-fluid-hover";

// Run the layout effect on the client (where it must fire before paint, so a
// merge/split shows on the first frame) and a no-op-safe useEffect on the server.
const useIsoLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

// Edge spring for the selected-bg merge/split: spring.moderate (critically
// damped) so converging edges meet exactly instead of overshooting. On a merge
// the inner corners trail by `cornerDelay`, staying rounded until the halves meet.
const mergeSpring = spring.moderate;
const cornerDelay = 0.07;
// A boundary resolves after its motion finishes (merge → swap to one block;
// split → drop), driven by a duration timer rather than onAnimationComplete —
// framer skips that callback when an animation's target equals its current value
// (which spam-toggling produces), which would otherwise strand a half. The
// buffer biases late, by which point the halves have met/parted, so it's unseen.
const convergeMs = (mergeSpring.duration + cornerDelay) * 1000 + 80;
const splitMs = mergeSpring.duration * 1000 + 80;

// A selected-background block for one render. A run is normally one block; mid
// merge/split it is drawn as two abutting halves with sharp inner corners.
type Rect = { top: number; left: number; width: number; height: number };
export interface SelBlock extends Rect {
  key: string;
  radii: [number, number, number, number]; // tl, tr, br, bl
  instant: boolean; // skip the spring (the zero-shift swap, the split snap-in)
  exitInstant: boolean; // drop without the fade (absorbed half at the swap)
  delayCorners: boolean; // trail the corner straightening (merge converge)
  cornerDelay?: number; // optional per-block delay override
  opacity?: number; // override the hover-derived opacity (commit ghost = 0)
  // State a fresh block animates *from* on mount, so it springs into place
  // instead of snapping when continuity is lost (fast toggling) or the block is
  // inherently new (a split's lower half). Continuous blocks ignore it.
  enterFrom?: { top: number; height: number; radii: [number, number, number, number] };
}

// A contiguous run of selected/checked rows, with a stable id so framer-motion
// can morph it across renders rather than exit+re-enter.
export type Run = { start: number; end: number; id: number };

/**
 * Groups checked row indices into contiguous runs with ids that survive
 * re-renders: a run keeps its id while any of its rows was in a run last
 * render, so framer morphs a growing/shrinking block instead of swapping it.
 * Feed the result to useMergeSplitBlocks.
 */
export function useSelectionRuns(checkedIndices: readonly number[]): Run[] {
  const prevGroupMap = useRef(new Map<number, number>());
  const groupIdCounter = useRef(0);

  const runs: { start: number; end: number }[] = [];
  const sorted = [...checkedIndices].sort((a, b) => a - b);
  for (const idx of sorted) {
    const last = runs[runs.length - 1];
    if (last && idx === last.end + 1) last.end = idx;
    else runs.push({ start: idx, end: idx });
  }

  const usedIds = new Set<number>();
  const nextGroupMap = new Map<number, number>();
  const result = runs.map((run) => {
    let stableId: number | null = null;
    for (let i = run.start; i <= run.end; i++) {
      const prevId = prevGroupMap.current.get(i);
      if (prevId !== undefined && !usedIds.has(prevId)) {
        stableId = prevId;
        break;
      }
    }
    const id = stableId ?? ++groupIdCounter.current;
    usedIds.add(id);
    for (let i = run.start; i <= run.end; i++) nextGroupMap.set(i, id);
    return { ...run, id };
  });
  prevGroupMap.current = nextGroupMap;
  return result;
}

// One in-flight merge or split; geometry is recomputed from the live runs each
// render so rapid toggles redirect instead of freezing.
interface Boundary {
  tid: number;
  kind: "merge" | "split";
  survivorId: number; // persisting run (merged run / split's upper run)
  otherId: number; // merge: absorbed run; split: new lower run
  gapIndex: number; // bridging/deselected row — where the halves meet
  phase: "converge" | "commit" | "splitIn" | "diverge";
}

// Two runs within `outer`, ordered, separated by exactly one row (a single-row
// bridge — the only shape a click can merge or split).
function bridgePair(outer: Run, runs: Run[]) {
  const inside = runs
    .filter((r) => r.start >= outer.start && r.end <= outer.end)
    .sort((a, b) => a.start - b.start);
  if (inside.length !== 2) return null;
  const [up, lo] = inside;
  return lo.start === up.end + 2 ? { up, lo, gap: up.end + 1 } : null;
}

// ── Merge / split boundary animation ─────────────────────────────
// When one unselected row bridges two selected runs, their inner edges glide to
// the bridging row's midpoint (facing corners straightening to sharp), then swap
// to one block with no visible motion — instead of the surviving block
// spring-growing over the whole union. Deselecting a middle row plays the
// inverse: snap into two abutting halves, then glide apart.
//
// Given the contiguous selection `runs` (with stable ids), the measured
// `itemRects`, and the corner radius `R` to round to, this returns the list of
// background blocks to paint — one per run, or two abutting halves for any run
// currently mid merge/split. Render them with <SelectionBackgrounds>.
export function useMergeSplitBlocks(
  runs: Run[],
  itemRects: ItemRect[],
  R: number
): SelBlock[] {
  const [boundaries, setBoundaries] = useState<Boundary[]>([]);
  const prevRunsRef = useRef<Run[]>([]);
  const tidRef = useRef(0);
  const timersRef = useRef(new Map<number, ReturnType<typeof setTimeout>>());
  const runsSig = runs.map((g) => `${g.id}:${g.start}-${g.end}`).join("|");

  // Detect merges/splits before paint (so the first frame already shows the
  // halves) and drop any boundary the latest selection invalidated (e.g. the
  // bridge row was toggled again mid-flight).
  useIsoLayoutEffect(() => {
    const prev = prevRunsRef.current;
    const cur = runs;
    const found: Boundary[] = [];
    for (const c of cur) {
      const p = bridgePair(c, prev); // two prev runs collapsed into one
      if (p && (c.id === p.up.id || c.id === p.lo.id))
        found.push({
          tid: ++tidRef.current,
          kind: "merge",
          survivorId: c.id,
          otherId: c.id === p.up.id ? p.lo.id : p.up.id,
          gapIndex: p.gap,
          phase: "converge",
        });
    }
    for (const p of prev) {
      const c = bridgePair(p, cur); // one prev run split into two
      if (c)
        found.push({
          tid: ++tidRef.current,
          kind: "split",
          survivorId: c.up.id,
          otherId: c.lo.id,
          gapIndex: c.gap,
          phase: "splitIn",
        });
    }
    prevRunsRef.current = cur.map((r) => ({ ...r }));
    // Resolve each new boundary after its motion window (merge → swap to one
    // block; split → drop), so an interrupted animation can't strand a half.
    for (const b of found) {
      timersRef.current.set(
        b.tid,
        setTimeout(() => {
          timersRef.current.delete(b.tid);
          setBoundaries((bs) =>
            bs.some((x) => x.tid === b.tid)
              ? bs.flatMap((x) =>
                  x.tid !== b.tid
                    ? [x]
                    : x.kind === "merge"
                    ? [{ ...x, phase: "commit" as const }]
                    : []
                )
              : bs
          );
        }, b.kind === "merge" ? convergeMs : splitMs)
      );
    }
    const stillValid = (b: Boundary) =>
      b.kind === "merge"
        ? cur.some(
            (c) =>
              c.id === b.survivorId &&
              b.gapIndex > c.start &&
              b.gapIndex < c.end
          )
        : cur.some((c) => c.id === b.survivorId && c.end === b.gapIndex - 1) &&
          cur.some((c) => c.id === b.otherId && c.start === b.gapIndex + 1);
    setBoundaries((active) => {
      // Cancel the resolve timer of any boundary the latest selection
      // invalidated — otherwise it sits in timersRef until firing as a no-op.
      // Clearing is idempotent, so a double-invoked updater is harmless.
      for (const b of active) {
        if (stillValid(b)) continue;
        const timer = timersRef.current.get(b.tid);
        if (timer !== undefined) {
          clearTimeout(timer);
          timersRef.current.delete(b.tid);
        }
      }
      return [...active.filter(stillValid), ...found];
    });
  }, [runsSig]);

  // Clear any pending timers on unmount.
  useEffect(() => {
    const timers = timersRef.current;
    return () => timers.forEach(clearTimeout);
  }, []);

  // Follow-up render: a fresh split holds its abutting frame once then
  // diverges; a committed merge is dropped.
  useEffect(() => {
    if (!boundaries.some((b) => b.phase === "splitIn" || b.phase === "commit"))
      return;
    setBoundaries((bs) =>
      bs.flatMap((b) =>
        b.phase === "commit"
          ? []
          : [{ ...b, phase: b.phase === "splitIn" ? "diverge" : b.phase }]
      )
    );
  }, [boundaries]);

  // Build the blocks to paint: one per run, overridden into abutting halves for
  // any run in an in-flight boundary.
  const rectOf = (start: number, end: number): Rect | null => {
    const s = itemRects[start];
    const e = itemRects[end];
    if (!s || !e) return null;
    return {
      top: s.top,
      left: Math.min(s.left, e.left),
      width: Math.max(s.width, e.width),
      height: e.top + e.height - s.top,
    };
  };
  const blocks: SelBlock[] = [];
  for (const run of runs) {
    const r = rectOf(run.start, run.end);
    if (r)
      blocks.push({
        key: `sel-${run.id}`,
        ...r,
        radii: [R, R, R, R],
        instant: false,
        exitInstant: false,
        delayCorners: false,
      });
  }
  const byId = new Map(blocks.map((b) => [b.key, b]));
  for (const b of boundaries) {
    const gap = itemRects[b.gapIndex];
    const sv = byId.get(`sel-${b.survivorId}`);
    if (!gap || !sv) continue;
    const midY = gap.top + gap.height / 2;
    if (b.kind === "merge") {
      if (b.phase === "commit") {
        // Zero-shift swap: survivor jumps to the full union (already covered by
        // its top half + the absorbed bottom half). The absorbed half is held
        // one render at opacity 0 so removing it next render can't flash a
        // one-frame overlap with the now-full survivor.
        sv.instant = true;
        blocks.push({
          key: `sel-${b.otherId}`,
          top: midY,
          left: sv.left,
          width: sv.width,
          height: sv.top + sv.height - midY,
          radii: [0, 0, R, R],
          instant: true,
          exitInstant: true,
          delayCorners: false,
          opacity: 0,
        });
        continue;
      }
      // converge: survivor → top half, absorbed run → bottom-half ghost, inner
      // corners straightening to sharp.
      // Slightly trail lower merges while keeping a baseline and small cap.
      const mergeCornerDelay = Math.min(
        cornerDelay + 0.03,
        Math.max(cornerDelay, cornerDelay + (midY / Math.max(gap.height, 1)) * 0.002)
      );
      const bottom = sv.top + sv.height;
      sv.height = midY - sv.top;
      sv.radii = [R, R, 0, 0];
      sv.delayCorners = true;
      sv.cornerDelay = mergeCornerDelay;
      blocks.push({
        key: `sel-${b.otherId}`,
        top: midY,
        left: sv.left,
        width: sv.width,
        height: bottom - midY,
        radii: [0, 0, R, R],
        // Mount at full corners so a fresh ghost still animates the
        // straightening with the same delay as the survivor.
        enterFrom: { top: midY, height: bottom - midY, radii: [R, R, R, R] },
        instant: false,
        exitInstant: true,
        delayCorners: true,
        cornerDelay: mergeCornerDelay,
      });
    } else if (b.phase === "splitIn") {
      const lo = byId.get(`sel-${b.otherId}`);
      if (!lo) continue;
      // Pin both halves at the seam (identical to the single block); the
      // diverge render then springs them to their real rects.
      const bottom = lo.top + lo.height;
      sv.height = midY - sv.top;
      sv.radii = [R, R, 0, 0];
      sv.instant = true;
      lo.top = midY;
      lo.height = bottom - midY;
      lo.radii = [0, 0, R, R];
      lo.instant = true;
      lo.enterFrom = { top: midY, height: bottom - midY, radii: [0, 0, R, R] };
    }
    // diverge: nothing to override — the steady blocks spring to their real
    // rects from the seam; the timer drops the boundary.
  }

  // Split safety net, pinned synchronously. The split boundary above is created
  // in a layout effect that runs *after* this render, so on the very frame a
  // split first appears its fresh lower half would mount at its final rect and
  // snap. Detecting the split here (previous runs vs current) and pinning both
  // halves at the seam guarantees the lower mounts on the seam regardless of
  // render/paint timing (the cause of the rapid-toggle snap).
  for (const p of prevRunsRef.current) {
    const c = bridgePair(p, runs);
    const gap = c && itemRects[c.gap];
    if (!c || !gap) continue;
    const midY = gap.top + gap.height / 2;
    const up = byId.get(`sel-${c.up.id}`);
    const lo = byId.get(`sel-${c.lo.id}`);
    if (!up || !lo) continue;
    const bottom = lo.top + lo.height;
    up.height = midY - up.top;
    up.radii = [R, R, 0, 0];
    up.instant = true;
    lo.top = midY;
    lo.height = bottom - midY;
    lo.radii = [0, 0, R, R];
    lo.instant = true;
    lo.enterFrom = { top: midY, height: bottom - midY, radii: [0, 0, R, R] };
  }

  return blocks;
}

// Renders the selected-background blocks produced by useMergeSplitBlocks — one
// per run, or two abutting halves mid merge/split. A block's own `opacity`
// override (e.g. the commit ghost) applies; otherwise blocks render fully
// opaque. Corners are driven numerically so merge/split can straighten and
// re-round individual corners.
export function SelectionBackgrounds({
  blocks,
}: {
  blocks: SelBlock[];
}) {
  return (
    <AnimatePresence>
      {blocks.map((b) => {
        const corner = b.delayCorners
          ? { ...mergeSpring, delay: b.cornerDelay ?? cornerDelay }
          : mergeSpring;
        const opacity = b.opacity ?? 1;
        return (
          <motion.div
            key={b.key}
            aria-hidden
            className="absolute bg-active pointer-events-none"
            initial={
              b.enterFrom
                ? {
                    opacity,
                    top: b.enterFrom.top,
                    left: b.left,
                    width: b.width,
                    height: b.enterFrom.height,
                    borderTopLeftRadius: b.enterFrom.radii[0],
                    borderTopRightRadius: b.enterFrom.radii[1],
                    borderBottomRightRadius: b.enterFrom.radii[2],
                    borderBottomLeftRadius: b.enterFrom.radii[3],
                  }
                : false
            }
            animate={{
              top: b.top,
              left: b.left,
              width: b.width,
              height: b.height,
              borderTopLeftRadius: b.radii[0],
              borderTopRightRadius: b.radii[1],
              borderBottomRightRadius: b.radii[2],
              borderBottomLeftRadius: b.radii[3],
              opacity,
            }}
            exit={{ opacity: 0, transition: b.exitInstant ? { duration: 0 } : mergeSpring.exit }}
            transition={
              b.instant
                ? { duration: 0 }
                : {
                    ...mergeSpring,
                    borderTopLeftRadius: corner,
                    borderTopRightRadius: corner,
                    borderBottomRightRadius: corner,
                    borderBottomLeftRadius: corner,
                    opacity: { duration: 0.08 },
                  }
            }
          />
        );
      })}
    </AnimatePresence>
  );
}
author/registry/default/lib/shape-context.tsx
파일 저장

"use client";

import {
  createContext,
  useContext,
  useState,
  useEffect,
  useRef,
  useCallback,
  useMemo,
  type ReactNode,
} from "react";

type ShapeVariant = "pill" | "rounded";

interface ShapeClasses {
  /** The variant these classes belong to — handy for conditionals. */
  variant: ShapeVariant;
  item: string;
  bg: string;
  focusRing: string;
  mergedBg: string;
  container: string;
  button: string;
  input: string;
  // Numeric counterparts of `bg` / `mergedBg`, in px. Needed where individual
  // corners are animated (e.g. the selected-background merge/split animation),
  // which requires per-corner numeric border-radii rather than a class.
  bgRadius: number;
  mergedRadius: number;
}

const shapeMap: Record<ShapeVariant, ShapeClasses> = {
  pill: {
    variant: "pill",
    item: "rounded-[20px]",
    bg: "rounded-[20px]",
    // +2px over `item` because the focus ring sits 2px outside the element
    // (top/left -2, width/height +4); this keeps the corners concentric so a
    // pill element gets a pill ring (matches the rounded-mode 8px→10px bump).
    focusRing: "rounded-[22px]",
    mergedBg: "rounded-2xl",
    container: "rounded-3xl",
    button: "rounded-[20px]",
    input: "rounded-[20px]",
    bgRadius: 20,
    mergedRadius: 16,
  },
  rounded: {
    variant: "rounded",
    item: "rounded-lg",
    bg: "rounded-lg",
    focusRing: "rounded-[10px]",
    mergedBg: "rounded-lg",
    container: "rounded-xl",
    button: "rounded-lg",
    input: "rounded-lg",
    bgRadius: 8,
    mergedRadius: 8,
  },
};

interface ShapeContextValue {
  shape: ShapeVariant;
  setShape: (shape: ShapeVariant) => void;
  classes: ShapeClasses;
}

const ShapeContext = createContext<ShapeContextValue | null>(null);

// Rounded is the default on every path: the site demos render under
// <ShapeProvider defaultShape="rounded">, the shipped :focus-visible fallback
// ring assumes its 8px radius, and the preset generators only emit a provider
// for pill. A consumer with no provider gets the corners the docs show.
function useShape(): ShapeClasses {
  const ctx = useContext(ShapeContext);
  if (!ctx) return shapeMap.rounded;
  return ctx.classes;
}

function useShapeContext() {
  const ctx = useContext(ShapeContext);
  if (!ctx) throw new Error("useShapeContext must be used within a ShapeProvider");
  return ctx;
}

function ShapeProvider({
  children,
  defaultShape = "rounded",
}: {
  children: ReactNode;
  defaultShape?: ShapeVariant;
}) {
  const [shape, setShapeState] = useState<ShapeVariant>(defaultShape);
  const transitionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Run a state change under the `.transitioning` guard (added + reflow-flushed
  // first so the 180ms border-radius cross-fade applies). Clearing the previous
  // timeout first keeps a double-press from removing the class mid-fade.
  const transitionShape = useCallback((callback: () => void) => {
    const root = document.documentElement;
    root.classList.add("transitioning");
    void root.offsetHeight;
    callback();
    if (transitionTimeoutRef.current) clearTimeout(transitionTimeoutRef.current);
    transitionTimeoutRef.current = setTimeout(
      () => root.classList.remove("transitioning"),
      200
    );
  }, []);

  const setShape = useCallback(
    (next: ShapeVariant) => {
      transitionShape(() => setShapeState(next));
    },
    [transitionShape]
  );

  // Publish the current element radius as a CSS custom property so plain-CSS
  // consumers that can't read React context stay in sync with the shape
  // system — e.g. the @layer base :focus-visible fallback ring in
  // globals.css. Set on <html> so portalled content sees it too.
  useEffect(() => {
    document.documentElement.style.setProperty(
      "--shape-input-radius",
      `${shapeMap[shape].bgRadius}px`
    );
  }, [shape]);

  const value = useMemo(
    () => ({ shape, setShape, classes: shapeMap[shape] }),
    [shape, setShape]
  );

  return (
    <ShapeContext.Provider value={value}>
      {children}
    </ShapeContext.Provider>
  );
}

export { ShapeProvider, useShape, useShapeContext, shapeMap };
export type { ShapeVariant, ShapeClasses };
author/registry/default/lib/size-context.tsx
파일 저장

"use client";

import {
  createContext,
  useContext,
  useState,
  useCallback,
  useMemo,
  type ReactNode,
} from "react";

type SizeVariant = "default" | "compact";

interface SizeClasses {
  /** The variant these classes belong to — handy for conditionals. */
  variant: SizeVariant;
  /** Bounded control height — buttons, inputs, select triggers, subtle tabs —
   *  AND list/menu rows (select options, dropdown, checkbox and radio rows).
   *  One token by design: a popup row lines up with the trigger that opened
   *  it because they share this height. */
  control: string;
  /** `control` as a number, for consumers that need raw pixels. */
  controlHeight: number;
  /** Tab trigger height inside a padded segmented list. Sized so
   *  `segmentPad` + `segmentItem` adds back up to the control height —
   *  the segmented control's outer box stays on the same ladder. */
  segmentItem: string;
  /** Padding of the segmented list around its tabs. */
  segmentPad: string;
  /** Body text inside controls. */
  text: string;
  /** Horizontal padding of bounded controls (select trigger, inputs). */
  px: string;
  /** Horizontal padding of list/menu rows, which sit inside a padded popup
   *  or group and need less inset than a bounded control. */
  itemPx: string;
  /** Gap between an icon / control glyph and its label, and between
   *  neighbouring controls in a row (toolbars, filter bars, button
   *  clusters). Density is spacing as much as control height, so the
   *  compact step halves it. */
  gap: string;
  /** Glyph size in px: leading/trailing icons inside controls, and the
   *  checkbox square / radio circle. */
  icon: number;
}

const sizeMap: Record<SizeVariant, SizeClasses> = {
  // 36px — the default control height. Matches a 13px label with comfortable
  // breathing room and keeps controls a workable pointer target.
  default: {
    variant: "default",
    control: "h-9",
    controlHeight: 36,
    segmentItem: "h-7",
    segmentPad: "p-1",
    text: "text-[13px]",
    px: "px-3",
    itemPx: "px-2",
    gap: "gap-2",
    icon: 16,
  },
  // 28px — the compact height for dense surfaces: filter bars, toolbars,
  // table headers, sidebars. One step down in text (12px) and icon (14px)
  // so the whole control shrinks together, not just its box.
  compact: {
    variant: "compact",
    control: "h-7",
    controlHeight: 28,
    segmentItem: "h-6",
    segmentPad: "p-0.5",
    text: "text-[12px]",
    px: "px-2.5",
    itemPx: "px-1.5",
    gap: "gap-1",
    icon: 14,
  },
};

/** One role of the type scale: px per ladder step. */
interface TypeScaleStep {
  default: number;
  compact: number;
}

/**
 * Role-based type scale, per ladder step (px values).
 *
 * The default column is the system as shipped; the compact column steps each
 * role down one notch so dense regions read as a smaller sibling of the same
 * hierarchy, not a squeezed copy. `body`, `caption`, and `subtitle` are what
 * the sized components already render through `SizeClasses.text` and their
 * compact conditionals; `display` and `title` are the page-level roles
 * for consumers composing their own screens.
 */
const typeScale = {
  /** Page titles. */
  display: { default: 28, compact: 24 },
  /** Section headings, dialog titles. */
  title: { default: 16, compact: 15 },
  /** Card titles, chat bubbles, emphasized rows. */
  subtitle: { default: 14, compact: 13 },
  /** Control labels and body copy — `SizeClasses.text`. */
  body: { default: 13, compact: 12 },
  /** Secondary text: descriptions, meta rows, errors, eyebrows and group
   *  labels (the former overline role — an uppercase or muted caption). */
  caption: { default: 12, compact: 11 },
} as const satisfies Record<string, TypeScaleStep>;

type TypeScaleRole = keyof typeof typeScale;

/** The type scale resolved for the active ladder step (px per role):
 *  explicit override > surrounding SizeProvider > "default". */
function useTypeScale(
  override?: SizeVariant | null
): Record<TypeScaleRole, number> {
  const variant = useSizeVariant(override);
  return {
    display: typeScale.display[variant],
    title: typeScale.title[variant],
    subtitle: typeScale.subtitle[variant],
    body: typeScale.body[variant],
    caption: typeScale.caption[variant],
  };
}

interface SizeContextValue {
  size: SizeVariant;
  setSize: (size: SizeVariant) => void;
  classes: SizeClasses;
}

const SizeContext = createContext<SizeContextValue | null>(null);

/** Resolve the active size variant: explicit prop > provider > "default". */
function useSizeVariant(override?: SizeVariant | null): SizeVariant {
  const ctx = useContext(SizeContext);
  return override ?? ctx?.size ?? "default";
}

/** Resolve size classes: explicit prop > provider > "default". */
function useSize(override?: SizeVariant | null): SizeClasses {
  return sizeMap[useSizeVariant(override)];
}

function useSizeContext() {
  const ctx = useContext(SizeContext);
  if (!ctx) throw new Error("useSizeContext must be used within a SizeProvider");
  return ctx;
}

function SizeProvider({
  children,
  size,
  defaultSize = "default",
}: {
  children: ReactNode;
  /** Controlled variant — pin a whole region to one size (e.g. a compact
   *  filter bar). Overrides internal state. */
  size?: SizeVariant;
  defaultSize?: SizeVariant;
}) {
  const [internalSize, setInternalSize] = useState<SizeVariant>(defaultSize);
  const isControlled = size !== undefined;
  const resolved = size ?? internalSize;

  // Controlled providers ignore setSize entirely — a background write to the
  // shadowed internal state would pop back out if the size prop were later
  // removed.
  const setSize = useCallback(
    (next: SizeVariant) => {
      if (isControlled) return;
      setInternalSize(next);
    },
    [isControlled]
  );

  const value = useMemo(
    () => ({ size: resolved, setSize, classes: sizeMap[resolved] }),
    [resolved, setSize]
  );

  return <SizeContext.Provider value={value}>{children}</SizeContext.Provider>;
}

export {
  SizeProvider,
  useSize,
  useSizeVariant,
  useSizeContext,
  useTypeScale,
  sizeMap,
  typeScale,
};
export type { SizeVariant, SizeClasses, TypeScaleRole, TypeScaleStep };
author/registry/default/fluid-hover-highlight.tsx
파일 저장

"use client";

import {
  motion,
  AnimatePresence,
  useReducedMotion,
  type Transition,
} from "framer-motion";
import { cn } from "@/lib/utils";
import { spring } from "@/lib/springs";
import type { ItemRect, UseFluidHoverReturn } from "@/hooks/use-fluid-hover";

// ---------------------------------------------------------------------------
// The one hover highlight every fluid hover list renders: an absolutely
// positioned fill that springs between the rects `useFluidHover` measures.
// Consumers used to hand-roll this motion.div; this is that block, once.
//
//   const hover = useFluidHover(ref);
//   <FluidHoverHighlight hover={hover} className={shape.bg} />
//
// It owns no layout opinion beyond `absolute`: radius, z-index, and the
// offsetParent (the container must be `relative`) are the consumer's.
// ---------------------------------------------------------------------------

/** What the highlight reads off the hook: the highlighted index, the
 *  measured rects, whether they are current, and the pointer session. */
export type FluidHoverSource = Pick<
  UseFluidHoverReturn,
  "activeIndex" | "itemRects" | "isMeasured" | "sessionRef"
>;

interface HighlightFromHook {
  /** The hook's return value. The highlight sits on
   *  `itemRects[activeIndex]` once `isMeasured`, and re-keys on the session. */
  hover: FluidHoverSource;
  /** Keep the list's state but show nothing (a closed popup, hover switched
   *  off). Runs the exit fade. */
  hidden?: boolean;
  rect?: never;
  session?: never;
}

interface HighlightFromRect {
  /** For lists that resolve their own rect (the sidebar's unified scope):
   *  the rect to sit on, in the container's coordinate space. `null` hides
   *  the highlight (it fades out on `spring.fast.exit`). */
  rect: ItemRect | null;
  /** `sessionRef.current` from `useFluidHover`. It increments when the
   *  cursor enters the container, which re-keys the highlight so it fades in
   *  at `from ?? rect` instead of sliding over from wherever it was last. */
  session: number;
  hover?: never;
  hidden?: never;
}

export type FluidHoverHighlightProps = (HighlightFromHook | HighlightFromRect) & {
  /** Where a fresh session fades in from. A dropdown passes its checked row,
   *  a nav menu its active route. Defaults to the rect itself. */
  from?: ItemRect | null;
  /** Radius, z-index, anything else. Merged onto
   *  `absolute bg-hover pointer-events-none`. */
  className?: string;
  /** The positional spring. Defaults to `spring.fast`. Pass `false` to snap
   *  to the new rect with no travel (a layout reflow that moved the rows
   *  underneath, not a hover change). The opacity fade is always 0.08s. */
  transition?: Transition | false;
};

const fade: Transition = { duration: 0.08 };
const snap: Transition = { duration: 0 };

/** A measured rect as animation targets: position as a transform, size as
 *  layout. Exported for the unit test. */
export function toTarget(rect: ItemRect) {
  return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
}

/**
 * Resolves the positional transition. Reduced motion keeps the opacity fade
 * and drops the travel, per the motion guidelines: fewer and gentler, not
 * none. Exported for the unit test.
 */
export function resolveHighlightTransition(
  transition: Transition | false | undefined,
  reduceMotion: boolean
): Transition {
  const positional =
    transition === false || reduceMotion ? snap : (transition ?? spring.fast);
  return { ...positional, opacity: fade };
}

/** The rect and session a set of props resolves to. Exported for the test. */
export function resolveHighlightSource(
  props: FluidHoverHighlightProps
): { rect: ItemRect | null; session: number } {
  if (props.hover) {
    const { activeIndex, itemRects, isMeasured, sessionRef } = props.hover;
    const rect =
      !props.hidden && isMeasured && activeIndex !== null
        ? (itemRects[activeIndex] ?? null)
        : null;
    return { rect, session: sessionRef.current };
  }
  return { rect: props.rect, session: props.session };
}

export function FluidHoverHighlight(props: FluidHoverHighlightProps) {
  const { from, className, transition } = props;
  const { rect, session } = resolveHighlightSource(props);
  // Reads the OS media query directly, so an installed copy honours reduced
  // motion without the app wrapping its tree in MotionConfig. A wrapped app
  // gets the same result twice over: the travel is a transform, which
  // MotionConfig reduces too.
  const reduceMotion = useReducedMotion() ?? false;
  return (
    <AnimatePresence>
      {rect && (
        <motion.div
          key={session}
          data-slot="fluid-hover-highlight"
          // Pinned to the container's padding corner and moved with a
          // transform, so the travel runs on the compositor instead of
          // re-laying out every frame. Width and height are real layout
          // values, but they only change when the target rect's size does,
          // which in most lists is never.
          className={cn(
            "pointer-events-none absolute left-0 top-0 bg-hover",
            className
          )}
          initial={{ opacity: 0, ...toTarget(from ?? rect) }}
          animate={{ opacity: 1, ...toTarget(rect) }}
          exit={{ opacity: 0, transition: spring.fast.exit }}
          transition={resolveHighlightTransition(transition, reduceMotion)}
        />
      )}
    </AnimatePresence>
  );
}
author/app/globals.css
파일 저장

@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

@font-face {
  font-family: "Inter";
  src: url("/fonts/InterVariable.ttf") format("truetype");
  font-weight: 100 900;
  font-display: swap;
}

/* ============================================================================
   1. DESIGN TOKENS
   ----------------------------------------------------------------------------
   Every color token is written ONCE, as light-dark(light, dark). Which side
   renders is driven by `color-scheme`: the root follows the OS, and a `.light`
   or `.dark` class (on <html> via ThemeProvider, or on any subtree for a
   forced-theme preview) pins it.

   The selector is `:root, .light, .dark` — not just `:root` — because
   light-dark() inside a custom property resolves against the color-scheme of
   the element the property is *declared on*, not the element that uses it.
   Re-declaring the same block on forced-scope elements makes each scope its
   own resolution point, so a `.dark` preview nested in a light page (see
   /docs/surfaces) gets dark tokens.

   Two exceptions can't ride light-dark() and keep a small per-theme switch in
   section 2: the --overlay RGB triplet (consumed as rgb(var(--overlay) / α)
   by components, so the format is a public contract) and the shadow ladder
   (light and dark use structurally different recipes, not just different
   colors).
============================================================================ */

:root {
  color-scheme: light dark;
}

:root,
.light,
.dark {
  /* ── Surfaces (8-level ladder) ──
     Light: 2 color steps (floor, sunken) then flat #FFFFFF; shadow does the
     work. Dark: additive white-opacity ladder over #171717. Each surface
     pairs 1:1 with a shadow recipe (--shadow-1 .. --shadow-8). */
  --surface-1: light-dark(#FAFAFA, #171717);
  --surface-2: light-dark(#FCFCFC, #1E1E1E);
  --surface-3: light-dark(#FFFFFF, #252525);
  --surface-4: light-dark(#FFFFFF, #2C2C2C);
  --surface-5: light-dark(#FFFFFF, #333333);
  --surface-6: light-dark(#FFFFFF, #3A3A3A);
  --surface-7: light-dark(#FFFFFF, #414141);
  --surface-8: light-dark(#FFFFFF, #484848);

  /* ── Semantic tokens ── */
  --background: var(--surface-1);
  --foreground: light-dark(#171717, #F5F5F5);
  --card: var(--surface-3);
  --card-foreground: light-dark(#171717, #F5F5F5);
  /* Light `muted` decouples from surface-2: the light surface ladder is so
     compressed (#FAFAFA → #FCFCFC → #FFFFFF) that bg-muted would read as
     near-white, making anything sitting on it (Tabs track, etc.) blend
     together. A slightly more grey value gives muted real semantic presence.
     Dark keeps the ladder value (#1E1E1E = surface-2). */
  --muted: light-dark(#F4F4F5, #1E1E1E);
  --muted-foreground: light-dark(#737373, #A3A3A3);

  /* ── Interactive states ── */
  --accent: light-dark(#E5E5E5, #525252);
  --accent-foreground: light-dark(#171717, #F5F5F5);
  --selected: light-dark(#D4D4D4, #525252);

  /* ── Borders ──
     --border mixes from --foreground, so it tracks the theme automatically —
     including inside nested forced scopes, where light-dark() re-resolves
     --foreground before the mix happens. */
  --border: color-mix(in oklab, var(--foreground) 12%, transparent);
  --ring: light-dark(#E5E5E5, #404040);
  --input: light-dark(#E5E5E5, #404040);

  /* ── Error / Destructive ── */
  --destructive: light-dark(#EF4444, #F87171);
  --destructive-light: light-dark(#FEF2F2, #450A0A);

  /* ── Focus indicator ──
     Every component focus ring reads var(--focus-ring, #6B97FF), so this is
     the single theming point. The literal fallback keeps registry components
     self-contained when installed into projects without the variable. */
  --focus-ring: #6B97FF;

  /* ── Alpha checkerboard (color-picker swatches) ── */
  --checker-a: light-dark(#bbbbbb, #1f1f1f);
  --checker-b: light-dark(#ffffff, #2a2a2a);

  /* ── Surface-relative interactive overlays (work on any elevation) ──
     --overlay is the *tint direction* triplet — black for light surfaces,
     white for dark. Components consume it as rgb(var(--overlay) / α), so it
     stays an RGB triplet and switches per theme in section 2. --hover and
     --active are the pre-mixed conveniences. */
  --overlay: 0 0 0;
  --hover: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.06));
  --active: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.1));

  /* ── Shadow recipes ──
     Light: additive stacked drops, halving offsets (1/3/6/12/24/48/96 px).
     Dark: inset highlight + inset ring + stacked drops (per CodePen).
     Structurally different, so each ladder is written out once here and the
     active --shadow-N is selected per theme in section 2.
     --shadow-color is intentionally static: it is also consumed directly by
     components (input-message edge drop) and has never varied by theme. */
  --shadow-color: rgb(0 0 0 / 0.06);
  --shadow-light-1: 0 0 0 1px var(--shadow-color);
  --shadow-light-2: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color);
  --shadow-light-3: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color);
  --shadow-light-4: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color);
  --shadow-light-5: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color);
  --shadow-light-6: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color), 0 24px 24px -12px var(--shadow-color);
  --shadow-light-7: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color), 0 24px 24px -12px var(--shadow-color), 0 48px 48px -24px var(--shadow-color);
  --shadow-light-8: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color), 0 24px 24px -12px var(--shadow-color), 0 48px 48px -24px var(--shadow-color), 0 96px 96px -48px var(--shadow-color);
  /* Surface-2/3 with the hairline moved INSIDE the box — for cards whose
     border must stay within their own edge (the sidebar's stacked footer
     callouts). Dark's recipes already draw their ring inset, so the dark
     scopes alias --shadow-dark-2/3 directly. */
  --shadow-light-2-inset: inset 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color);
  --shadow-light-3-inset: inset 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color);

  --dm-hi-base: rgba(255,255,255,0.01);
  --dm-hi-mid: rgba(255,255,255,0.02);
  --dm-hi-high: rgba(255,255,255,0.04);
  --dm-hi-peak: rgba(255,255,255,0.06);
  --dm-ring-base: rgba(255,255,255,0.02);
  --dm-ring-mid: rgba(255,255,255,0.04);
  --dm-ring-high: rgba(255,255,255,0.06);
  --dm-drop: rgba(0,0,0,0.18);
  --shadow-dark-1: inset 0 0 0 1px var(--dm-ring-base);
  --shadow-dark-2: inset 0 1px 0 0 var(--dm-hi-base), inset 0 0 0 1px var(--dm-ring-base), 0 1px 1px -0.5px var(--dm-drop);
  --shadow-dark-3: inset 0 1px 0 0 var(--dm-hi-mid), inset 0 0 0 1px var(--dm-ring-base), 0 0 0 1px rgba(0,0,0,0.12), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop);
  --shadow-dark-4: inset 0 1px 0 0 var(--dm-hi-mid), inset 0 0 0 1px var(--dm-ring-mid), 0 0 0 1px rgba(0,0,0,0.14), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop);
  --shadow-dark-5: inset 0 1px 0 0 var(--dm-hi-high), inset 0 0 0 1px var(--dm-ring-mid), 0 0 0 1px rgba(0,0,0,0.16), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop);
  --shadow-dark-6: inset 0 1px 0 0 var(--dm-hi-high), inset 0 0 0 1px var(--dm-ring-high), 0 0 0 1px rgba(0,0,0,0.18), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop), 0 24px 24px -12px var(--dm-drop);
  --shadow-dark-7: inset 0 1px 0 0 var(--dm-hi-peak), inset 0 0 0 1px var(--dm-ring-high), 0 0 0 1px rgba(0,0,0,0.20), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop), 0 24px 24px -12px var(--dm-drop), 0 48px 48px -24px var(--dm-drop);
  --shadow-dark-8: inset 0 1px 0 0 var(--dm-hi-peak), inset 0 0 0 1px var(--dm-ring-high), 0 0 0 1px rgba(0,0,0,0.22), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop), 0 24px 24px -12px var(--dm-drop), 0 48px 48px -24px var(--dm-drop), 0 96px 96px -48px var(--dm-drop);

  --shadow-1: var(--shadow-light-1);
  --shadow-2: var(--shadow-light-2);
  --shadow-3: var(--shadow-light-3);
  --shadow-4: var(--shadow-light-4);
  --shadow-5: var(--shadow-light-5);
  --shadow-6: var(--shadow-light-6);
  --shadow-7: var(--shadow-light-7);
  --shadow-8: var(--shadow-light-8);
  --shadow-2-inset: var(--shadow-light-2-inset);
  --shadow-3-inset: var(--shadow-light-3-inset);
}

/* ============================================================================
   2. THEME SWITCHING
   ----------------------------------------------------------------------------
   `color-scheme` is what flips every light-dark() token above; the classes
   work on <html> (ThemeProvider) and on any subtree (forced-theme previews).
   The only values that need manual switching are the --overlay triplet and
   the shadow recipe selection — see the section-1 note.
============================================================================ */

@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --overlay: 255 255 255;
    --shadow-1: var(--shadow-dark-1);
    --shadow-2: var(--shadow-dark-2);
    --shadow-3: var(--shadow-dark-3);
    --shadow-4: var(--shadow-dark-4);
    --shadow-5: var(--shadow-dark-5);
    --shadow-6: var(--shadow-dark-6);
    --shadow-7: var(--shadow-dark-7);
    --shadow-8: var(--shadow-dark-8);
    --shadow-2-inset: var(--shadow-dark-2);
    --shadow-3-inset: var(--shadow-dark-3);
  }
}

.light {
  color-scheme: light;
  --overlay: 0 0 0;
  --shadow-2-inset: var(--shadow-light-2-inset);
  --shadow-3-inset: var(--shadow-light-3-inset);
  --shadow-1: var(--shadow-light-1);
  --shadow-2: var(--shadow-light-2);
  --shadow-3: var(--shadow-light-3);
  --shadow-4: var(--shadow-light-4);
  --shadow-5: var(--shadow-light-5);
  --shadow-6: var(--shadow-light-6);
  --shadow-7: var(--shadow-light-7);
  --shadow-8: var(--shadow-light-8);
}

.dark {
  color-scheme: dark;
  --overlay: 255 255 255;
  --shadow-2-inset: var(--shadow-dark-2);
  --shadow-3-inset: var(--shadow-dark-3);
  --shadow-1: var(--shadow-dark-1);
  --shadow-2: var(--shadow-dark-2);
  --shadow-3: var(--shadow-dark-3);
  --shadow-4: var(--shadow-dark-4);
  --shadow-5: var(--shadow-dark-5);
  --shadow-6: var(--shadow-dark-6);
  --shadow-7: var(--shadow-dark-7);
  --shadow-8: var(--shadow-dark-8);
}

/* ============================================================================
   3. TAILWIND THEME MAPPING
============================================================================ */

@theme inline {
  --font-sans: "Inter", system-ui, sans-serif;
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-selected: var(--selected);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --color-input: var(--input);
  --color-destructive: var(--destructive);
  --color-destructive-light: var(--destructive-light);
  --color-hover: var(--hover);
  --color-active: var(--active);

  /* Surface ladder — bg-surface-1..8 utilities */
  --color-surface-1: var(--surface-1);
  --color-surface-2: var(--surface-2);
  --color-surface-3: var(--surface-3);
  --color-surface-4: var(--surface-4);
  --color-surface-5: var(--surface-5);
  --color-surface-6: var(--surface-6);
  --color-surface-7: var(--surface-7);
  --color-surface-8: var(--surface-8);

  /* Shadow ladder — shadow-surface-1..8 utilities. Monotonic with surfaces. */
  --shadow-surface-1: var(--shadow-1);
  --shadow-surface-2: var(--shadow-2);
  --shadow-surface-3: var(--shadow-3);
  --shadow-surface-4: var(--shadow-4);
  --shadow-surface-5: var(--shadow-5);
  --shadow-surface-6: var(--shadow-6);
  --shadow-surface-7: var(--shadow-7);
  --shadow-surface-8: var(--shadow-8);

  /* Reserved for /compare shadcn theme only — DO NOT use these utilities
     (bg-primary, bg-secondary, bg-popover, *-foreground variants) in Fluid
     Functionalism components. The variables below are undefined at :root and
     only get values inside `.shadcn-theme` (see app/compare/shadcn-theme.css).
     ESLint enforces this. */
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-destructive-foreground: var(--destructive-foreground);
}

/* ============================================================================
   4. BASE STYLES
============================================================================ */

/* No explicit overflow here: scrollbar-gutter applies to the viewport when
   set on the root, and an explicit `overflow-y: auto` would stop dialog
   scroll locks (`body { overflow: hidden }`) from propagating to the
   viewport — body would become a clip container instead, which breaks
   position: sticky for the sidebar and right panel (they snap to the
   document top the moment a dialog opens). */
html {
  scrollbar-gutter: stable;
}

/* Radix popups (dropdown, select, dialog) lock scroll through
   react-remove-scroll, which compensates for the vanished viewport scrollbar
   with `margin-right: <scrollbar-width> !important` on <body>. The gutter
   above already keeps that space reserved while the lock hides the scrollbar,
   so the compensation double-counts and the whole page shifts left every time
   a menu opens (visible since the native scrollbar theme below forces inset
   scrollbars). The doubled attribute selector out-specifies the injected rule
   (0-2-1 vs 0-1-1) so this wins regardless of stylesheet order. Base UI's
   scroll lock is gutter-aware and needs no counterpart. */
body[data-scroll-locked][data-scroll-locked] {
  margin-right: 0 !important;
}

html, body {
  margin: 0;
  min-height: 100vh;
  overscroll-behavior-y: none;
  background-color: var(--background);
  /* Explicit text color so unstyled text follows the theme (and so the UA's
     color-scheme-driven canvastext default never shows through). */
  color: var(--foreground);
  font-family: var(--font-sans);
  /* Inherited by everything (portals included) — a `subpixel-antialiased`
     utility on a subtree is the only way to opt back out. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* @layer base so a utility can still win: unlayered, these outrank
   `truncate` on the text-wrap-mode longhand (white-space is a shorthand for
   white-space-collapse + text-wrap-mode), which silently un-truncates every
   <p> that asks for it — CardDescription among them. */
@layer base {
  h1, h2, h3 {
    text-wrap: balance;
  }

  p {
    text-wrap: pretty;
  }

  /* Inline code chips: bare <code> in prose gets its own ground (the same
     recipe the motion/scrollbars pages hand-rolled). Excludes code inside
     <pre> — shiki blocks style themselves. Base layer, so a page can still
     override with utilities. */
  code:not(pre code) {
    background: light-dark(#EBEBED, #2C2C2C);
    border-radius: 4px;
    padding: 1px 4px;
    font-size: var(--fs-caption, 12px);
    color: var(--color-foreground, inherit);
  }
}

/* ── Type-scale roles ─────────────────────────────────────────────────────
   Site-chrome font sizes ride these variables instead of fixed px so the
   whole site follows the size ladder. The docs-only SizeAttribute component
   (lib/docs/size-attribute.tsx) mirrors the active step onto
   <html data-size>; values must stay in sync with `typeScale` in
   registry/default/lib/size-context.tsx. `display` keeps its responsive
   pair (mobile / ≥sm) from the old text-[22px] sm:text-[28px] titles. */
:root {
  --fs-display: 22px;
  --fs-title: 16px;
  --fs-subtitle: 14px;
  --fs-body: 13px;
  --fs-caption: 12px;
}

@media (min-width: 640px) {
  :root {
    --fs-display: 28px;
  }
}

html[data-size="compact"] {
  --fs-display: 20px;
  --fs-title: 15px;
  --fs-subtitle: 13px;
  --fs-body: 12px;
  --fs-caption: 11px;
}

@media (min-width: 640px) {
  html[data-size="compact"] {
    --fs-display: 24px;
  }
}

@utility text-display {
  font-size: var(--fs-display, 28px);
}
@utility text-title {
  font-size: var(--fs-title, 16px);
}
@utility text-subtitle {
  font-size: var(--fs-subtitle, 14px);
}
@utility text-body {
  font-size: var(--fs-body, 13px);
}
@utility text-caption {
  font-size: var(--fs-caption, 12px);
}

/* Theme-toggle cross-fade: ThemeProvider adds `.transitioning` around the
   class swap so colors tween instead of snapping. */
html.transitioning *,
html.transitioning *::before,
html.transitioning *::after {
  transition: border-radius 180ms ease-in-out,
              background-color 180ms ease-in-out,
              color 180ms ease-in-out,
              border-color 180ms ease-in-out,
              fill 180ms ease-in-out,
              stroke 180ms ease-in-out !important;
}

/* Focus fallback — natively focusable elements no component styles (scroll
   containers Chrome makes tabbable, prose links, demo boxes) get the same
   token ring as the components instead of the UA default, which follows the
   OS accent color. Lives in @layer base so `outline-none` (utilities layer)
   still wins — components that draw their own ring aren't double-ringed,
   and elements with their own radius utility keep it. The radius rides
   --shape-input-radius (published by ShapeProvider) so the ring corners
   match the borderless select in both shape modes; 8px = the rounded-mode
   default for pre-hydration paint. Enforced by the focus-ring lint rule in
   eslint.config.mjs. */
@layer base {
  :focus-visible {
    outline: 1px solid var(--focus-ring, #6B97FF);
    outline-offset: 2px;
    border-radius: var(--shape-input-radius, 8px);
  }

  /* Prose links are inline text boxes — the shape-system radius reads
     pill-ish at text height, so their ring takes a tight 2px corner
     instead. Component links (NavItem, Button asChild) carry their own
     radius utilities, which win over this base-layer rule. */
  a:focus-visible {
    border-radius: 2px;
  }
}

/* ============================================================================
   5. NATIVE SCROLLBAR THEME
   ----------------------------------------------------------------------------
   Matches the ScrollArea thumb language (narrow, low-contrast, darkens on
   hover) so surfaces that fall back to native scrollbars (the root page,
   touch-device viewports) stay consistent with the custom scrollbar. Rides
   the same fixed surface-relative overlay ramp as the ScrollArea thumb
   (rest 8%, hover 12%, drag 16%) — adapts to dark mode through --overlay.
   Only on fine pointers; touch devices keep their overlay momentum bars.
   `.scrollbar-hide` still wins (higher specificity). Chromium 121+ renders
   scrollbar-color and ignores the ::-webkit-* block, so both ramps must
   stay in sync.
============================================================================ */

@media (pointer: fine) {
  * {
    scrollbar-width: thin;
    scrollbar-color: rgb(var(--overlay) / 0.08) transparent;
  }

  ::-webkit-scrollbar {
    width: 10px;
    height: 10px;
  }

  ::-webkit-scrollbar-track,
  ::-webkit-scrollbar-corner {
    background: transparent;
  }

  ::-webkit-scrollbar-thumb {
    /* Transparent borders + content-box clip leave a ~4px visible thumb
       inside the 10px hit area, mirroring the custom thumb's resting width.
       The asymmetric edge-side border (5px outer / 1px inner, set per
       orientation below) slides the visible thumb 2px away from the edge,
       matching the ScrollArea thumb's -translate nudge. */
    background-color: rgb(var(--overlay) / 0.08);
    border: 3px solid transparent;
    background-clip: content-box;
    border-radius: 9999px;
  }

  ::-webkit-scrollbar-thumb:vertical {
    border-left-width: 1px;
    border-right-width: 5px;
  }

  ::-webkit-scrollbar-thumb:horizontal {
    border-top-width: 1px;
    border-bottom-width: 5px;
  }

  ::-webkit-scrollbar-thumb:hover {
    background-color: rgb(var(--overlay) / 0.12);
  }

  ::-webkit-scrollbar-thumb:active {
    background-color: rgb(var(--overlay) / 0.16);
  }
}

.scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
}

.scrollbar-hide::-webkit-scrollbar {
  display: none;
}

/* ============================================================================
   6. UTILITIES
============================================================================ */

/* ── shimmer-text (thinking-indicator, thinking-steps) ── */

@keyframes shimmer {
  0% {
    background-position: 0% 0;
  }
  100% {
    background-position: 100% 0;
  }
}

.shimmer-text {
  color: transparent;
  background: linear-gradient(
    90deg,
    light-dark(#a3a3a3, #525252) 0%,
    light-dark(#a3a3a3, #525252) 35%,
    light-dark(#525252, #a3a3a3) 50%,
    light-dark(#a3a3a3, #525252) 65%,
    light-dark(#a3a3a3, #525252) 100%
  );
  background-size: 300% 100%;
  background-clip: text;
  -webkit-background-clip: text;
  animation: shimmer 1.5s ease-in-out infinite;
}

/* ── Spinner keyframes ── */

@keyframes spinner-move {
  to { stroke-dashoffset: -100; }
}

@keyframes spinner-dash {
  0%, 100% { stroke-dasharray: 15 85; }
  50%      { stroke-dasharray: 40 60; }
}

/* ── Responsive side-panel cross-fade ──
   The left sidebar and right panel switch in/out at xl (1280px). Rather than
   snapping via `display`, these classes fade them: `display` is animated with
   `allow-discrete` so the element stays visible across the none↔shown flip,
   while opacity tweens. Durations mirror the `spring.slow` motion token —
   appear in 0.24s, disappear in 0.16s (exit one tier quicker, matching the
   token's faster `.exit`). The `@starting-style` gives the appear transition a
   starting opacity — required for an element to animate *out of* `display:none`
   (otherwise it would pop in). Because the panel's space is already reserved at
   that width, the appear is a pure opacity fade with no layout shift (it also
   fades in once on initial wide-screen load). Browsers without
   `transition-behavior` degrade gracefully to the old instant switch. */

.xl-fade-flex,
.xl-fade-block {
  display: none;
  opacity: 0;
  transition:
    opacity 0.16s ease-out,
    display 0.16s allow-discrete;
}

@media (min-width: 1280px) {
  .xl-fade-flex {
    display: flex;
  }
  .xl-fade-block {
    display: block;
  }
  .xl-fade-flex,
  .xl-fade-block {
    opacity: 1;
    transition:
      opacity 0.24s ease-out,
      display 0.24s allow-discrete;
  }
  @starting-style {
    .xl-fade-flex,
    .xl-fade-block {
      opacity: 0;
    }
  }
}

@media (prefers-reduced-motion: reduce) {
  .xl-fade-flex,
  .xl-fade-block {
    transition: none;
  }
}

/* ── In-view cross-fade ──
   Same fade technique as the side panels above, but toggled by a `data-shown`
   attribute instead of the breakpoint — used to fade the playground controls
   in/out of the right rail as the playground scrolls into and out of view. */
.inview-fade-block {
  display: none;
  opacity: 0;
  transition:
    opacity 0.16s ease-out,
    display 0.16s allow-discrete;
}
.inview-fade-block[data-shown="true"] {
  display: block;
  opacity: 1;
  transition:
    opacity 0.24s ease-out,
    display 0.24s allow-discrete;
}
@starting-style {
  .inview-fade-block[data-shown="true"] {
    opacity: 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .inview-fade-block {
    transition: none;
  }
}

/* ── scroll-fade — vendored equivalent of shadcn's scroll-fade utility ──
   (https://ui.shadcn.com/docs/utils/scroll-fade). A mask-image dissolves the
   content toward the edges that have more to scroll. CSS scroll-driven
   animations make it scroll-aware: the true start/end edge stays crisp until
   you scroll past it. Browsers without scroll-driven animations fall back to a
   static fade on both edges. Apply to a scroll container, or to a ScrollArea
   via `viewportClassName`. */

@property --sf-start {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}
@property --sf-end {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

@keyframes sf-reveal-start {
  from {
    --sf-start: 1;
  }
  to {
    --sf-start: 0;
  }
}
@keyframes sf-reveal-end {
  from {
    --sf-end: 0;
  }
  to {
    --sf-end: 1;
  }
}

.scroll-fade,
.scroll-fade-x {
  --scroll-fade-size: 48px;
}

/* Static fallback: fade both edges. */
.scroll-fade {
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 var(--scroll-fade-size),
    #000 calc(100% - var(--scroll-fade-size)),
    transparent 100%
  );
  mask-image: linear-gradient(
    to bottom,
    transparent 0,
    #000 var(--scroll-fade-size),
    #000 calc(100% - var(--scroll-fade-size)),
    transparent 100%
  );
}
.scroll-fade-x {
  -webkit-mask-image: linear-gradient(
    to right,
    transparent 0,
    #000 var(--scroll-fade-size),
    #000 calc(100% - var(--scroll-fade-size)),
    transparent 100%
  );
  mask-image: linear-gradient(
    to right,
    transparent 0,
    #000 var(--scroll-fade-size),
    #000 calc(100% - var(--scroll-fade-size)),
    transparent 100%
  );
}

/* ── scroll-divider — a hairline marking a scroll edge ──────────────────────
   Pairs with .scroll-fade on a descendant viewport: where the fade dissolves
   content, this draws the line that says "the region continues". The line
   can't live inside the scroller — the fade's own mask would erase it at
   exactly the edge it belongs on — so it rides the parent's pseudo-elements
   and borrows the scroller's timeline by name. Browsers without scroll-driven
   timelines simply get the fade, as before. */
@keyframes sf-divider-start {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
@keyframes sf-divider-end {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

@supports (timeline-scope: --sf) {
  .scroll-divider {
    position: relative;
    timeline-scope: --sf-scroller;
    --scroll-divider-size: 48px;
  }
  .scroll-divider .scroll-fade {
    scroll-timeline-name: --sf-scroller;
  }
  .scroll-divider::before,
  .scroll-divider::after {
    content: "";
    position: absolute;
    /* Inset so the line can hug the content's own gutter rather than the
       panel edge — the sidebar's inset variant sets this to 8px. */
    left: var(--scroll-divider-inset, 0px);
    right: var(--scroll-divider-inset, 0px);
    height: 1px;
    background-color: var(--border);
    pointer-events: none;
    z-index: 1;
    opacity: 0;
  }
  /* Top edge: absent at rest, fading in as content passes above. */
  .scroll-divider::before {
    top: 0;
    animation: sf-divider-start both linear;
    animation-timeline: --sf-scroller;
    animation-range: 0 var(--scroll-divider-size);
  }
  /* A region flush with the panel's own top edge has nothing above for
     content to pass under, so the start line would read as a stray border. */
  .scroll-divider:first-child::before {
    content: none;
  }
  /* Bottom edge: present while content continues below, gone at the end. */
  .scroll-divider::after {
    bottom: 0;
    animation: sf-divider-end both linear;
    animation-timeline: --sf-scroller;
    animation-range: calc(100% - var(--scroll-divider-size)) 100%;
  }
}

/* Scroll-aware: the start/end edge stays solid until there's content past it. */
@supports (animation-timeline: scroll()) {
  .scroll-fade {
    mask-image: linear-gradient(
      to bottom,
      rgba(0, 0, 0, var(--sf-start)) 0,
      #000 var(--scroll-fade-size),
      #000 calc(100% - var(--scroll-fade-size)),
      rgba(0, 0, 0, var(--sf-end)) 100%
    );
    -webkit-mask-image: linear-gradient(
      to bottom,
      rgba(0, 0, 0, var(--sf-start)) 0,
      #000 var(--scroll-fade-size),
      #000 calc(100% - var(--scroll-fade-size)),
      rgba(0, 0, 0, var(--sf-end)) 100%
    );
    animation: sf-reveal-start both linear, sf-reveal-end both linear;
    animation-timeline: scroll(self), scroll(self);
    animation-range-start: 0, calc(100% - var(--scroll-fade-size));
    animation-range-end: var(--scroll-fade-size), 100%;
  }
  .scroll-fade-x {
    mask-image: linear-gradient(
      to right,
      rgba(0, 0, 0, var(--sf-start)) 0,
      #000 var(--scroll-fade-size),
      #000 calc(100% - var(--scroll-fade-size)),
      rgba(0, 0, 0, var(--sf-end)) 100%
    );
    -webkit-mask-image: linear-gradient(
      to right,
      rgba(0, 0, 0, var(--sf-start)) 0,
      #000 var(--scroll-fade-size),
      #000 calc(100% - var(--scroll-fade-size)),
      rgba(0, 0, 0, var(--sf-end)) 100%
    );
    animation: sf-reveal-start both linear, sf-reveal-end both linear;
    animation-timeline: scroll(self inline), scroll(self inline);
    animation-range-start: 0, calc(100% - var(--scroll-fade-size));
    animation-range-end: var(--scroll-fade-size), 100%;
  }
}

/* ============================================================================
   7. SITE-SPECIFIC (home page bento grid, shiki code blocks)
============================================================================ */

.bento-card-border {
  border-color: light-dark(
    var(--border),
    color-mix(in oklab, var(--border), transparent 40%)
  );
  box-shadow: 0 1px 2px light-dark(rgb(0 0 0 / 0.04), rgb(0 0 0 / 0.4));
}

.bento-card-border:hover {
  border-color: light-dark(
    color-mix(in oklab, var(--foreground), transparent 80%),
    var(--border)
  );
  box-shadow: 0 1px 2px light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.6));
}

/* Keyboard-active card (focus is inside it): a clearly stronger border than
   hover so it reads as "this is the container receiving shortcuts". Placed
   after :hover so it wins when a card is both hovered and focused. */
.bento-card-border:focus-within {
  border-color: light-dark(
    color-mix(in oklab, var(--foreground), transparent 35%),
    color-mix(in oklab, var(--foreground), transparent 30%)
  );
  box-shadow: 0 1px 3px light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.7));
}

@media (min-width: 768px) {
  .bento-grid {
    grid-auto-flow: dense;
    grid-auto-rows: 300px;
  }
}

/* Shiki dual-theme: the light/dark colors are inlined per token by shiki;
   light-dark() picks the active one. */
.shiki,
.shiki span {
  color: light-dark(var(--shiki-light), var(--shiki-dark)) !important;
  background-color: transparent !important;
}
author/package.json
파일 저장

{
  "name": "fluid-functionalism",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "eslint .",
    "test": "vitest run",
    "registry:build": "shadcn build && node scripts/postbuild-registry.mjs"
  },
  "dependencies": {
    "@base-ui/react": "^1.4.1",
    "@hugeicons/core-free-icons": "^4.1.1",
    "@hugeicons/react": "^1.1.6",
    "@phosphor-icons/react": "^2.1.10",
    "@radix-ui/react-accordion": "^1.2.12",
    "@radix-ui/react-checkbox": "^1.3.3",
    "@radix-ui/react-collapsible": "^1.1.15",
    "@radix-ui/react-dialog": "^1.1.15",
    "@radix-ui/react-dropdown-menu": "^2.1.16",
    "@radix-ui/react-label": "^2.1.8",
    "@radix-ui/react-popover": "^1.1.23",
    "@radix-ui/react-radio-group": "^1.3.8",
    "@radix-ui/react-scroll-area": "^1.2.11",
    "@radix-ui/react-select": "^2.2.6",
    "@radix-ui/react-slider": "^1.3.6",
    "@radix-ui/react-slot": "^1.2.4",
    "@radix-ui/react-switch": "^1.2.6",
    "@radix-ui/react-tabs": "^1.1.13",
    "@radix-ui/react-tooltip": "^1.2.8",
    "@tabler/icons-react": "^3.41.1",
    "@untitledui/icons": "^0.0.22",
    "@vercel/analytics": "^1.6.1",
    "@vercel/speed-insights": "^1.3.1",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "dialkit": "^1.1.0",
    "framer-motion": "^12.34.0",
    "lucide-react": "^0.564.0",
    "motion": "^12.42.2",
    "next": "^15.5.9",
    "pdfjs-dist": "^5.7.284",
    "react": "^19.2.0",
    "react-dom": "^19.2.0",
    "shadcn": "^3.0.0",
    "shiki": "^3.22.0",
    "sonner": "^2.0.8",
    "tailwind-merge": "^3.4.0"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3.3.1",
    "@tailwindcss/postcss": "^4.1.18",
    "@testing-library/dom": "^10.4.1",
    "@testing-library/react": "^16.3.3",
    "@types/node": "^20.19.9",
    "@types/react": "^19.2.14",
    "@types/react-dom": "^19.2.3",
    "eslint": "^9.39.1",
    "eslint-config-next": "^15.3.1",
    "jsdom": "^29.1.1",
    "tailwindcss": "^4.1.18",
    "typescript": "^5.9.3",
    "vitest": "^4.1.10"
  }
}
author-variant-adapter.tsx실행 안내·자료
파일 저장

"use client";
import { useState } from "react";
import { CheckboxGroup, CheckboxItem } from "@/registry/radix/checkbox-group";
const items = ["작업 알림","검토 요청","일정 변경","주간 소식","월간 회고"];
export default function AuthorCheckboxVariant() {
  const [checked, setChecked] = useState<Set<number>>(new Set([]));
  return <CheckboxGroup className="w-80 max-w-full" checkedIndices={checked}>{items.map((label, index) => <CheckboxItem key={label} index={index} label={label} checked={checked.has(index)} onToggle={() => setChecked(previous => { const next = new Set(previous); if (next.has(index)) next.delete(index); else next.add(index); return next; })} />)}</CheckboxGroup>;
}
runtime/fonts/InterVariable.ttf실행 안내·자료

큰 소스 파일과 이미지·모델 등의 자료는 파일을 저장해 확인할 수 있어요.

runtime/fonts/OFL.txt실행 안내·자료
파일 저장

Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL

-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
runtime/fonts/provenance.json실행 안내·자료
파일 저장

{
  "font": "InterVariable.ttf",
  "sourceUrl": "https://raw.githubusercontent.com/mickadesign/fluid-functionalism/b3587bdbd83fc66c2a6aae3817ffb856cb09260b/public/fonts/InterVariable.ttf",
  "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
  "sourceGitBlob": "2d4b47093aab700cf180eb344d34f8052e7c8108",
  "sha256": "746431e950fd28d29b0189d708d4a5852a8458edb3184387eadcee9e5e34676c",
  "license": "OFL-1.1",
  "licenseSource": "https://raw.githubusercontent.com/rsms/inter/353b61b9f4430d5f420d56605a6e7993e0941470/LICENSE.txt",
  "licenseRevision": "353b61b9f4430d5f420d56605a6e7993e0941470",
  "licenseSha256": "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a",
  "modification": "none"
}
runtime/font-adapter.css실행 안내·자료
파일 저장

/* Original author font, unmodified; URL is embedded for the offline host. */
@font-face{font-family:"Inter";src:url("./fonts/InterVariable.ttf") format("truetype");font-weight:100 900;font-display:swap;}
html,body{font-family:var(--font-sans);}
runtime/theme-adapter.js실행 안내·자료
파일 저장

document.documentElement.classList.toggle("light",new URLSearchParams(location.search).get("theme")!=="dark");
Usage.tsx실행 안내·자료
파일 저장

// Local host for the unchanged exact baseline demonstration.
import OriginalDemo from "./author-variant-adapter.tsx";
export default function Demo() { return <><OriginalDemo /></>; }
runtime/author-mount.tsx실행 안내·자료
파일 저장

/** Local sandbox host. Ready is a mount observation, never a verification result. */
import React, { Component, useEffect } from 'react';
import { createRoot } from 'react-dom/client';

declare global {
  interface Window {
    __STYLEGALLERY_PREVIEW__: { id: string; status: string; errors: string[] };
  }
}

export function mount(Demo: React.ComponentType, id: string) {
  const state = window.__STYLEGALLERY_PREVIEW__ = { id, status: 'loading', errors: [] as string[] };
  const send = (message: object) => parent.postMessage({ ...message, id }, '*');
  const report = (error: unknown) => {
    const message = error instanceof Error ? error.message : String(error);
    if (!state.errors.includes(message)) state.errors.push(message);
    state.status = 'error';
    document.body.dataset.previewStatus = 'error';
    send({ type: 'sg-preview-error', message });
  };
  window.addEventListener('error', (event) => report(event.error ?? event.message));
  window.addEventListener('unhandledrejection', (event) => report(event.reason));
  window.addEventListener('securitypolicyviolation', (event) => report(`Blocked by preview policy: ${event.violatedDirective}`));
  const theme = new URLSearchParams(location.search).get('theme') === 'dark' ? 'dark' : 'light';
  document.documentElement.classList.toggle('dark', theme === 'dark');
  document.documentElement.dataset.theme = theme;
  // Keep original source bytes while ensuring imported attribution/navigation remains local text.
  const removeDestinations = () => document.querySelectorAll('a[href]').forEach((link) => {
    link.removeAttribute('href');
    link.removeAttribute('target');
  });
  document.addEventListener('click', (event) => {
    if ((event.target as Element)?.closest?.('a')) event.preventDefault();
  }, true);
  document.addEventListener('submit', (event) => event.preventDefault());
  new MutationObserver(removeDestinations).observe(document.getElementById('root')!, { childList: true, subtree: true });
  const observe = () => {
    const root = document.getElementById('root')!;
    const elements = Array.from(root.querySelectorAll('*'));
    const rect = root.getBoundingClientRect();
    const diagnostics = {
      textLength: (root.textContent ?? '').trim().length,
      elementCount: elements.length,
      visibleElementCount: elements.filter((element) => {
        const bounds = element.getBoundingClientRect();
        const style = getComputedStyle(element);
        return bounds.width > 0 && bounds.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) !== 0;
      }).length,
      images: [],
      canvases: [],
      rootRect: { width: rect.width, height: rect.height },
      documentWidth: document.documentElement.scrollWidth,
      viewportWidth: innerWidth,
    };
    send({ type: 'sg-preview-observation', diagnostics });
  };
  class Boundary extends Component<{ children: React.ReactNode }, { error: string | null }> {
    state = { error: null as string | null };
    static getDerivedStateFromError(error: Error) { return { error: error.message }; }
    componentDidCatch(error: Error) { report(error); }
    render() { return this.state.error ? <p role="alert">This preview could not render: {this.state.error}</p> : this.props.children; }
  }
  function Ready() {
    useEffect(() => {
      const preference = matchMedia('(prefers-reduced-motion: reduce)');
      const syncSVG = () => document.querySelectorAll('svg').forEach((svg) => {
        if (preference.matches) svg.pauseAnimations?.();
        else svg.unpauseAnimations?.();
      });
      syncSVG();
      preference.addEventListener('change', syncSVG);
      let second = 0;
      let delayed = 0;
      const first = requestAnimationFrame(() => { second = requestAnimationFrame(() => {
        if (state.status !== 'error') {
          state.status = 'mounted';
          document.body.dataset.previewStatus = 'mounted';
          send({ type: 'sg-preview-ready' });
          observe();
          delayed = window.setTimeout(observe, 700);
        }
      }); });
      return () => {
        cancelAnimationFrame(first);
        cancelAnimationFrame(second);
        clearTimeout(delayed);
        preference.removeEventListener('change', syncSVG);
      };
    }, []);
    return <Demo />;
  }
  createRoot(document.getElementById('root')!).render(<Boundary><Ready /></Boundary>);
}
runtime/author-styles.css실행 안내·자료
파일 저장

@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--background);
  --color-card-foreground: var(--foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--muted);
  --color-secondary-foreground: var(--foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--muted);
  --color-accent-foreground: var(--foreground);
  --color-popover: var(--background);
  --color-popover-foreground: var(--foreground);
  --color-destructive: #dc2626;
  --color-destructive-foreground: #ffffff;
  --color-input: var(--border);
  --color-border: var(--border);
  --color-ring: var(--foreground);
  --radius-lg: 0.5rem;
  --radius-md: 0.375rem;
  --radius-sm: 0.25rem;
}

:root {
  --background: #ffffff;
  --border: #d4d4d8;
  --foreground: #18181b;
  --muted: #f4f4f5;
  --muted-foreground: #71717a;
  --primary: #18181b;
  --primary-foreground: #fafafa;
  background: var(--background);
  color: var(--foreground);
  color-scheme: light;
  font-family: Arial, sans-serif;
}

.dark {
  --background: #09090b;
  --border: #3f3f46;
  --foreground: #fafafa;
  --muted: #27272a;
  --muted-foreground: #a1a1aa;
  --primary: #fafafa;
  --primary-foreground: #18181b;
  color-scheme: dark;
}

body {
  margin: 0;
  min-height: 100vh;
}

#root {
  align-items: center;
  box-sizing: border-box;
  display: flex;
  justify-content: center;
  min-height: 100vh;
  padding: 24px;
  width: 100%;
}

#root > * {
  max-width: 100%;
}

noscript {
  display: block;
  padding: 24px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-play-state: paused !important;
  }
}
runtime/applied-theme.css실행 안내·자료
파일 저장

:root {
  color-scheme: light dark;
}

:root,
.light,
.dark {
  /* ── Surfaces (8-level ladder) ──
     Light: 2 color steps (floor, sunken) then flat #FFFFFF; shadow does the
     work. Dark: additive white-opacity ladder over #171717. Each surface
     pairs 1:1 with a shadow recipe (--shadow-1 .. --shadow-8). */
  --surface-1: light-dark(#FAFAFA, #171717);
  --surface-2: light-dark(#FCFCFC, #1E1E1E);
  --surface-3: light-dark(#FFFFFF, #252525);
  --surface-4: light-dark(#FFFFFF, #2C2C2C);
  --surface-5: light-dark(#FFFFFF, #333333);
  --surface-6: light-dark(#FFFFFF, #3A3A3A);
  --surface-7: light-dark(#FFFFFF, #414141);
  --surface-8: light-dark(#FFFFFF, #484848);

  /* ── Semantic tokens ── */
  --background: var(--surface-1);
  --foreground: light-dark(#171717, #F5F5F5);
  --card: var(--surface-3);
  --card-foreground: light-dark(#171717, #F5F5F5);
  /* Light `muted` decouples from surface-2: the light surface ladder is so
     compressed (#FAFAFA → #FCFCFC → #FFFFFF) that bg-muted would read as
     near-white, making anything sitting on it (Tabs track, etc.) blend
     together. A slightly more grey value gives muted real semantic presence.
     Dark keeps the ladder value (#1E1E1E = surface-2). */
  --muted: light-dark(#F4F4F5, #1E1E1E);
  --muted-foreground: light-dark(#737373, #A3A3A3);

  /* ── Interactive states ── */
  --accent: light-dark(#E5E5E5, #525252);
  --accent-foreground: light-dark(#171717, #F5F5F5);
  --selected: light-dark(#D4D4D4, #525252);

  /* ── Borders ──
     --border mixes from --foreground, so it tracks the theme automatically —
     including inside nested forced scopes, where light-dark() re-resolves
     --foreground before the mix happens. */
  --border: color-mix(in oklab, var(--foreground) 12%, transparent);
  --ring: light-dark(#E5E5E5, #404040);
  --input: light-dark(#E5E5E5, #404040);

  /* ── Error / Destructive ── */
  --destructive: light-dark(#EF4444, #F87171);
  --destructive-light: light-dark(#FEF2F2, #450A0A);

  /* ── Focus indicator ──
     Every component focus ring reads var(--focus-ring, #6B97FF), so this is
     the single theming point. The literal fallback keeps registry components
     self-contained when installed into projects without the variable. */
  --focus-ring: #6B97FF;

  /* ── Alpha checkerboard (color-picker swatches) ── */
  --checker-a: light-dark(#bbbbbb, #1f1f1f);
  --checker-b: light-dark(#ffffff, #2a2a2a);

  /* ── Surface-relative interactive overlays (work on any elevation) ──
     --overlay is the *tint direction* triplet — black for light surfaces,
     white for dark. Components consume it as rgb(var(--overlay) / α), so it
     stays an RGB triplet and switches per theme in section 2. --hover and
     --active are the pre-mixed conveniences. */
  --overlay: 0 0 0;
  --hover: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.06));
  --active: light-dark(rgb(0 0 0 / 0.07), rgb(255 255 255 / 0.1));

  /* ── Shadow recipes ──
     Light: additive stacked drops, halving offsets (1/3/6/12/24/48/96 px).
     Dark: inset highlight + inset ring + stacked drops (per CodePen).
     Structurally different, so each ladder is written out once here and the
     active --shadow-N is selected per theme in section 2.
     --shadow-color is intentionally static: it is also consumed directly by
     components (input-message edge drop) and has never varied by theme. */
  --shadow-color: rgb(0 0 0 / 0.06);
  --shadow-light-1: 0 0 0 1px var(--shadow-color);
  --shadow-light-2: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color);
  --shadow-light-3: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color);
  --shadow-light-4: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color);
  --shadow-light-5: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color);
  --shadow-light-6: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color), 0 24px 24px -12px var(--shadow-color);
  --shadow-light-7: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color), 0 24px 24px -12px var(--shadow-color), 0 48px 48px -24px var(--shadow-color);
  --shadow-light-8: 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color), 0 6px 6px -3px var(--shadow-color), 0 12px 12px -6px var(--shadow-color), 0 24px 24px -12px var(--shadow-color), 0 48px 48px -24px var(--shadow-color), 0 96px 96px -48px var(--shadow-color);
  /* Surface-2/3 with the hairline moved INSIDE the box — for cards whose
     border must stay within their own edge (the sidebar's stacked footer
     callouts). Dark's recipes already draw their ring inset, so the dark
     scopes alias --shadow-dark-2/3 directly. */
  --shadow-light-2-inset: inset 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color);
  --shadow-light-3-inset: inset 0 0 0 1px var(--shadow-color), 0 1px 1px -0.5px var(--shadow-color), 0 3px 3px -1.5px var(--shadow-color);

  --dm-hi-base: rgba(255,255,255,0.01);
  --dm-hi-mid: rgba(255,255,255,0.02);
  --dm-hi-high: rgba(255,255,255,0.04);
  --dm-hi-peak: rgba(255,255,255,0.06);
  --dm-ring-base: rgba(255,255,255,0.02);
  --dm-ring-mid: rgba(255,255,255,0.04);
  --dm-ring-high: rgba(255,255,255,0.06);
  --dm-drop: rgba(0,0,0,0.18);
  --shadow-dark-1: inset 0 0 0 1px var(--dm-ring-base);
  --shadow-dark-2: inset 0 1px 0 0 var(--dm-hi-base), inset 0 0 0 1px var(--dm-ring-base), 0 1px 1px -0.5px var(--dm-drop);
  --shadow-dark-3: inset 0 1px 0 0 var(--dm-hi-mid), inset 0 0 0 1px var(--dm-ring-base), 0 0 0 1px rgba(0,0,0,0.12), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop);
  --shadow-dark-4: inset 0 1px 0 0 var(--dm-hi-mid), inset 0 0 0 1px var(--dm-ring-mid), 0 0 0 1px rgba(0,0,0,0.14), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop);
  --shadow-dark-5: inset 0 1px 0 0 var(--dm-hi-high), inset 0 0 0 1px var(--dm-ring-mid), 0 0 0 1px rgba(0,0,0,0.16), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop);
  --shadow-dark-6: inset 0 1px 0 0 var(--dm-hi-high), inset 0 0 0 1px var(--dm-ring-high), 0 0 0 1px rgba(0,0,0,0.18), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop), 0 24px 24px -12px var(--dm-drop);
  --shadow-dark-7: inset 0 1px 0 0 var(--dm-hi-peak), inset 0 0 0 1px var(--dm-ring-high), 0 0 0 1px rgba(0,0,0,0.20), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop), 0 24px 24px -12px var(--dm-drop), 0 48px 48px -24px var(--dm-drop);
  --shadow-dark-8: inset 0 1px 0 0 var(--dm-hi-peak), inset 0 0 0 1px var(--dm-ring-high), 0 0 0 1px rgba(0,0,0,0.22), 0 1px 1px -0.5px var(--dm-drop), 0 3px 3px -1.5px var(--dm-drop), 0 6px 6px -3px var(--dm-drop), 0 12px 12px -6px var(--dm-drop), 0 24px 24px -12px var(--dm-drop), 0 48px 48px -24px var(--dm-drop), 0 96px 96px -48px var(--dm-drop);

  --shadow-1: var(--shadow-light-1);
  --shadow-2: var(--shadow-light-2);
  --shadow-3: var(--shadow-light-3);
  --shadow-4: var(--shadow-light-4);
  --shadow-5: var(--shadow-light-5);
  --shadow-6: var(--shadow-light-6);
  --shadow-7: var(--shadow-light-7);
  --shadow-8: var(--shadow-light-8);
  --shadow-2-inset: var(--shadow-light-2-inset);
  --shadow-3-inset: var(--shadow-light-3-inset);
}

/* ============================================================================
   2. THEME SWITCHING
   ----------------------------------------------------------------------------
   `color-scheme` is what flips every light-dark() token above; the classes
   work on <html> (ThemeProvider) and on any subtree (forced-theme previews).
   The only values that need manual switching are the --overlay triplet and
   the shadow recipe selection — see the section-1 note.
============================================================================ */

@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --overlay: 255 255 255;
    --shadow-1: var(--shadow-dark-1);
    --shadow-2: var(--shadow-dark-2);
    --shadow-3: var(--shadow-dark-3);
    --shadow-4: var(--shadow-dark-4);
    --shadow-5: var(--shadow-dark-5);
    --shadow-6: var(--shadow-dark-6);
    --shadow-7: var(--shadow-dark-7);
    --shadow-8: var(--shadow-dark-8);
    --shadow-2-inset: var(--shadow-dark-2);
    --shadow-3-inset: var(--shadow-dark-3);
  }
}

.light {
  color-scheme: light;
  --overlay: 0 0 0;
  --shadow-2-inset: var(--shadow-light-2-inset);
  --shadow-3-inset: var(--shadow-light-3-inset);
  --shadow-1: var(--shadow-light-1);
  --shadow-2: var(--shadow-light-2);
  --shadow-3: var(--shadow-light-3);
  --shadow-4: var(--shadow-light-4);
  --shadow-5: var(--shadow-light-5);
  --shadow-6: var(--shadow-light-6);
  --shadow-7: var(--shadow-light-7);
  --shadow-8: var(--shadow-light-8);
}

.dark {
  color-scheme: dark;
  --overlay: 255 255 255;
  --shadow-2-inset: var(--shadow-dark-2);
  --shadow-3-inset: var(--shadow-dark-3);
  --shadow-1: var(--shadow-dark-1);
  --shadow-2: var(--shadow-dark-2);
  --shadow-3: var(--shadow-dark-3);
  --shadow-4: var(--shadow-dark-4);
  --shadow-5: var(--shadow-dark-5);
  --shadow-6: var(--shadow-dark-6);
  --shadow-7: var(--shadow-dark-7);
  --shadow-8: var(--shadow-dark-8);
}

/* ============================================================================
   3. TAILWIND THEME MAPPING
============================================================================ */

@theme inline {
  --font-sans: "Inter", system-ui, sans-serif;
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-selected: var(--selected);
  --color-border: var(--border);
  --color-ring: var(--ring);
  --color-input: var(--input);
  --color-destructive: var(--destructive);
  --color-destructive-light: var(--destructive-light);
  --color-hover: var(--hover);
  --color-active: var(--active);

  /* Surface ladder — bg-surface-1..8 utilities */
  --color-surface-1: var(--surface-1);
  --color-surface-2: var(--surface-2);
  --color-surface-3: var(--surface-3);
  --color-surface-4: var(--surface-4);
  --color-surface-5: var(--surface-5);
  --color-surface-6: var(--surface-6);
  --color-surface-7: var(--surface-7);
  --color-surface-8: var(--surface-8);

  /* Shadow ladder — shadow-surface-1..8 utilities. Monotonic with surfaces. */
  --shadow-surface-1: var(--shadow-1);
  --shadow-surface-2: var(--shadow-2);
  --shadow-surface-3: var(--shadow-3);
  --shadow-surface-4: var(--shadow-4);
  --shadow-surface-5: var(--shadow-5);
  --shadow-surface-6: var(--shadow-6);
  --shadow-surface-7: var(--shadow-7);
  --shadow-surface-8: var(--shadow-8);

  /* Reserved for /compare shadcn theme only — DO NOT use these utilities
     (bg-primary, bg-secondary, bg-popover, *-foreground variants) in Fluid
     Functionalism components. The variables below are undefined at :root and
     only get values inside `.shadcn-theme` (see app/compare/shadcn-theme.css).
     ESLint enforces this. */
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-destructive-foreground: var(--destructive-foreground);
}

THIRD-PARTY-LICENSES.txt실행 안내·자료
파일 저장

react-fluid 19.2.3 — LICENSE

MIT License

Copyright (c) Meta Platforms, Inc. and affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

framer-motion-fluid 12.34.0 — LICENSE.md

The MIT License (MIT)

Copyright (c) 2018 Framer B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

motion-utils 12.39.0 — LICENSE.md

The MIT License (MIT)

Copyright (c) 2024 [Motion](https://motion.dev) B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

motion-dom 12.43.0 — LICENSE.md

The MIT License (MIT)

Copyright (c) 2024 [Motion](https://motion.dev) B.V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@emotion/memoize 0.9.0 — LICENSE

MIT License

Copyright (c) Emotion team and other contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@emotion/is-prop-valid 1.4.0 — LICENSE

MIT License

Copyright (c) Emotion team and other contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-compose-refs 1.1.2 — MIT notice from the same declared Radix primitives repository, included in @radix-ui/react-slot 1.2.3

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-context 1.1.2 — MIT notice from the same declared Radix primitives repository, included in @radix-ui/react-slot 1.2.3

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/primitive 1.1.3 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-use-layout-effect 1.1.1 — MIT notice from the same declared Radix primitives repository, included in @radix-ui/react-slot 1.2.3

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-use-effect-event 0.0.2 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-use-controllable-state 1.2.2 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-use-previous 1.1.1 — MIT notice from the same declared Radix primitives repository, included in @radix-ui/react-slot 1.2.3

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-use-size 1.1.1 — MIT notice from the same declared Radix primitives repository, included in @radix-ui/react-slot 1.2.3

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-presence 1.1.5 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

react-dom-fluid 19.2.3 — LICENSE

MIT License

Copyright (c) Meta Platforms, Inc. and affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-slot 1.2.3 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

@radix-ui/react-primitive 2.1.3 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

radix-checkbox-fluid 1.3.3 — LICENSE

MIT License

Copyright (c) 2022 WorkOS

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

clsx 2.1.1 — license

MIT License

Copyright (c) Luke Edwards <luke.edwards05@gmail.com> (lukeed.com)

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


========================================================================

tailwind-merge-fluid 3.4.0 — LICENSE.md

MIT License

Copyright (c) 2021 Dany Castillo

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

scheduler 0.27.0 — LICENSE

MIT License

Copyright (c) Meta Platforms, Inc. and affiliates.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

tailwindcss 4.1.13 — LICENSE

MIT License

Copyright (c) Tailwind Labs, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


========================================================================

Inter b3587bdbd83fc66c2a6aae3817ffb856cb09260b — unmodified author font; SIL Open Font License 1.1

Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL

-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------

PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.

DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.

"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.

PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.

2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.

3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.

4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.

5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.

TERMINATION
This license becomes null and void if any of the above conditions are
not met.

DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
provenance.json실행 안내·자료
파일 저장

{
  "id": "21st-0286dc068e8b",
  "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
  "demoIdentity": {
    "file": "author-variant-adapter.tsx",
    "export": "default",
    "source": "author-official-variant-adapter"
  },
  "fidelity": {
    "preserved": "정확히 식별한 작성자의 MIT 구현과 공식 예제; 원래 CDN demo의 license 표기는 별도 유지",
    "original_demo_license": "not-specified",
    "dependency_revision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
    "observed_differences": [
      "원래 CDN demo의 license는 비어 있어 재사용하지 않는다. 작성자 MIT 구현과 MIT 문서 예제 구성을 사용하며 문구는 새로 작성했다.",
      "원래 variant의 항목 수와 초기 선택 인덱스는 공개 demo에서 확인했다. default=5개/[0,2], none=5개/[], all=4개/[0,1,2,3].",
      "공식 현재 문서는 기본 Base UI 화면을 보이지만 bare registry와 원본 path는 Radix 버전이다. 설치 문서가 두 flavor의 같은 API를 명시한다.",
      "폭은 독립 adapter에서 w-80 max-w-full로 제한한다. 기존 CDN 내부 component와 바이트 동일성은 주장하지 않는다."
    ],
    "mapping": []
  },
  "acquisitionLimitations": [
    "CDN 데모는 SHA256으로, 작성자 원본과 의존 파일은 Git 커밋 및 blob SHA로 고정했다.",
    "현재 작성자 revision의 의존 파일이 과거 21st 내부 구현과 바이트 단위로 같다는 주장은 하지 않는다.",
    "다운로드한 코드를 실행하지 않았으며 브라우저·상호작용 검증은 별도 단계다.",
    "Inter variable font의 wght/opsz 축은 원본의 글자 폭 보정에 필요하다. 저장소 README의 로딩 요구와 globals.css를 전달하며 폰트 파일은 별도 확보하지 않았다."
  ],
  "files": [
    {
      "file": "author-example.tsx",
      "kind": "implementation",
      "sha256": "6c8d58703f3686d77ee894955645ac5d17d211133e4fc5c4f01188b494890981",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "LICENSE",
      "kind": "license",
      "sha256": "3dc44f653f431ac7920c70f6f064a55a8852e6b7544499bb8506dfbfd351d2e1",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "upstream/app/docs/checkbox-group/page.tsx",
      "kind": "upstream-implementation",
      "sha256": "2a353b19490065c2a40b3870f1046f8fd9f951b9be3f503ce506f22f57b77eff",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "upstream/public/r/checkbox-group.json",
      "kind": "upstream-implementation",
      "sha256": "a5e11be2af9fc4a5ca7d1a6980759c33df2481ff68333d1822111673a244e54f",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "upstream/public/r/tokens.json",
      "kind": "upstream-implementation",
      "sha256": "f193a0b9a135e64c346f2721a65fa6c9c22f9c2dec81ab79012ee9d83f062679",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "upstream/README.md",
      "kind": "upstream-implementation",
      "sha256": "dd9cd82cd9de52f1ba2c5a0318e4c37f53ad1d6c71341d90a8b1cdc96c3356d4",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/lib/utils.ts",
      "kind": "implementation-dependency",
      "sha256": "24c4753aba781368a7984feb5d1e4baa8858b0e5baa895fc9804f80372511190",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/lib/springs.ts",
      "kind": "implementation-dependency",
      "sha256": "7676f071763de34ed84eb19ae5cc21b09a49e5f4c9ae5d00b2cbe62d3364c6bc",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/lib/font-weight.ts",
      "kind": "implementation-dependency",
      "sha256": "7c14ccb6887ef5fc5d26f9f62576fd9943dbae0cbd35940a73edd1b108acdecf",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/hooks/use-fluid-hover.ts",
      "kind": "implementation-dependency",
      "sha256": "27890ada1c3e799b583265f00775ead635aefb438d6ca1d260801f6ed9bfac7d",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/hooks/use-merge-split.ts",
      "kind": "implementation-dependency",
      "sha256": "f3e017d6246ad63f1b0f6757d01d69834406c3e246a448ed4cfd6415aea51f81",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/lib/shape-context.tsx",
      "kind": "implementation-dependency",
      "sha256": "e1416603cdfe6df73ddbaa52499ae9e7a9f772161227f38116bc650224186b61",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/lib/size-context.tsx",
      "kind": "implementation-dependency",
      "sha256": "33a1b9c41112083529db8db6e9e6efc22802f4796ac4d167a9f004d559f825a8",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/components/ui/fluid-hover-highlight.tsx",
      "kind": "implementation-dependency",
      "sha256": "7dbfc3661e470f717ed2b71013209851355dd3c7a2f06869f0a0e96e6c3bf62d",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/lib/utils.ts",
      "kind": "implementation-dependency",
      "sha256": "267863ba7a82f11320f207a30735bd19653e0207e1cfd39fde32958ba8c861ce",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/lib/springs.ts",
      "kind": "implementation-dependency",
      "sha256": "b5d1c7c55ba4b865ac1156effc9ae41e95e4fdd08b42cefcda2854e93b817057",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/lib/font-weight.ts",
      "kind": "implementation-dependency",
      "sha256": "3d249aa3cf6e7e8bf2830e8367659abededc3ed4d430670537a1bd065953a14a",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/hooks/use-fluid-hover.ts",
      "kind": "implementation-dependency",
      "sha256": "dba8381f5b50c9120a3eba4e86a75ec252aa4ac401c6afbf35922fe0ff47d61d",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/hooks/use-merge-split.tsx",
      "kind": "implementation-dependency",
      "sha256": "5dd15075c9da5590d21bb57b34a293bf9ca2d2e0847b8645c8a6bf98686c3517",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/lib/shape-context.tsx",
      "kind": "implementation-dependency",
      "sha256": "f50445a1ef9178effe6a26a500f0ed13e85643321059c04ca9e2d248193795f6",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/lib/size-context.tsx",
      "kind": "implementation-dependency",
      "sha256": "cca35a72e37b121a3a5701c95fde8470626ceb74bcf55fe040b99afd06f0e21f",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/registry/default/fluid-hover-highlight.tsx",
      "kind": "implementation-dependency",
      "sha256": "86d67bbd28a843d7bad433cf499d786476a1f11d7ef74afd4bafc55279304dfc",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/app/globals.css",
      "kind": "theme-source",
      "sha256": "a4e543fc2bbfcaaaf064ae29d26fbab9ac1f1be600b8a814be82974989a64d54",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author/package.json",
      "kind": "dependency-manifest",
      "sha256": "d6ba3dea6d77bdf7c2212ad571f9da151e17de106c7029de99ec2bbbd30e0c3a",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    },
    {
      "file": "author-variant-adapter.tsx",
      "kind": "example-adapter",
      "sha256": "b9c16791faa17bc955ed2840c6527a008aebd2f33107dc11cf495fd1f9d55f02",
      "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
      "license": "MIT"
    }
  ],
  "importMap": {
    "@/lib/utils": "author/lib/utils.ts",
    "@/lib/springs": "author/lib/springs.ts",
    "@/lib/font-weight": "author/lib/font-weight.ts",
    "@/hooks/use-fluid-hover": "author/hooks/use-fluid-hover.ts",
    "@/hooks/use-merge-split": "author/hooks/use-merge-split.ts",
    "@/lib/shape-context": "author/lib/shape-context.tsx",
    "@/lib/size-context": "author/lib/size-context.tsx",
    "@/components/ui/fluid-hover-highlight": "author/components/ui/fluid-hover-highlight.tsx",
    "@/registry/default/lib/utils": "author/registry/default/lib/utils.ts",
    "@/registry/default/lib/springs": "author/registry/default/lib/springs.ts",
    "@/registry/default/lib/font-weight": "author/registry/default/lib/font-weight.ts",
    "@/registry/default/hooks/use-fluid-hover": "author/registry/default/hooks/use-fluid-hover.ts",
    "@/registry/default/hooks/use-merge-split": "author/registry/default/hooks/use-merge-split.tsx",
    "@/registry/default/lib/shape-context": "author/registry/default/lib/shape-context.tsx",
    "@/registry/default/lib/size-context": "author/registry/default/lib/size-context.tsx",
    "@/registry/default/fluid-hover-highlight": "author/registry/default/fluid-hover-highlight.tsx",
    "@/registry/radix/checkbox-group": "author-example.tsx"
  },
  "declaredDependencies": {
    "@radix-ui/react-checkbox": "^1.3.3",
    "clsx": "^2.1.1",
    "framer-motion": "^12.34.0",
    "react": "^19.2.0",
    "tailwind-merge": "^3.4.0"
  },
  "runtimeDependencies": {
    "react-fluid": "19.2.3",
    "framer-motion-fluid": "12.34.0",
    "motion-utils": "12.39.0",
    "motion-dom": "12.43.0",
    "@emotion/memoize": "0.9.0",
    "@emotion/is-prop-valid": "1.4.0",
    "@radix-ui/react-compose-refs": "1.1.2",
    "@radix-ui/react-context": "1.1.2",
    "@radix-ui/primitive": "1.1.3",
    "@radix-ui/react-use-layout-effect": "1.1.1",
    "@radix-ui/react-use-effect-event": "0.0.2",
    "@radix-ui/react-use-controllable-state": "1.2.2",
    "@radix-ui/react-use-previous": "1.1.1",
    "@radix-ui/react-use-size": "1.1.1",
    "@radix-ui/react-presence": "1.1.5",
    "react-dom-fluid": "19.2.3",
    "@radix-ui/react-slot": "1.2.3",
    "@radix-ui/react-primitive": "2.1.3",
    "radix-checkbox-fluid": "1.3.3",
    "clsx": "2.1.1",
    "tailwind-merge-fluid": "3.4.0",
    "scheduler": "0.27.0",
    "tailwindcss": "4.1.13"
  },
  "packageAliases": {
    "react": "react-fluid",
    "react-dom": "react-dom-fluid",
    "framer-motion": "framer-motion-fluid",
    "@radix-ui/react-checkbox": "radix-checkbox-fluid",
    "tailwind-merge": "tailwind-merge-fluid"
  },
  "assets": [],
  "assetAdaptations": [],
  "font": {
    "font": "InterVariable.ttf",
    "sourceUrl": "https://raw.githubusercontent.com/mickadesign/fluid-functionalism/b3587bdbd83fc66c2a6aae3817ffb856cb09260b/public/fonts/InterVariable.ttf",
    "sourceRevision": "b3587bdbd83fc66c2a6aae3817ffb856cb09260b",
    "sourceGitBlob": "2d4b47093aab700cf180eb344d34f8052e7c8108",
    "sha256": "746431e950fd28d29b0189d708d4a5852a8458edb3184387eadcee9e5e34676c",
    "license": "OFL-1.1",
    "licenseSource": "https://raw.githubusercontent.com/rsms/inter/353b61b9f4430d5f420d56605a6e7993e0941470/LICENSE.txt",
    "licenseRevision": "353b61b9f4430d5f420d56605a6e7993e0941470",
    "licenseSha256": "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a",
    "modification": "none"
  },
  "adaptations": [
    "The runtime uses the author’s MIT CheckboxGroup/CheckboxItem implementation at the recorded Git revision and the official documentation composition. The historical CDN demo license declaration was empty; its code is not redistributed or used as the entry.",
    "author-variant-adapter.tsx supplies newly written Korean labels, the recorded item count and initial selected indices, and w-80 max-w-full. It is an explicit composition adapter, not a byte-identical copy of the historical CDN demo.",
    "The original source imports the Radix flavor. Its complete original re-export closure is retained; the current documentation-site flavor is not used to replace this component.",
    "The theme slice contains original design tokens, light/dark switching, and Tailwind mappings. Documentation scrollbars, page transitions, bento styles, and the original absolute font URL are outside the selected slice. The font is a separately recorded host asset.",
    "The host must apply the matching literal light or dark class as well as its theme selection, so light-dark() tokens follow an explicit light preview even when the OS is dark. Use the original --font-sans family stack for the preview body.",
    "Inter with both wght and opsz axes is required to reproduce the source label-weight treatment. The unchanged font from the same author Git revision and its OFL notice were acquired separately under runtime/fonts, with provenance.json recording their sources and hashes. The acquired source pack is unchanged. Browser loading, Korean glyph fallback, and width compensation have not been verified.",
    "The adapter supplies no ShapeProvider or size prop. Source fallbacks select rounded corners and the default 36px control size. No account, network request, persistence, or bulk select-all control is added.",
    "This variant has 5 items and initial selected indices []. Subsequent row toggles update only the adapter’s local Set."
  ],
  "runtime_verified": false
}
README.md실행 안내·자료
파일 저장

# Unchecked Group · Fluid Functionalism

The acquired original files are unchanged. Source revision: b3587bdbd83fc66c2a6aae3817ffb856cb09260b. Each exact file hash and any demo content revision is recorded in provenance.json. Keep all included license notices.

- The runtime uses the author’s MIT CheckboxGroup/CheckboxItem implementation at the recorded Git revision and the official documentation composition. The historical CDN demo license declaration was empty; its code is not redistributed or used as the entry.
- author-variant-adapter.tsx supplies newly written Korean labels, the recorded item count and initial selected indices, and w-80 max-w-full. It is an explicit composition adapter, not a byte-identical copy of the historical CDN demo.
- The original source imports the Radix flavor. Its complete original re-export closure is retained; the current documentation-site flavor is not used to replace this component.
- The theme slice contains original design tokens, light/dark switching, and Tailwind mappings. Documentation scrollbars, page transitions, bento styles, and the original absolute font URL are outside the selected slice. The font is a separately recorded host asset.
- The host must apply the matching literal light or dark class as well as its theme selection, so light-dark() tokens follow an explicit light preview even when the OS is dark. Use the original --font-sans family stack for the preview body.
- Inter with both wght and opsz axes is required to reproduce the source label-weight treatment. The unchanged font from the same author Git revision and its OFL notice were acquired separately under runtime/fonts, with provenance.json recording their sources and hashes. The acquired source pack is unchanged. Browser loading, Korean glyph fallback, and width compensation have not been verified.
- The adapter supplies no ShapeProvider or size prop. Source fallbacks select rounded corners and the default 36px control size. No account, network request, persistence, or bulk select-all control is added.
- This variant has 5 items and initial selected indices []. Subsequent row toggles update only the adapter’s local Set.
- Host spacing and fallback tokens come from runtime/author-styles.css. runtime/applied-theme.css contains the selected original author theme and overrides fallback tokens.

Open the standalone HTML to run with all JavaScript and CSS bundled locally. ?theme=light and ?theme=dark select the host theme. The parent gallery must use an opaque allow-scripts sandbox. No external requests are required.

For source reuse, start with Usage.tsx and the unchanged baseline demonstration. Resolve @/ imports to their included matching author files and install the exact package versions in provenance.json. The host mount and styles are supplied for reference.

Build success and the ready message do not prove runtime or accessibility behavior. Browser validation is recorded separately.