{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "grid",
  "type": "registry:ui",
  "title": "Grid",
  "description": "많은 행을 페이지 단위로 보여주는 데이터 테이블입니다. 헤더는 고정되고, 컬럼은 정렬·이동·너비 조절·고정·필터를 지원합니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/button",
    "@atom/icons",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-use-controllable-state",
    "@atom/pagination",
    "@atom/select",
    "@atom/text-input"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/grid.tsx",
      "content": "\"use client\";\n\nimport {\n  type CSSProperties,\n  type DragEvent,\n  type FocusEvent,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { useControllableState } from \"../lib/use-controllable-state\";\nimport { Button } from \"./button\";\nimport { CheckIcon, ChevronRightIcon } from \"./icons\";\nimport { Pagination } from \"./pagination\";\nimport { Select } from \"./select\";\nimport { TextInput } from \"./text-input\";\n\n/* -------------------------------------------------------------------------- */\n/*  Grid (components/grid.md, styles/atom/elements/grid.css)                  */\n/*    <Grid                                                                   */\n/*      columns={[{ key: \"name\", label: \"항목명\", pinned: true, sortable: true },  */\n/*                { key: \"qty\", label: \"수량\", align: \"end\" }]}               */\n/*      rows={pageRows} totalCount={1284}                                     */\n/*      pageIndex={page} onPageChange={setPage}                               */\n/*      selectable selectedKeys={keys} onSelectionChange={setKeys}            */\n/*    />                                                                      */\n/*  Footer = 총 건수 · Pagination · Page size(Select). 행은 현재 페이지분만     */\n/*  받습니다. 정렬·필터는 받은 행에 다시 적용해도 결과가 같으므로(멱등) 서버가  */\n/*  이미 처리했든 아니든 그대로 그립니다. 트리는 평평한 <tr> + 인라인           */\n/*  --atom-grid-tree-depth 이고 role=\"treegrid\" 가 됩니다.                     */\n/* -------------------------------------------------------------------------- */\n\nexport type GridSize = \"small\" | \"medium\";\nexport type GridState = \"ready\" | \"loading\" | \"empty\";\nexport type GridAlign = \"start\" | \"end\" | \"center\";\nexport type GridPageSize = 100 | 500 | 1000;\nexport type GridSortDirection = \"desc\" | \"asc\";\nexport type GridSort = { key: string; direction: GridSortDirection } | null;\nexport type GridFilterTextMode = \"eq\" | \"neq\" | \"contains\";\nexport type GridFilter = {\n  text?: { mode: GridFilterTextMode; value: string };\n  values?: string[];\n};\nexport type GridFilters = Record<string, GridFilter>;\nexport type GridRow = Record<string, unknown>;\nexport type GridColumn = {\n  key: string;\n  label: string;\n  width?: number;\n  align?: GridAlign;\n  sortable?: boolean;\n  filterable?: boolean;\n  resizable?: boolean;\n  movable?: boolean;\n  pinned?: boolean;\n  /** 셀 내용을 직접 그립니다. 없으면 `row[key]` 를 문자열로 (숫자는 천 단위 구분). */\n  render?: (row: GridRow) => ReactNode;\n};\n\nconst B = \"atom-grid\";\nconst PAGE_SIZES: GridPageSize[] = [100, 500, 1000];\nconst MODE_LABEL: Record<GridFilterTextMode, string> = { eq: \"일치\", neq: \"불일치\", contains: \"포함\" };\nconst NO_KEYS: string[] = [];\nconst NO_FILTERS: GridFilters = {};\nconst MIN_WIDTH = 40;\nconst DEFAULT_WIDTH = 120;\nconst CHECK_WIDTH = { small: 36, medium: 44 } as const;\n\nexport type GridProps = Omit<HTMLAttributes<HTMLDivElement>, \"children\" | \"onChange\"> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 컬럼 정의입니다. */\n  columns: GridColumn[];\n  /** 현재 페이지에 그릴 행 데이터입니다. */\n  rows: GridRow[];\n  /** 필터가 적용된 전체 결과 수입니다. 현재 페이지의 행 수가 아닙니다. */\n  totalCount: number;\n  /** 표의 표시 상태입니다. */\n  state?: GridState;\n  /** 현재 페이지 인덱스입니다. 0부터 시작합니다. (기본 0, 주면 controlled) */\n  pageIndex?: number;\n  /** 한 페이지에 담을 행 수입니다. (기본 100, 주면 controlled) */\n  pageSize?: GridPageSize;\n  /** Pagination 에 한 번에 노출할 번호 개수입니다. */\n  maxPageButtons?: number;\n  /** 선택된 행의 키입니다. (기본 [], 주면 controlled) */\n  selectedKeys?: string[];\n  /** 행 체크박스 컬럼을 추가합니다. */\n  selectable?: boolean;\n  /** 현재 정렬입니다. `null` 이 기본값 순서입니다. (기본 null, 주면 controlled) */\n  sort?: GridSort;\n  /** 컬럼별 필터입니다. `text` 와 `values` 는 AND 로 결합합니다. (기본 {}, 주면 controlled) */\n  filters?: GridFilters;\n  /** 텍스트 필터의 조건 선택지입니다. 일치 · 불일치 · 포함. */\n  filterTextModes?: GridFilterTextMode[];\n  /** 계층 표시를 켭니다. 켜면 표가 `role=\"treegrid\"` 가 됩니다. */\n  treeData?: boolean;\n  /** 행의 계층 경로입니다. ag-Grid 의 `getDataPath` 와 같습니다. */\n  getRowPath?: (row: GridRow) => string[];\n  /** 계층을 지는 컬럼입니다. 이 컬럼에만 캐럿과 들여쓰기가 붙습니다. (기본 첫 컬럼) */\n  treeColumnKey?: string;\n  /** 처음 펼칠 깊이입니다. `-1` 은 전부 펼침. */\n  defaultExpandedDepth?: number;\n  /** 펼쳐진 행의 키입니다. 주면 펼침 상태가 제어됩니다. */\n  expandedKeys?: string[];\n  /** 부모 행 뒤에 자손 수 `(3)` 을 붙입니다. */\n  showChildCount?: boolean;\n  /** 부모를 고르면 자손도 함께 선택됩니다. `selectable` 과 함께 씁니다. */\n  selectsChildren?: boolean;\n  /** 필터에서 조상 행을 맥락으로 남기지 않습니다. */\n  excludeAncestorsWhenFiltering?: boolean;\n  /** 가지를 여닫을 때 호출됩니다. */\n  onExpandedChange?: (keys: string[]) => void;\n  /** 행 높이와 폰트 크기를 결정합니다. */\n  size?: GridSize;\n  /** Grid 전체 높이입니다. 주면 `__scroll` 이 남는 공간을 채웁니다. */\n  height?: number | string;\n  /** `height` 가 없을 때 스크롤 영역의 높이 상한입니다. */\n  maxHeight?: number | string;\n  /** 결과가 없을 때 보일 문구입니다. */\n  emptyText?: string;\n  /** 조회 중 오버레이에 보일 문구입니다. */\n  loadingText?: string;\n  /** 정렬이 바뀔 때 호출됩니다. 3번째 클릭에서는 `null` 이 전달됩니다. */\n  onSortChange?: (sort: GridSort) => void;\n  /** 필터를 적용하거나 초기화할 때 호출됩니다. */\n  onFilterChange?: (key: string, filter: GridFilter | null) => void;\n  /** 페이지가 바뀔 때 호출됩니다. */\n  onPageChange?: (pageIndex: number) => void;\n  /** 페이지 크기가 바뀔 때 호출됩니다. 페이지 인덱스는 0으로 되돌립니다. */\n  onPageSizeChange?: (pageSize: number) => void;\n  /** 행 선택이 바뀔 때 호출됩니다. */\n  onSelectionChange?: (keys: string[]) => void;\n  /** 컬럼 순서가 바뀔 때 호출됩니다. */\n  onColumnOrderChange?: (keys: string[]) => void;\n  /** 컬럼 너비가 바뀔 때 호출됩니다. */\n  onColumnResize?: (key: string, width: number) => void;\n  /** (구현 추가) 행의 키입니다. 기본은 `row.key` → `row.id` → 트리 경로 → 인덱스. */\n  getRowKey?: (row: GridRow, index: number) => string;\n};\n\n/* ---------------------------- 원본 데모의 인라인 SVG ---------------------------- */\n\nfunction SortGlyphs() {\n  return (\n    <span className={`${B}__th-sort`} aria-hidden=\"true\">\n      <svg viewBox=\"0 0 8 5\" aria-hidden=\"true\">\n        <path d=\"M4 0l4 5H0z\" fill=\"currentColor\" />\n      </svg>\n      <svg viewBox=\"0 0 8 5\" aria-hidden=\"true\">\n        <path d=\"M4 5L0 0h8z\" fill=\"currentColor\" />\n      </svg>\n    </span>\n  );\n}\n\nfunction FilterGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" aria-hidden=\"true\">\n      <path\n        d=\"M2 3h12l-4.6 5.4v4.2L6.6 14V8.4z\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction GripGlyph() {\n  return (\n    <svg viewBox=\"0 0 10 14\" aria-hidden=\"true\">\n      {[3, 7, 11].map((cy) =>\n        [3, 7].map((cx) => <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r=\"1.2\" fill=\"currentColor\" />),\n      )}\n    </svg>\n  );\n}\n\ntype CheckState = \"checked\" | \"indeterminate\" | \"unchecked\";\n\n/** atom-grid__check — Checkbox 의 시각을 반복합니다 (grid.css 헤더 주석). */\nfunction GridCheck({\n  state,\n  label,\n  onToggle,\n}: {\n  state: CheckState;\n  label: string;\n  onToggle: () => void;\n}) {\n  return (\n    <span\n      className={`${B}__check`}\n      data-state={state}\n      role=\"checkbox\"\n      aria-checked={state === \"checked\" ? true : state === \"indeterminate\" ? \"mixed\" : false}\n      aria-label={label}\n      tabIndex={0}\n      onClick={(e) => {\n        e.stopPropagation();\n        onToggle();\n      }}\n      onKeyDown={(e) => {\n        if (e.key === \" \" || e.key === \"Enter\") {\n          e.preventDefault();\n          e.stopPropagation();\n          onToggle();\n        }\n      }}\n    >\n      <CheckIcon />\n      <i />\n    </span>\n  );\n}\n\n/* ------------------------------- 행 모델 헬퍼 ------------------------------- */\n\nfunction cellText(v: unknown): string {\n  if (v === null || v === undefined) return \"\";\n  return String(v);\n}\n\nfunction formatCell(v: unknown): ReactNode {\n  if (typeof v === \"number\") return v.toLocaleString(\"ko-KR\");\n  return cellText(v);\n}\n\nfunction compareValues(a: unknown, b: unknown): number {\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n  return cellText(a).localeCompare(cellText(b), \"ko\", { numeric: true });\n}\n\nfunction matchesFilter(row: GridRow, filters: GridFilters): boolean {\n  for (const [key, f] of Object.entries(filters)) {\n    const text = cellText(row[key]);\n    if (f.values && !f.values.includes(text)) return false;\n    if (f.text && f.text.value !== \"\") {\n      const q = f.text.value;\n      if (f.text.mode === \"eq\" && text !== q) return false;\n      if (f.text.mode === \"neq\" && text === q) return false;\n      if (f.text.mode === \"contains\" && !text.toLowerCase().includes(q.toLowerCase())) return false;\n    }\n  }\n  return true;\n}\n\nfunction hasFilter(filters: GridFilters) {\n  return Object.keys(filters).length > 0;\n}\n\ntype TreeNode = {\n  key: string;\n  row: GridRow | null; // null = 경로에만 있는 중간 노드\n  path: string[];\n  depth: number;\n  children: TreeNode[];\n  descendants: number;\n};\n\ntype VisibleRow = {\n  key: string;\n  row: GridRow | null;\n  node?: TreeNode;\n  depth: number;\n  parentKey?: string;\n  posinset?: number;\n  setsize?: number;\n};\n\nfunction buildTree(\n  rows: GridRow[],\n  getPath: (row: GridRow) => string[],\n  keyOf: (row: GridRow, i: number) => string,\n): TreeNode[] {\n  const byPath = new Map<string, TreeNode>();\n  const roots: TreeNode[] = [];\n  const ensure = (path: string[]): TreeNode => {\n    const id = path.join(\"\\u0000\");\n    let node = byPath.get(id);\n    if (!node) {\n      node = { key: `path:${path.join(\"/\")}`, row: null, path, depth: path.length - 1, children: [], descendants: 0 };\n      byPath.set(id, node);\n      if (path.length === 1) roots.push(node);\n      else ensure(path.slice(0, -1)).children.push(node);\n    }\n    return node;\n  };\n  rows.forEach((row, i) => {\n    const node = ensure(getPath(row));\n    node.row = row;\n    node.key = keyOf(row, i);\n  });\n  const count = (n: TreeNode): number => {\n    n.descendants = n.children.reduce((s, c) => s + 1 + count(c), 0);\n    return n.descendants;\n  };\n  roots.forEach(count);\n  return roots;\n}\n\nfunction collectDescendantKeys(node: TreeNode, out: string[] = []): string[] {\n  for (const c of node.children) {\n    out.push(c.key);\n    collectDescendantKeys(c, out);\n  }\n  return out;\n}\n\n/* ---------------------------------- Grid ---------------------------------- */\n\nfunction GridRoot({\n  ref,\n  columns,\n  rows,\n  totalCount,\n  state = \"ready\",\n  pageIndex: pageIndexProp,\n  pageSize: pageSizeProp,\n  maxPageButtons = 10,\n  selectedKeys: selectedKeysProp,\n  selectable = false,\n  sort: sortProp,\n  filters: filtersProp,\n  filterTextModes = [\"eq\", \"neq\", \"contains\"],\n  treeData = false,\n  getRowPath,\n  treeColumnKey,\n  defaultExpandedDepth = 0,\n  expandedKeys: expandedKeysProp,\n  showChildCount = true,\n  selectsChildren = false,\n  excludeAncestorsWhenFiltering = false,\n  onExpandedChange,\n  size = \"medium\",\n  height,\n  maxHeight = 420,\n  emptyText = \"조회된 데이터가 없습니다\",\n  loadingText = \"로딩중입니다\",\n  onSortChange,\n  onFilterChange,\n  onPageChange,\n  onPageSizeChange,\n  onSelectionChange,\n  onColumnOrderChange,\n  onColumnResize,\n  getRowKey,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  ...rest\n}: GridProps) {\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n\n  /* --- controlled / uncontrolled 상태 --- */\n  const [pageIndex, setPageIndex] = useControllableState({\n    value: pageIndexProp,\n    defaultValue: 0,\n    onChange: onPageChange,\n  });\n  const [pageSize, setPageSize] = useControllableState<GridPageSize>({\n    value: pageSizeProp,\n    defaultValue: 100,\n    onChange: onPageSizeChange,\n  });\n  const [selectedKeys, setSelectedKeys] = useControllableState({\n    value: selectedKeysProp,\n    defaultValue: NO_KEYS,\n    onChange: onSelectionChange,\n  });\n  const [sort, setSort] = useControllableState<GridSort>({\n    value: sortProp,\n    defaultValue: null,\n    onChange: onSortChange,\n  });\n  const [filters, setFilters] = useState<GridFilters>(filtersProp ?? NO_FILTERS);\n  const activeFilters = filtersProp ?? filters;\n\n  /* --- 컬럼 순서 · 너비 (원본에 제어 prop 이 없어 내부 상태 + 콜백) --- */\n  const [order, setOrder] = useState<string[]>(() => columns.map((c) => c.key));\n  const [widths, setWidths] = useState<Record<string, number>>({});\n  const ordered = useMemo(() => {\n    const byKey = new Map(columns.map((c) => [c.key, c] as const));\n    const known = order.filter((k) => byKey.has(k));\n    const added = columns.map((c) => c.key).filter((k) => !known.includes(k));\n    const all = [...known, ...added].map((k) => byKey.get(k)!);\n    // 고정 컬럼은 시작 가장자리에 붙어야 하므로 앞으로 모읍니다.\n    return [...all.filter((c) => c.pinned), ...all.filter((c) => !c.pinned)];\n  }, [columns, order]);\n  const widthOf = (c: GridColumn) => widths[c.key] ?? c.width;\n\n  /* --- 행 모델 --- */\n  const keyOf = (row: GridRow, i: number): string => {\n    if (getRowKey) return getRowKey(row, i);\n    const k = row.key ?? row.id;\n    if (k !== undefined && k !== null) return String(k);\n    if (treeData && getRowPath) return getRowPath(row).join(\"/\");\n    return String(i);\n  };\n  const filtering = hasFilter(activeFilters);\n  const sortRows = <T,>(list: T[], get: (t: T) => GridRow | null): T[] => {\n    if (!sort) return list;\n    const dir = sort.direction === \"asc\" ? 1 : -1;\n    return [...list].sort((a, b) => {\n      const ra = get(a);\n      const rb = get(b);\n      return dir * compareValues(ra?.[sort.key], rb?.[sort.key]);\n    });\n  };\n\n  const tree = useMemo(\n    () => (treeData && getRowPath ? buildTree(rows, getRowPath, keyOf) : null),\n    [treeData, getRowPath, rows, getRowKey],\n  );\n\n  // 처음 펼칠 깊이 — uncontrolled 초기값으로만 쓰입니다.\n  const defaultExpanded: string[] = [];\n  const walkDefault = (n: TreeNode) => {\n    if (n.children.length && (defaultExpandedDepth < 0 || n.depth < defaultExpandedDepth)) defaultExpanded.push(n.key);\n    n.children.forEach(walkDefault);\n  };\n  tree?.forEach(walkDefault);\n  const [expandedKeys, setExpandedKeys] = useControllableState({\n    value: expandedKeysProp,\n    defaultValue: defaultExpanded,\n    onChange: onExpandedChange,\n  });\n  const expanded = new Set(expandedKeys);\n\n  const visible: VisibleRow[] = useMemo(() => {\n    if (!tree) {\n      const list = rows.map((row, i) => ({ key: keyOf(row, i), row, depth: 0 }));\n      return sortRows(\n        list.filter((r) => matchesFilter(r.row, activeFilters)),\n        (r) => r.row,\n      );\n    }\n    const matches = (n: TreeNode) => n.row !== null && matchesFilter(n.row, activeFilters);\n    if (filtering && excludeAncestorsWhenFiltering) {\n      // 조상을 맥락으로 남기지 않으면 평평한 목록이 됩니다.\n      const flat: TreeNode[] = [];\n      const walk = (n: TreeNode) => {\n        if (matches(n)) flat.push(n);\n        n.children.forEach(walk);\n      };\n      tree.forEach(walk);\n      return sortRows(flat, (n) => n.row).map((n) => ({ key: n.key, row: n.row, depth: 0 }));\n    }\n    const keep = (n: TreeNode): boolean => !filtering || matches(n) || n.children.some(keep);\n    const out: VisibleRow[] = [];\n    const emit = (nodes: TreeNode[], parentKey?: string) => {\n      const siblings = sortRows(nodes.filter(keep), (n) => n.row);\n      siblings.forEach((n, i) => {\n        out.push({\n          key: n.key,\n          row: n.row,\n          node: n,\n          depth: n.depth,\n          parentKey,\n          posinset: i + 1,\n          setsize: siblings.length,\n        });\n        // 접힌 가지의 자손은 CSS 로 숨기지 않고 내보내지 않습니다.\n        if (n.children.length && expanded.has(n.key)) emit(n.children, n.key);\n      });\n    };\n    emit(tree);\n    return out;\n  }, [tree, rows, activeFilters, sort, expandedKeys, excludeAncestorsWhenFiltering, getRowKey]);\n\n  const nodeByKey = useMemo(() => {\n    const m = new Map<string, TreeNode>();\n    const walk = (n: TreeNode) => {\n      m.set(n.key, n);\n      n.children.forEach(walk);\n    };\n    tree?.forEach(walk);\n    return m;\n  }, [tree]);\n\n  const isTree = tree !== null;\n  const treeKey = treeColumnKey ?? ordered[0]?.key ?? columns[0]?.key;\n\n  /* --- 선택 --- */\n  const selected = new Set(selectedKeys);\n  const rowCheckState = (v: VisibleRow): CheckState => {\n    if (isTree && selectsChildren && v.node && v.node.descendants > 0) {\n      const desc = collectDescendantKeys(v.node);\n      const n = desc.filter((k) => selected.has(k)).length;\n      if (n === desc.length) return \"checked\";\n      if (n > 0) return \"indeterminate\";\n    }\n    return selected.has(v.key) ? \"checked\" : \"unchecked\";\n  };\n  const toggleRow = (v: VisibleRow) => {\n    const next = new Set(selected);\n    const on = rowCheckState(v) !== \"checked\";\n    const keys = [v.key, ...(isTree && selectsChildren && v.node ? collectDescendantKeys(v.node) : [])];\n    keys.forEach((k) => (on ? next.add(k) : next.delete(k)));\n    setSelectedKeys([...next]);\n  };\n  // Header 체크박스가 켜는 것은 현재 페이지(그려진 행)입니다.\n  const pageKeys = visible.map((v) => v.key);\n  const pageSelected = pageKeys.filter((k) => selected.has(k)).length;\n  const headerState: CheckState =\n    pageKeys.length > 0 && pageSelected === pageKeys.length ? \"checked\" : pageSelected > 0 ? \"indeterminate\" : \"unchecked\";\n  const toggleAll = () => {\n    const next = new Set(selected);\n    if (headerState === \"checked\") pageKeys.forEach((k) => next.delete(k));\n    else pageKeys.forEach((k) => next.add(k));\n    setSelectedKeys([...next]);\n  };\n\n  /* --- 정렬 — 내림차 → 오름차 → 기본값 --- */\n  const cycleSort = (key: string) => {\n    if (!sort || sort.key !== key) setSort({ key, direction: \"desc\" });\n    else if (sort.direction === \"desc\") setSort({ key, direction: \"asc\" });\n    else setSort(null);\n  };\n\n  /* --- 컬럼 이동 --- */\n  const [dragKey, setDragKey] = useState<string | null>(null);\n  const [dropKey, setDropKey] = useState<string | null>(null);\n  const commitOrder = (keys: string[]) => {\n    setOrder(keys);\n    onColumnOrderChange?.(keys);\n  };\n  const moveBefore = (from: string, to: string) => {\n    if (from === to) return;\n    const keys = ordered.map((c) => c.key).filter((k) => k !== from);\n    keys.splice(keys.indexOf(to), 0, from);\n    commitOrder(keys);\n  };\n  const moveBy = (key: string, delta: number) => {\n    const keys = ordered.map((c) => c.key);\n    const i = keys.indexOf(key);\n    const j = i + delta;\n    if (j < 0 || j >= keys.length) return;\n    keys.splice(i, 1);\n    keys.splice(j, 0, key);\n    commitOrder(keys);\n  };\n\n  /* --- 너비 조절 --- */\n  const [resizing, setResizing] = useState<string | null>(null);\n  const resizeStart = useRef<{ x: number; w: number }>({ x: 0, w: 0 });\n  const thRefs = useRef(new Map<string, HTMLTableCellElement>());\n  const setWidth = (key: string, w: number, commit: boolean) => {\n    const width = Math.max(MIN_WIDTH, Math.round(w));\n    setWidths((prev) => ({ ...prev, [key]: width }));\n    if (commit) onColumnResize?.(key, width);\n  };\n  const onResizeDown = (c: GridColumn) => (e: PointerEvent<HTMLSpanElement>) => {\n    e.stopPropagation();\n    e.preventDefault();\n    const th = thRefs.current.get(c.key);\n    resizeStart.current = { x: e.clientX, w: widthOf(c) ?? th?.offsetWidth ?? DEFAULT_WIDTH };\n    e.currentTarget.setPointerCapture?.(e.pointerId);\n    setResizing(c.key);\n  };\n  const onResizeMove = (c: GridColumn) => (e: PointerEvent<HTMLSpanElement>) => {\n    if (resizing !== c.key) return;\n    setWidth(c.key, resizeStart.current.w + (e.clientX - resizeStart.current.x), false);\n  };\n  const onResizeUp = (c: GridColumn) => (e: PointerEvent<HTMLSpanElement>) => {\n    if (resizing !== c.key) return;\n    setResizing(null);\n    setWidth(c.key, resizeStart.current.w + (e.clientX - resizeStart.current.x), true);\n  };\n\n  /* --- 가로 스크롤 그림자 --- */\n  const [scrolledX, setScrolledX] = useState(false);\n\n  /* --- 필터 popover --- */\n  const [filterKey, setFilterKey] = useState<string | null>(null);\n  const [popPos, setPopPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });\n  const filterBtnRefs = useRef(new Map<string, HTMLButtonElement>());\n  const popoverRef = useRef<HTMLDivElement | null>(null);\n  const closeFilter = (restoreFocus: boolean) => {\n    const key = filterKey;\n    setFilterKey(null);\n    if (restoreFocus && key) filterBtnRefs.current.get(key)?.focus();\n  };\n  const openFilter = (key: string) => {\n    const th = thRefs.current.get(key);\n    const root = rootRef.current;\n    if (th && root) {\n      const r = root.getBoundingClientRect();\n      const t = th.getBoundingClientRect();\n      const left = Math.max(0, Math.min(t.left - r.left, r.width - 280));\n      setPopPos({ top: t.bottom - r.top + 2, left });\n    }\n    setFilterKey(key);\n  };\n  const applyFilter = (key: string, f: GridFilter | null) => {\n    const next = { ...activeFilters };\n    if (f) next[key] = f;\n    else delete next[key];\n    setFilters(next);\n    onFilterChange?.(key, f);\n  };\n  // 바깥을 누르면 닫습니다 (초점 이동은 popover 의 focusout 이 처리).\n  useEffect(() => {\n    if (!filterKey) return;\n    const onDown = (e: globalThis.PointerEvent) => {\n      const t = e.target as Node;\n      if (popoverRef.current?.contains(t)) return;\n      if (filterBtnRefs.current.get(filterKey)?.contains(t)) return;\n      setFilterKey(null);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [filterKey]);\n\n  /* --- 트리 펼침 · 키보드 --- */\n  const toggleExpand = (key: string, open?: boolean) => {\n    const isOpen = expanded.has(key);\n    const want = open ?? !isOpen;\n    if (want === isOpen) return;\n    const next = new Set(expanded);\n    if (want) next.add(key);\n    else next.delete(key);\n    setExpandedKeys([...next]);\n  };\n  const [focusKey, setFocusKey] = useState<string | null>(null);\n  const rowRefs = useRef(new Map<string, HTMLTableRowElement>());\n  const tabKey = focusKey && visible.some((v) => v.key === focusKey) ? focusKey : visible[0]?.key;\n  const focusRow = (key: string | undefined) => {\n    if (!key) return;\n    setFocusKey(key);\n    rowRefs.current.get(key)?.focus();\n  };\n  const pendingFocus = useRef<string | null>(null);\n  useLayoutEffect(() => {\n    if (pendingFocus.current) {\n      rowRefs.current.get(pendingFocus.current)?.focus();\n      pendingFocus.current = null;\n    }\n  });\n  const onTreeRowKeyDown = (v: VisibleRow, i: number) => (e: KeyboardEvent<HTMLTableRowElement>) => {\n    if (e.target !== e.currentTarget) return;\n    const hasKids = !!v.node && v.node.children.length > 0;\n    const isOpen = expanded.has(v.key);\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        focusRow(visible[i + 1]?.key);\n        break;\n      case \"ArrowUp\":\n        e.preventDefault();\n        focusRow(visible[i - 1]?.key);\n        break;\n      case \"Home\":\n        e.preventDefault();\n        focusRow(visible[0]?.key);\n        break;\n      case \"End\":\n        e.preventDefault();\n        focusRow(visible[visible.length - 1]?.key);\n        break;\n      case \"ArrowRight\":\n        e.preventDefault();\n        if (hasKids && !isOpen) toggleExpand(v.key, true);\n        else if (hasKids) focusRow(visible[i + 1]?.key);\n        break;\n      case \"ArrowLeft\":\n        e.preventDefault();\n        if (hasKids && isOpen) toggleExpand(v.key, false);\n        else if (v.parentKey) focusRow(v.parentKey);\n        break;\n      case \"Enter\":\n        e.preventDefault();\n        if (hasKids) toggleExpand(v.key);\n        break;\n      case \" \":\n        if (selectable) {\n          e.preventDefault();\n          toggleRow(v);\n        }\n        break;\n    }\n  };\n\n  /* --- 고정 컬럼 오프셋 --- */\n  const anyPinned = ordered.some((c) => c.pinned);\n  const checkWidth = CHECK_WIDTH[size];\n  const pinOffsets = new Map<string, number>();\n  {\n    let offset = selectable && anyPinned ? checkWidth : 0;\n    for (const c of ordered) {\n      if (!c.pinned) break;\n      pinOffsets.set(c.key, offset);\n      offset += widthOf(c) ?? DEFAULT_WIDTH;\n    }\n  }\n  const pinStyle = (c: GridColumn): CSSProperties | undefined => {\n    const off = pinOffsets.get(c.key);\n    return off ? { insetInlineStart: off } : undefined;\n  };\n\n  const rowLabel = (v: VisibleRow) => {\n    const labelKey = isTree ? treeKey : ordered[0]?.key;\n    const val = v.row && labelKey ? v.row[labelKey] : undefined;\n    return cellText(val ?? v.node?.path[v.node.path.length - 1] ?? v.key);\n  };\n\n  const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));\n  const filterCol = filterKey ? ordered.find((c) => c.key === filterKey) : undefined;\n\n  return (\n    <div\n      {...rest}\n      ref={(node) => {\n        rootRef.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) ref.current = node;\n      }}\n      className={cn(B, mod(B, \"size\", size, \"medium\"), className)}\n      data-state={state}\n      data-scrolled-x={scrolledX ? \"\" : undefined}\n      aria-busy={state === \"loading\" || undefined}\n      style={height !== undefined ? { height, ...style } : style}\n    >\n      <div\n        ref={scrollRef}\n        className={`${B}__scroll`}\n        tabIndex={0}\n        style={height !== undefined ? { maxHeight: \"none\" } : maxHeight !== 420 ? { maxHeight } : undefined}\n        onScroll={(e) => setScrolledX(e.currentTarget.scrollLeft > 0)}\n      >\n        <table\n          className={`${B}__table`}\n          role={isTree ? \"treegrid\" : undefined}\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-multiselectable={selectable || undefined}\n        >\n          <thead className={`${B}__thead`}>\n            <tr>\n              {selectable && (\n                <th className={cn(`${B}__th`, `${B}__th--check`, anyPinned && `${B}__th--pinned`)}>\n                  <span className={`${B}__th-inner`}>\n                    <GridCheck state={headerState} label=\"전체 행 선택\" onToggle={toggleAll} />\n                  </span>\n                </th>\n              )}\n              {ordered.map((c) => {\n                const sortedDir = sort?.key === c.key ? sort.direction : null;\n                const interactive = !!(c.sortable || c.movable || c.resizable);\n                const w = widthOf(c);\n                const active = !!activeFilters[c.key];\n                return (\n                  <th\n                    key={c.key}\n                    ref={(el) => {\n                      if (el) thRefs.current.set(c.key, el);\n                      else thRefs.current.delete(c.key);\n                    }}\n                    scope=\"col\"\n                    className={cn(\n                      `${B}__th`,\n                      c.pinned && `${B}__th--pinned`,\n                      mod(B + \"__th\", \"sorted\", sortedDir),\n                      mod(B + \"__th\", \"align\", c.align, \"start\"),\n                    )}\n                    style={{ ...(w !== undefined ? { width: w } : null), ...pinStyle(c) }}\n                    aria-sort={c.sortable ? (sortedDir === \"desc\" ? \"descending\" : sortedDir === \"asc\" ? \"ascending\" : \"none\") : undefined}\n                    tabIndex={interactive ? 0 : undefined}\n                    draggable={c.movable || undefined}\n                    data-dragging={dragKey === c.key ? \"\" : undefined}\n                    data-drop-target={dropKey === c.key && dragKey !== c.key ? \"\" : undefined}\n                    onClick={c.sortable ? () => cycleSort(c.key) : undefined}\n                    onKeyDown={(e: KeyboardEvent<HTMLTableCellElement>) => {\n                      if (e.target !== e.currentTarget) return;\n                      if (c.sortable && (e.key === \"Enter\" || e.key === \" \")) {\n                        e.preventDefault();\n                        cycleSort(c.key);\n                      } else if (c.movable && e.altKey && (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\")) {\n                        e.preventDefault();\n                        moveBy(c.key, e.key === \"ArrowLeft\" ? -1 : 1);\n                      } else if (c.resizable && e.shiftKey && (e.key === \"ArrowLeft\" || e.key === \"ArrowRight\")) {\n                        e.preventDefault();\n                        const cur = w ?? e.currentTarget.offsetWidth ?? DEFAULT_WIDTH;\n                        setWidth(c.key, (cur || DEFAULT_WIDTH) + (e.key === \"ArrowLeft\" ? -10 : 10), true);\n                      }\n                    }}\n                    onDragStart={(e: DragEvent<HTMLTableCellElement>) => {\n                      if (!c.movable) return;\n                      e.dataTransfer?.setData(\"text/plain\", c.key);\n                      setDragKey(c.key);\n                    }}\n                    onDragOver={(e: DragEvent<HTMLTableCellElement>) => {\n                      if (!dragKey) return;\n                      e.preventDefault();\n                      if (dropKey !== c.key) setDropKey(c.key);\n                    }}\n                    onDrop={(e: DragEvent<HTMLTableCellElement>) => {\n                      e.preventDefault();\n                      if (dragKey) moveBefore(dragKey, c.key);\n                      setDragKey(null);\n                      setDropKey(null);\n                    }}\n                    onDragEnd={() => {\n                      setDragKey(null);\n                      setDropKey(null);\n                    }}\n                  >\n                    <span className={`${B}__th-inner`}>\n                      {c.movable && (\n                        <span className={`${B}__th-grip`} aria-hidden=\"true\" onClick={(e) => e.stopPropagation()}>\n                          <GripGlyph />\n                        </span>\n                      )}\n                      <span className={`${B}__th-label`}>{c.label}</span>\n                      {c.sortable && <SortGlyphs />}\n                      {c.filterable && (\n                        <button\n                          ref={(el) => {\n                            if (el) filterBtnRefs.current.set(c.key, el);\n                            else filterBtnRefs.current.delete(c.key);\n                          }}\n                          className={`${B}__th-filter`}\n                          type=\"button\"\n                          aria-label={`${c.label} 필터`}\n                          aria-haspopup=\"dialog\"\n                          aria-expanded={filterKey === c.key}\n                          aria-controls={filterKey === c.key ? `${baseId}-filter` : undefined}\n                          data-active={active ? \"\" : undefined}\n                          onClick={(e: MouseEvent) => {\n                            e.stopPropagation();\n                            if (filterKey === c.key) closeFilter(false);\n                            else openFilter(c.key);\n                          }}\n                          onKeyDown={(e) => e.stopPropagation()}\n                        >\n                          <FilterGlyph />\n                        </button>\n                      )}\n                      {c.resizable && (\n                        <span\n                          className={`${B}__th-resize`}\n                          aria-hidden=\"true\"\n                          data-dragging={resizing === c.key ? \"\" : undefined}\n                          onClick={(e) => e.stopPropagation()}\n                          onPointerDown={onResizeDown(c)}\n                          onPointerMove={onResizeMove(c)}\n                          onPointerUp={onResizeUp(c)}\n                        />\n                      )}\n                    </span>\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody className={`${B}__tbody`}>\n            {visible.map((v, i) => {\n              const check = rowCheckState(v);\n              const hasKids = !!v.node && v.node.children.length > 0;\n              const isOpen = expanded.has(v.key);\n              return (\n                <tr\n                  key={v.key}\n                  ref={(el) => {\n                    if (el) rowRefs.current.set(v.key, el);\n                    else rowRefs.current.delete(v.key);\n                  }}\n                  className={`${B}__tr`}\n                  aria-selected={selectable ? check === \"checked\" : undefined}\n                  aria-level={isTree ? v.depth + 1 : undefined}\n                  aria-expanded={isTree && hasKids ? isOpen : undefined}\n                  aria-posinset={isTree ? v.posinset : undefined}\n                  aria-setsize={isTree ? v.setsize : undefined}\n                  tabIndex={isTree ? (v.key === tabKey ? 0 : -1) : undefined}\n                  onFocus={isTree ? (e) => e.target === e.currentTarget && setFocusKey(v.key) : undefined}\n                  onKeyDown={isTree ? onTreeRowKeyDown(v, i) : undefined}\n                >\n                  {selectable && (\n                    <td className={cn(`${B}__td`, `${B}__td--check`, anyPinned && `${B}__td--pinned`)}>\n                      <GridCheck state={check} label={`${rowLabel(v)} 행 선택`} onToggle={() => toggleRow(v)} />\n                    </td>\n                  )}\n                  {ordered.map((c) => {\n                    const isTreeCol = isTree && c.key === treeKey;\n                    const content = v.row ? (c.render ? c.render(v.row) : formatCell(v.row[c.key])) : null;\n                    if (!isTreeCol) {\n                      return (\n                        <td\n                          key={c.key}\n                          className={cn(`${B}__td`, c.pinned && `${B}__td--pinned`, mod(B + \"__td\", \"align\", c.align, \"start\"))}\n                          style={pinStyle(c)}\n                        >\n                          {content}\n                        </td>\n                      );\n                    }\n                    const label = v.row ? content : v.node?.path[v.node.path.length - 1];\n                    return (\n                      <td\n                        key={c.key}\n                        className={cn(\n                          `${B}__td`,\n                          `${B}__td--tree`,\n                          c.pinned && `${B}__td--pinned`,\n                          mod(B + \"__td\", \"align\", c.align, \"start\"),\n                        )}\n                        style={{ \"--atom-grid-tree-depth\": v.depth, ...pinStyle(c) } as CSSProperties}\n                      >\n                        <span className={`${B}__tree-cell`}>\n                          {hasKids ? (\n                            <button\n                              className={`${B}__tree-caret`}\n                              type=\"button\"\n                              tabIndex={-1}\n                              aria-label={isOpen ? \"접기\" : \"펼치기\"}\n                              onClick={(e) => {\n                                e.stopPropagation();\n                                pendingFocus.current = v.key;\n                                setFocusKey(v.key);\n                                toggleExpand(v.key);\n                              }}\n                            >\n                              <ChevronRightIcon />\n                            </button>\n                          ) : (\n                            <span className={cn(`${B}__tree-caret`, `${B}__tree-caret--empty`)} aria-hidden=\"true\">\n                              <ChevronRightIcon />\n                            </span>\n                          )}\n                          <span className={`${B}__tree-label`}>{label}</span>\n                          {showChildCount && hasKids && v.node && (\n                            <span className={`${B}__tree-count`}>({v.node.descendants})</span>\n                          )}\n                        </span>\n                      </td>\n                    );\n                  })}\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n\n        {state === \"loading\" && (\n          <div className={`${B}__overlay`}>\n            <span className={`${B}__spinner`} aria-hidden=\"true\" />\n            <span className={`${B}__overlay-text`}>{loadingText}</span>\n          </div>\n        )}\n        {state === \"empty\" && (\n          <div className={`${B}__empty`}>\n            <span className={`${B}__empty-text`}>{emptyText}</span>\n          </div>\n        )}\n      </div>\n\n      {filterCol && (\n        <GridFilterPopover\n          key={filterCol.key}\n          id={`${baseId}-filter`}\n          ref={popoverRef}\n          column={filterCol}\n          rows={rows}\n          filter={activeFilters[filterCol.key]}\n          modes={filterTextModes}\n          top={popPos.top}\n          left={popPos.left}\n          filterButton={() => filterBtnRefs.current.get(filterCol.key)}\n          onApply={(f) => {\n            applyFilter(filterCol.key, f);\n            closeFilter(true);\n          }}\n          onReset={() => applyFilter(filterCol.key, null)}\n          onClose={closeFilter}\n        />\n      )}\n\n      <div className={`${B}__footer`}>\n        <span className={`${B}__count`} aria-live=\"polite\">\n          총 건수 : <span className={`${B}__count-value`}>{totalCount.toLocaleString(\"ko-KR\")}</span>\n        </span>\n        <Pagination\n          pageCount={pageCount}\n          pageIndex={Math.min(pageIndex, pageCount - 1)}\n          maxPageButtons={maxPageButtons}\n          onPageChange={setPageIndex}\n        />\n        <div className={`${B}__page-size`}>\n          <Select\n            size=\"small\"\n            aria-label=\"페이지당 행 수\"\n            options={PAGE_SIZES.map((n) => ({ value: String(n), label: String(n) }))}\n            value={String(pageSize)}\n            onValueChange={(v) => {\n              const n = Number(v) as GridPageSize;\n              if (n === pageSize) return;\n              setPageSize(n);\n              // 3페이지에서 크기를 바꾸면 보던 행의 위치를 알 수 없으므로 처음으로.\n              if (pageIndex !== 0) setPageIndex(0);\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* ----------------------------- 컬럼 필터 popover ----------------------------- */\n\nfunction GridFilterPopover({\n  id,\n  ref,\n  column,\n  rows,\n  filter,\n  modes,\n  top,\n  left,\n  filterButton,\n  onApply,\n  onReset,\n  onClose,\n}: {\n  id: string;\n  ref: Ref<HTMLDivElement>;\n  column: GridColumn;\n  rows: GridRow[];\n  filter: GridFilter | undefined;\n  modes: GridFilterTextMode[];\n  top: number;\n  left: number;\n  filterButton: () => HTMLButtonElement | undefined;\n  onApply: (f: GridFilter | null) => void;\n  onReset: () => void;\n  onClose: (restoreFocus: boolean) => void;\n}) {\n  const values = useMemo(() => {\n    const set = new Set<string>();\n    rows.forEach((r) => set.add(cellText(r[column.key])));\n    filter?.values?.forEach((v) => set.add(v));\n    return [...set];\n  }, [rows, column.key, filter]);\n\n  const [mode, setMode] = useState<GridFilterTextMode>(filter?.text?.mode ?? modes[modes.length - 1] ?? \"contains\");\n  const [text, setText] = useState(filter?.text?.value ?? \"\");\n  const [checked, setChecked] = useState<Set<string>>(() => new Set(filter?.values ?? values));\n  const [active, setActive] = useState(0);\n  const selfRef = useRef<HTMLDivElement | null>(null);\n  const listId = `${id}-list`;\n\n  // 열리면 첫 컨트롤로 초점을 옮깁니다.\n  useEffect(() => {\n    selfRef.current?.querySelector<HTMLElement>(\"button, input, [tabindex='0']\")?.focus();\n  }, []);\n\n  const allState = checked.size === values.length ? \"all\" : checked.size === 0 ? \"none\" : \"some\";\n  const toggleValue = (v: string) => {\n    const next = new Set(checked);\n    if (next.has(v)) next.delete(v);\n    else next.add(v);\n    setChecked(next);\n  };\n  const toggleAllValues = () => setChecked(allState === \"all\" ? new Set() : new Set(values));\n  const itemCount = values.length + 1;\n  const activate = (i: number) => (i === 0 ? toggleAllValues() : toggleValue(values[i - 1]!));\n\n  const build = (): GridFilter | null => {\n    const f: GridFilter = {};\n    if (text !== \"\") f.text = { mode, value: text };\n    if (allState !== \"all\") f.values = values.filter((v) => checked.has(v));\n    return f.text || f.values ? f : null;\n  };\n\n  return (\n    <div\n      id={id}\n      ref={(node) => {\n        selfRef.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      className={`${B}__filter-popover`}\n      data-state=\"open\"\n      role=\"dialog\"\n      aria-label={`${column.label} 필터`}\n      style={{ top, insetInlineStart: left }}\n      onKeyDown={(e) => {\n        if (e.key !== \"Escape\") return;\n        // 안쪽 Select 가 열려 있으면 그 목록만 닫히게 둡니다.\n        const target = e.target as HTMLElement;\n        if (target.closest?.(\".atom-select\")?.getAttribute(\"data-state\") === \"open\") return;\n        e.preventDefault();\n        onClose(true);\n      }}\n      onBlur={(e: FocusEvent<HTMLDivElement>) => {\n        const next = e.relatedTarget as Node | null;\n        if (!next) return;\n        if (e.currentTarget.contains(next)) return;\n        if (filterButton()?.contains(next)) return;\n        onClose(false);\n      }}\n    >\n      <div className={`${B}__filter-group`}>\n        <span className={`${B}__filter-title`}>텍스트 필터</span>\n        <div className={`${B}__filter-row`}>\n          <Select\n            size=\"small\"\n            aria-label=\"조건\"\n            options={modes.map((m) => ({ value: m, label: MODE_LABEL[m] }))}\n            value={mode}\n            onValueChange={(v) => setMode(v as GridFilterTextMode)}\n          />\n          <TextInput size=\"small\" aria-label=\"검색어\" value={text} onChange={(e) => setText(e.target.value)} />\n        </div>\n      </div>\n\n      <div className={`${B}__filter-divider`} />\n\n      <div className={`${B}__filter-group`}>\n        <span className={`${B}__filter-title`} id={`${id}-values`}>\n          데이터 필터\n        </span>\n        <div\n          id={listId}\n          className={`${B}__filter-list`}\n          role=\"listbox\"\n          aria-multiselectable=\"true\"\n          aria-labelledby={`${id}-values`}\n          aria-activedescendant={`${listId}-${active}`}\n          tabIndex={0}\n          onKeyDown={(e) => {\n            if (e.key === \"ArrowDown\") {\n              e.preventDefault();\n              setActive((a) => Math.min(a + 1, itemCount - 1));\n            } else if (e.key === \"ArrowUp\") {\n              e.preventDefault();\n              setActive((a) => Math.max(a - 1, 0));\n            } else if (e.key === \" \" || e.key === \"Enter\") {\n              e.preventDefault();\n              activate(active);\n            }\n          }}\n        >\n          <div\n            id={`${listId}-0`}\n            className=\"atom-multi-select__option\"\n            role=\"option\"\n            aria-selected={allState === \"all\"}\n            aria-checked={allState === \"all\" ? true : allState === \"some\" ? \"mixed\" : false}\n            data-active={active === 0 ? \"\" : undefined}\n            onClick={() => {\n              setActive(0);\n              toggleAllValues();\n            }}\n          >\n            <span className=\"atom-multi-select__option-box\">\n              <CheckIcon className=\"atom-multi-select__option-check\" />\n              <span className=\"atom-multi-select__option-indeterminate\" />\n            </span>\n            <span className=\"atom-multi-select__option-text\">\n              <span className=\"atom-multi-select__option-label\">전체 선택</span>\n            </span>\n          </div>\n          {values.map((v, i) => (\n            <div\n              key={v}\n              id={`${listId}-${i + 1}`}\n              className=\"atom-multi-select__option\"\n              role=\"option\"\n              aria-selected={checked.has(v)}\n              data-active={active === i + 1 ? \"\" : undefined}\n              onClick={() => {\n                setActive(i + 1);\n                toggleValue(v);\n              }}\n            >\n              <span className=\"atom-multi-select__option-box\">\n                <CheckIcon className=\"atom-multi-select__option-check\" />\n                <span className=\"atom-multi-select__option-indeterminate\" />\n              </span>\n              <span className=\"atom-multi-select__option-text\">\n                <span className=\"atom-multi-select__option-label\">{v === \"\" ? \"(빈 값)\" : v}</span>\n              </span>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div className={`${B}__filter-actions`}>\n        <Button\n          size=\"xsmall\"\n          variant=\"ghost\"\n          onClick={() => {\n            // 이 컬럼의 두 영역을 함께 비웁니다.\n            setText(\"\");\n            setChecked(new Set(values));\n            onReset();\n          }}\n        >\n          초기화\n        </Button>\n        <Button size=\"xsmall\" variant=\"brandSolid\" onClick={() => onApply(build())}>\n          적용\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nexport const Grid = Object.assign(GridRoot, {});\n",
      "type": "registry:ui",
      "target": "components/ui/grid.tsx"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/button.css",
      "content": "/* ============================================================================\n   Element — Button\n   ----------------------------------------------------------------------------\n   atom-button\n     --size_{xsmall|small|medium|large}      medium is the default\n     --variant_{brand-solid|neutral-solid|neutral-weak|critical-solid\n                |brand-outline|neutral-outline|ghost}\n     --layout_icon-only\n     --width_full\n   states: :hover  [data-pressed]  [disabled]  [data-loading]\n\n   Loaded by both the card preview and the detail page, so\n   this file is the only definition of the component.\n   ========================================================================== */\n\n.atom-button {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: var(--atom-x2);\n  height: 47px;\n  padding-inline: var(--atom-x5);\n  border-radius: var(--atom-radius-r2);\n  font-size: 15px;\n  font-weight: 600;\n  line-height: 1;\n  white-space: nowrap;\n  user-select: none;\n  transition: filter var(--atom-duration-fast) ease,\n    background-color var(--atom-duration-fast) ease;\n}\n\n.atom-button__icon {\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n}\n\n.atom-button__label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* --- variants ------------------------------------------------------------- */\n.atom-button--variant_neutral-solid {\n  background: var(--atom-bg-neutral-solid);\n  color: var(--atom-fg-on-solid);\n}\n\n.atom-button--variant_brand-solid {\n  background: var(--atom-primary-600);\n  color: #fff;\n}\n\n.atom-button--variant_critical-solid {\n  background: var(--atom-bg-critical-solid);\n  color: #fff;\n}\n\n.atom-button--variant_neutral-weak {\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral);\n}\n\n.atom-button--variant_brand-outline {\n  background: transparent;\n  color: var(--atom-primary-600);\n  box-shadow: inset 0 0 0 1px currentColor;\n}\n\n.atom-button--variant_neutral-outline {\n  background: transparent;\n  color: var(--atom-fg-neutral);\n  box-shadow: inset 0 0 0 1px var(--atom-border-default);\n}\n\n.atom-button--variant_ghost {\n  background: transparent;\n  color: var(--atom-fg-neutral);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-button--size_xsmall {\n  height: 32px;\n  gap: var(--atom-x1);\n  padding-inline: var(--atom-x3);\n  border-radius: var(--atom-radius-full);\n  font-size: 13px;\n}\n\n.atom-button--size_xsmall .atom-button__icon {\n  width: 14px;\n  height: 14px;\n}\n\n.atom-button--size_small {\n  height: 40px;\n  padding-inline: var(--atom-x4);\n  font-size: 14px;\n}\n\n.atom-button--size_small .atom-button__icon {\n  width: 16px;\n  height: 16px;\n}\n\n.atom-button--size_large {\n  height: 54px;\n  padding-inline: var(--atom-x6);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n.atom-button--size_large .atom-button__icon {\n  width: 20px;\n  height: 20px;\n}\n\n/* --- layout -------------------------------------------------------------- */\n.atom-button--layout_icon-only {\n  width: 47px;\n  padding-inline: 0;\n}\n\n.atom-button--layout_icon-only.atom-button--size_xsmall {\n  width: 32px;\n}\n\n.atom-button--layout_icon-only.atom-button--size_small {\n  width: 40px;\n}\n\n.atom-button--layout_icon-only.atom-button--size_large {\n  width: 54px;\n}\n\n.atom-button--width_full {\n  width: 100%;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-button:hover:not([disabled]):not([data-loading]) {\n  filter: brightness(0.94);\n}\n\n.atom-button--variant_ghost:hover:not([disabled]),\n.atom-button--variant_neutral-outline:hover:not([disabled]),\n.atom-button--variant_brand-outline:hover:not([disabled]) {\n  filter: none;\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-button[data-pressed] {\n  filter: brightness(0.88);\n}\n\n.atom-button[disabled] {\n  opacity: 0.35;\n  cursor: not-allowed;\n  filter: none;\n}\n\n.atom-button[data-loading] {\n  cursor: progress;\n}\n\n.atom-button[data-loading] .atom-button__label {\n  opacity: 0.45;\n}\n\n.atom-button__spinner {\n  width: 16px;\n  height: 16px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-full);\n  border: 2px solid currentColor;\n  border-top-color: transparent;\n  animation: atom-button-spin 700ms linear infinite;\n}\n\n@keyframes atom-button-spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/button.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/grid.css",
      "content": "/* ============================================================================\n   Element — Grid (Data Table)\n   ----------------------------------------------------------------------------\n   A paged data table. The header stays put while the body scrolls, columns can\n   be sorted, moved, resized, pinned and filtered, and the footer reports the\n   row count, the pager and the page size in one line.\n\n   atom-grid\n     --size_{small|medium}                   medium is the default\n     [data-state=\"ready|loading|empty\"]\n     __scroll  __table  __thead  __tbody\n     __th  __th-inner  __th-label  __th-sort  __th-filter  __th-grip\n     __th-resize\n     __tr  __td  __cell-check\n     __tree-cell  __tree-caret  __tree-count    the hierarchy column\n     __check                                 header + row checkbox\n     __filter-popover  __filter-group  __filter-title  __filter-row\n     __filter-list  __filter-divider  __filter-actions\n     __overlay  __overlay-text  __spinner  __empty  __empty-text\n     __footer  __count  __count-value  __page-size\n\n   The footer carries all three status controls in one row —\n   총 건수 · Pagination · Page size, left to right. The middle one is the\n   Pagination element (styles/elements/pagination.css), not a part of this file.\n\n   Column modifiers live on both __th and __td so a pinned column stays lined\n   up through the body:\n     --pinned          sticky to the inline start edge\n     --sorted_asc  --sorted_desc\n     --align_{end|center}\n     --check           the row-selection column\n\n   __td--tree is the exception: it sits on the body cell only. The header of a\n   tree column needs no indent, and a modifier with no rule behind it is a\n   promise the stylesheet does not keep.\n\n   Tree rows are flat <tr>s, not nested ones — a table cannot nest and stay a\n   table. Depth arrives as an inline custom property on the row, the way\n   tree.css does it, because a `--depth_1 … --depth_9` ladder would cap the\n   hierarchy at whatever number this file stopped at. Collapsed descendants are\n   not hidden with CSS; the row model stops emitting them.\n\n   The checkbox repeats the Checkbox visual rather than importing it, for the\n   same reason Multi Select does: a card preview loads one stylesheet.\n   ========================================================================== */\n\n.atom-grid {\n  position: relative;\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  min-width: 0;\n  font-size: 14px;\n  color: var(--atom-fg-neutral);\n}\n\n/* --- scroll container ---------------------------------------------------- */\n/* Vertical scroll lives here rather than on the page so the sticky header has\n   something to stick to; horizontal scroll appears on its own once the columns\n   outgrow the box.\n\n   `flex: 1 1 auto` with an auto basis means the box sizes to its content when\n   the Grid has no height of its own, and absorbs the leftover space when it\n   does — so `height: 300px` on .atom-grid is enough to pin the whole thing. */\n.atom-grid__scroll {\n  position: relative;\n  flex: 1 1 auto;\n  min-height: 0;\n  overflow: auto;\n  max-height: 420px;\n  border-radius: var(--atom-radius-r2);\n  box-shadow: inset 0 0 0 1px var(--atom-border-subtle);\n}\n\n.atom-grid__table {\n  width: 100%;\n  border-collapse: separate;\n  border-spacing: 0;\n  table-layout: fixed;\n}\n\n/* --- header -------------------------------------------------------------- */\n.atom-grid__thead .atom-grid__th {\n  position: sticky;\n  top: 0;\n  z-index: 2;\n  height: 44px;\n  padding: 0;\n  background: var(--atom-bg-neutral-weak);\n  border-bottom: 1px solid var(--atom-border-default);\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral);\n  text-align: left;\n  white-space: nowrap;\n}\n\n.atom-grid__th-inner {\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  height: 100%;\n  padding-inline: var(--atom-x3);\n}\n\n.atom-grid__th-label {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* --- sort (C) — 내림차 → 오름차 → 기본값 --------------------------------- */\n.atom-grid__th-sort {\n  display: inline-flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 1px;\n  width: 14px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-subtle);\n  cursor: pointer;\n}\n\n.atom-grid__th-sort svg {\n  width: 8px;\n  height: 5px;\n  display: block;\n}\n\n.atom-grid__th--sorted_desc .atom-grid__th-sort,\n.atom-grid__th--sorted_asc .atom-grid__th-sort {\n  color: var(--atom-fg-neutral);\n}\n\n/* Only the active direction stays solid — a sorted column that highlights both\n   arrows tells the user nothing about which way it went. */\n.atom-grid__th--sorted_desc .atom-grid__th-sort svg:first-child,\n.atom-grid__th--sorted_asc .atom-grid__th-sort svg:last-child {\n  opacity: 0.25;\n}\n\n/* --- filter (F) ---------------------------------------------------------- */\n.atom-grid__th-filter {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-r1);\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease,\n    color var(--atom-duration-fast) ease;\n}\n\n.atom-grid__th-filter svg {\n  width: 13px;\n  height: 13px;\n}\n\n.atom-grid__th-filter:hover {\n  background: var(--atom-bg-layer);\n  color: var(--atom-fg-neutral);\n}\n\n.atom-grid__th-filter[data-active] {\n  color: var(--atom-fg-brand);\n}\n\n.atom-grid__th-filter[aria-expanded=\"true\"] {\n  background: var(--atom-bg-layer);\n  color: var(--atom-fg-neutral);\n}\n\n/* --- column filter popover (F) ------------------------------------------- */\n/* Anchored to .atom-grid, not to the header cell: __scroll clips its own\n   overflow, so a popover parented inside a cell would be cut off the moment it\n   grew taller than the header. Production code positions it against the cell's\n   rect and closes it on focusout or Esc. */\n.atom-grid__filter-popover {\n  position: absolute;\n  z-index: 5;\n  display: flex;\n  flex-direction: column;\n  gap: var(--atom-x3);\n  width: 280px;\n  padding: var(--atom-x4);\n  border-radius: var(--atom-radius-r3);\n  background: var(--atom-bg-layer-raised);\n  box-shadow: var(--atom-shadow-s3), inset 0 0 0 1px var(--atom-border-subtle);\n  font-size: 13px;\n  text-align: left;\n  font-weight: 400;\n}\n\n.atom-grid__filter-popover[data-state=\"closed\"] {\n  display: none;\n}\n\n.atom-grid__filter-group {\n  display: flex;\n  flex-direction: column;\n  gap: var(--atom-x2);\n}\n\n.atom-grid__filter-title {\n  font-size: 11px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-grid__filter-row {\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x2);\n}\n\n/* Both children default to width:100%, so without explicit flex the two of\n   them ask for more than the row has and shrink together — squeezing the mode\n   picker until its label breaks mid-word. The mode has a fixed job and a fixed\n   width; the search box takes what is left. */\n.atom-grid__filter-row .atom-select {\n  flex: 0 0 96px;\n}\n\n.atom-grid__filter-row .atom-text-input {\n  flex: 1 1 auto;\n  min-width: 0;\n}\n\n/* The value list scrolls on its own so the popover keeps a predictable height\n   no matter how many distinct values the column holds. */\n.atom-grid__filter-list {\n  display: flex;\n  flex-direction: column;\n  max-height: 84px;\n  overflow-y: auto;\n}\n\n.atom-grid__filter-divider {\n  height: 1px;\n  background: var(--atom-border-subtle);\n}\n\n.atom-grid__filter-actions {\n  display: flex;\n  justify-content: flex-end;\n  gap: var(--atom-x2);\n}\n\n/* --- move (B) ------------------------------------------------------------ */\n.atom-grid__th-grip {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 14px;\n  height: 20px;\n  flex-shrink: 0;\n  margin-inline-start: -4px;\n  color: var(--atom-fg-neutral-subtle);\n  cursor: grab;\n  opacity: 0;\n  transition: opacity var(--atom-duration-fast) ease;\n}\n\n.atom-grid__th-grip svg {\n  width: 10px;\n  height: 14px;\n}\n\n.atom-grid__th:hover .atom-grid__th-grip {\n  opacity: 1;\n}\n\n.atom-grid__th[data-dragging] {\n  background: var(--atom-bg-layer-raised);\n}\n\n.atom-grid__th[data-drop-target] {\n  box-shadow: inset 2px 0 0 var(--atom-fg-brand);\n}\n\n/* --- resize (D) ---------------------------------------------------------- */\n/* The handle is wider than the line it draws — a 1px hit area is unusable with\n   a mouse and impossible with a trackpad. */\n.atom-grid__th-resize {\n  position: absolute;\n  inset-block: 0;\n  inset-inline-end: -4px;\n  width: 9px;\n  cursor: col-resize;\n  z-index: 1;\n}\n\n.atom-grid__th-resize::after {\n  content: \"\";\n  position: absolute;\n  inset-block: 10px;\n  inset-inline-start: 4px;\n  width: 1px;\n  background: var(--atom-border-default);\n}\n\n.atom-grid__th-resize:hover::after,\n.atom-grid__th-resize[data-dragging]::after {\n  inset-block: 0;\n  width: 2px;\n  background: var(--atom-fg-brand);\n}\n\n/* --- body ---------------------------------------------------------------- */\n.atom-grid__td {\n  height: 44px;\n  padding-inline: var(--atom-x3);\n  border-bottom: 1px solid var(--atom-border-subtle);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  background: var(--atom-bg-layer);\n}\n\n.atom-grid__tr:last-child .atom-grid__td {\n  border-bottom: 0;\n}\n\n.atom-grid__tr:hover .atom-grid__td {\n  background: var(--atom-bg-nav-hover);\n}\n\n.atom-grid__tr[aria-selected=\"true\"] .atom-grid__td {\n  background: var(--atom-bg-informative-weak);\n}\n\n.atom-grid__th--align_end,\n.atom-grid__td--align_end {\n  text-align: right;\n}\n\n.atom-grid__th--align_end .atom-grid__th-inner {\n  flex-direction: row-reverse;\n}\n\n.atom-grid__th--align_center,\n.atom-grid__td--align_center {\n  text-align: center;\n}\n\n/* Numbers are compared down the column, so they line up digit by digit. */\n.atom-grid__td--align_end {\n  font-variant-numeric: tabular-nums;\n}\n\n/* --- pinned (E) ---------------------------------------------------------- */\n.atom-grid__th--pinned,\n.atom-grid__td--pinned {\n  position: sticky;\n  inset-inline-start: 0;\n  z-index: 1;\n}\n\n.atom-grid__thead .atom-grid__th--pinned {\n  z-index: 3;\n}\n\n/* The row washes have to stay opaque on a pinned cell.\n   `--atom-bg-nav-hover` is 4% brand — a tint, not a fill. Behind a normal cell\n   that lands on a static surface and reads as intended, but a pinned cell is\n   the only thing standing between the reader and the columns sliding\n   underneath it: replacing its opaque `--atom-bg-layer` with a 4% wash lets\n   the scrolled text through and the two sets of text collide. So the surface\n   stays the background *colour* and the wash is painted as an image over it.\n\n   Selection outranks hover here the same way it does on a plain cell, and it\n   needs no layering — `--atom-bg-informative-weak` is already opaque. */\n.atom-grid__tr:hover .atom-grid__td--pinned {\n  background-color: var(--atom-bg-layer);\n  background-image: linear-gradient(\n    var(--atom-bg-nav-hover),\n    var(--atom-bg-nav-hover)\n  );\n}\n\n.atom-grid__tr[aria-selected=\"true\"] .atom-grid__td--pinned,\n.atom-grid__tr[aria-selected=\"true\"]:hover .atom-grid__td--pinned {\n  background-color: var(--atom-bg-informative-weak);\n  background-image: none;\n}\n\n/* The shadow only appears once the grid is actually scrolled sideways —\n   a permanent seam reads as a border the user cannot explain. */\n.atom-grid[data-scrolled-x] .atom-grid__th--pinned::after,\n.atom-grid[data-scrolled-x] .atom-grid__td--pinned::after {\n  content: \"\";\n  position: absolute;\n  inset-block: 0;\n  inset-inline-end: 0;\n  width: 6px;\n  transform: translateX(100%);\n  background: linear-gradient(\n    to right,\n    rgb(0 0 0 / 0.08),\n    rgb(0 0 0 / 0)\n  );\n  pointer-events: none;\n}\n\n/* --- row checkbox (A) ---------------------------------------------------- */\n.atom-grid__th--check,\n.atom-grid__td--check {\n  width: 44px;\n  padding-inline: 0;\n  text-align: center;\n}\n\n.atom-grid__th--check .atom-grid__th-inner {\n  justify-content: center;\n  padding-inline: 0;\n}\n\n.atom-grid__check {\n  position: relative;\n  display: inline-block;\n  width: 18px;\n  height: 18px;\n  vertical-align: middle;\n  border-radius: var(--atom-radius-r1);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1.5px var(--atom-border-default);\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease,\n    box-shadow var(--atom-duration-fast) ease;\n}\n\n.atom-grid__check svg,\n.atom-grid__check i {\n  position: absolute;\n  inset: 0;\n  margin: auto;\n  color: var(--atom-fg-on-solid);\n  opacity: 0;\n}\n\n.atom-grid__check svg {\n  width: 12px;\n  height: 12px;\n}\n\n.atom-grid__check i {\n  width: 9px;\n  height: 2px;\n  border-radius: 1px;\n  background: var(--atom-fg-on-solid);\n}\n\n.atom-grid__check[data-state=\"checked\"],\n.atom-grid__check[data-state=\"indeterminate\"] {\n  background: var(--atom-bg-neutral-solid);\n  box-shadow: inset 0 0 0 1.5px var(--atom-bg-neutral-solid);\n}\n\n.atom-grid__check[data-state=\"checked\"] svg,\n.atom-grid__check[data-state=\"indeterminate\"] i {\n  opacity: 1;\n}\n\n/* --- loading (5) --------------------------------------------------------- */\n.atom-grid__overlay {\n  position: absolute;\n  inset: 0;\n  z-index: 4;\n  display: none;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: var(--atom-x3);\n  background: var(--atom-bg-scrim);\n  border-radius: var(--atom-radius-r2);\n}\n\n.atom-grid[data-state=\"loading\"] .atom-grid__overlay {\n  display: flex;\n}\n\n.atom-grid__overlay-text {\n  font-size: 13px;\n  color: var(--atom-fg-neutral);\n}\n\n.atom-grid__spinner {\n  width: 28px;\n  height: 28px;\n  border-radius: var(--atom-radius-full);\n  border: 3px solid var(--atom-border-default);\n  border-top-color: var(--atom-fg-brand);\n  animation: atom-grid-spin var(--atom-duration-slow) linear infinite;\n}\n\n@keyframes atom-grid-spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .atom-grid__spinner {\n    animation-duration: 2.4s;\n  }\n}\n\n/* --- empty (6) ----------------------------------------------------------- */\n.atom-grid__empty {\n  display: none;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: var(--atom-x2);\n  min-height: 180px;\n  padding: var(--atom-x8) var(--atom-x4);\n  background: var(--atom-bg-layer);\n}\n\n.atom-grid[data-state=\"empty\"] .atom-grid__empty {\n  display: flex;\n}\n\n.atom-grid[data-state=\"empty\"] .atom-grid__tbody {\n  display: none;\n}\n\n.atom-grid__empty-text {\n  font-size: 14px;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n/* --- footer — 총 건수 (1) + pager (2) + page size (3) --------------------- */\n/* One row, three slots: count left, pager centred, page size right. Only the\n   two ends are styled here — the pager in the middle is its own element, and\n   what this file still owns about it is placement. See the rule below. */\n.atom-grid__footer {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--atom-x4);\n  padding-top: var(--atom-x3);\n}\n\n.atom-grid__count {\n  flex-shrink: 0;\n  font-size: 13px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n/* The count is the one number a user checks against their own expectation, so\n   it stays legible while the label around it recedes. */\n.atom-grid__count-value {\n  font-weight: 600;\n  font-variant-numeric: tabular-nums;\n  color: var(--atom-fg-neutral);\n}\n\n.atom-grid__page-size {\n  flex-shrink: 0;\n  width: 96px;\n}\n\n/* The pager is Pagination — styles/elements/pagination.css. The block moved\n   out of this file once a list that is not a table needed the same control,\n   and it deliberately carries no margin of its own, so where it sits is the\n   caller's business. Here it is centred against the Grid rather than against\n   the gap between its neighbours: the count growing a digit must not shift the\n   page numbers out from under a hand about to press the same button twice. */\n.atom-grid__footer .atom-pagination {\n  margin-inline: auto;\n}\n\n/* --- tree ----------------------------------------------------------------\n   One column carries the hierarchy; every other column stays a plain cell.\n\n   The indent is padding on the cell, not a margin on its contents, so the row\n   fill — hover, selection, the zebra of the border — still runs the full width\n   of the column. Content pushed in by a margin leaves the fill starting at the\n   indent, and the rows read as a set of disconnected boxes.\n\n   20px per level is tree.css's `--atom-tree-indent`. The two controls sit side\n   by side in the same screens, and a hierarchy that steps 20px in a Tree and\n   something else in a Grid reads as two different depths. */\n.atom-grid {\n  --atom-grid-tree-indent: 20px;\n}\n\n/* The fallback is not decoration: a virtualised body renders rows into a flat\n   container that may not have .atom-grid above it, and a row with no depth\n   still has to draw at level 0. */\n.atom-grid__td--tree {\n  padding-inline-start: calc(\n    var(--atom-x3) + var(--atom-grid-tree-depth, 0) *\n      var(--atom-grid-tree-indent, 20px)\n  );\n}\n\n/* The cell is a flex row so the caret keeps its box while the label truncates.\n   __td already sets `overflow: hidden`, so the ellipsis lands on the label. */\n.atom-grid__tree-cell {\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x2);\n  min-width: 0;\n}\n\n/* A leaf keeps the slot and drops the glyph — the same trade tree.css makes.\n   Removing the box would pull every leaf 16px left of its siblings, and the\n   indent is the only thing saying which parent a row belongs to. */\n.atom-grid__tree-caret {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 16px;\n  height: 16px;\n  flex-shrink: 0;\n  border: 0;\n  padding: 0;\n  background: none;\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n  transition: transform var(--atom-duration-fast) var(--atom-ease-out);\n}\n\n.atom-grid__tree-caret svg {\n  display: block;\n  width: 14px;\n  height: 14px;\n}\n\n.atom-grid__tree-caret--empty {\n  pointer-events: none;\n  visibility: hidden;\n}\n\n.atom-grid__tree-caret:focus-visible {\n  outline: 2px solid var(--atom-fg-brand);\n  outline-offset: 1px;\n  border-radius: var(--atom-radius-r1);\n}\n\n.atom-grid__tr[aria-expanded=\"true\"] .atom-grid__tree-caret {\n  transform: rotate(90deg);\n}\n\n/* The label owns the truncation, not the cell: an ellipsis on __td would clip\n   the count along with the name. */\n.atom-grid__tree-label {\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* Muted and a step down, because it is a fact about the row rather than the\n   row's name. `tabular-nums` so the number stops shifting as branches open. */\n.atom-grid__tree-count {\n  flex-shrink: 0;\n  font-variant-numeric: tabular-nums;\n  color: var(--atom-fg-neutral-muted);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-grid--size_small {\n  /* One step narrower with the rows, so the staircase keeps the same slope\n     against a 36px row that it has against a 44px one. */\n  --atom-grid-tree-indent: 16px;\n\n  font-size: 13px;\n}\n\n.atom-grid--size_small .atom-grid__thead .atom-grid__th {\n  height: 36px;\n  font-size: 12px;\n}\n\n.atom-grid--size_small .atom-grid__td {\n  height: 36px;\n}\n\n.atom-grid--size_small .atom-grid__th--check,\n.atom-grid--size_small .atom-grid__td--check {\n  width: 36px;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/grid.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/multi-select.css",
      "content": "/* ============================================================================\n   Element — Multi Select\n   ----------------------------------------------------------------------------\n   A trigger that opens a listbox of checkable options. Unlike Select the\n   listbox stays open after a pick, so the trigger has to report what is\n   currently chosen — either as removable chips or as a count.\n\n   atom-multi-select\n     --size_{small|medium|large}             medium is the default\n     --variant_{weak|outline}                weak is the default\n     --display_{chip|count}                  chip is the default\n     [data-state=\"closed|open\"]  [data-empty]  [data-invalid]  [data-disabled]\n     __trigger  __value  __placeholder  __caret\n     __chip  __chip-label  __chip-remove  __overflow  __count\n     __listbox  __toolbar  __clear  __group-label\n     __option  __option-box  __option-check  __option-indeterminate\n     __option-icon  __option-text  __option-label  __option-description\n     __empty  __footer\n\n   The option box repeats the Checkbox visual rather than importing it: a card\n   preview loads one stylesheet, and pulling a second file in just for the box\n   would leave the element unusable on its own.\n   ========================================================================== */\n\n.atom-multi-select {\n  position: relative;\n  width: 100%;\n  min-width: 0;\n}\n\n/* --- trigger ------------------------------------------------------------- */\n.atom-multi-select__trigger {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--atom-x3);\n  width: 100%;\n  height: var(--atom-control-h);\n  padding-inline: var(--atom-x3) var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-neutral-weak);\n  font-size: 15px;\n  color: var(--atom-fg-neutral);\n  text-align: left;\n  transition: box-shadow var(--atom-duration-fast) ease,\n    filter var(--atom-duration-fast) ease;\n}\n\n/* Chips must never push the trigger taller — the row clips and the overflow\n   counter carries the rest. A wrapping trigger reflows the whole form every\n   time a box is ticked. */\n.atom-multi-select__value {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  flex-wrap: nowrap;\n}\n\n.atom-multi-select__placeholder {\n  color: var(--atom-fg-neutral-subtle);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-multi-select__caret {\n  width: 24px;\n  height: 24px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n  transition: transform var(--atom-duration-base) var(--atom-ease-out);\n}\n\n/* --- chips --------------------------------------------------------------- */\n.atom-multi-select__chip {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  min-width: 0;\n  max-width: 140px;\n  padding: 3px 4px 3px 9px;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1px var(--atom-border-subtle);\n  font-size: 13px;\n  line-height: 1.4;\n  color: var(--atom-fg-neutral);\n}\n\n.atom-multi-select__chip-label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-multi-select__chip-remove {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-full);\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease,\n    color var(--atom-duration-fast) ease;\n}\n\n.atom-multi-select__chip-remove svg {\n  width: 12px;\n  height: 12px;\n}\n\n.atom-multi-select__chip-remove:hover {\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral);\n}\n\n.atom-multi-select__overflow {\n  flex-shrink: 0;\n  padding: 3px 8px;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-neutral-weak);\n  font-size: 12px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-multi-select__count {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* --- listbox ------------------------------------------------------------- */\n.atom-multi-select__listbox {\n  margin-top: 6px;\n  padding: 6px;\n  border-radius: var(--atom-radius-r3);\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-shadow-s2), inset 0 0 0 1px var(--atom-border-subtle);\n}\n\n.atom-multi-select__toolbar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--atom-x3);\n  padding: 4px 10px 8px;\n  border-bottom: 1px solid var(--atom-border-subtle);\n  margin-bottom: 6px;\n}\n\n.atom-multi-select__clear {\n  flex-shrink: 0;\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n}\n\n.atom-multi-select__clear:hover {\n  color: var(--atom-fg-neutral);\n}\n\n.atom-multi-select__group-label {\n  padding: 6px 10px 4px;\n  font-size: 11px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-multi-select__option {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 9px 10px;\n  border-radius: var(--atom-radius-r2);\n  font-size: 14px;\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease;\n}\n\n/* --- option checkbox ----------------------------------------------------- */\n.atom-multi-select__option-box {\n  position: relative;\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-r1);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1.5px var(--atom-border-default);\n  transition: background-color var(--atom-duration-fast) ease,\n    box-shadow var(--atom-duration-fast) ease;\n}\n\n.atom-multi-select__option-check,\n.atom-multi-select__option-indeterminate {\n  position: absolute;\n  inset: 0;\n  margin: auto;\n  color: var(--atom-fg-on-solid);\n  opacity: 0;\n}\n\n.atom-multi-select__option-check {\n  width: 14px;\n  height: 14px;\n}\n\n.atom-multi-select__option-indeterminate {\n  width: 10px;\n  height: 2px;\n  border-radius: 1px;\n  background: var(--atom-fg-on-solid);\n}\n\n.atom-multi-select__option-icon {\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-multi-select__option-text {\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  flex: 1;\n}\n\n.atom-multi-select__option-label {\n  font-weight: 500;\n  color: var(--atom-fg-neutral);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-multi-select__option-description {\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-multi-select__empty {\n  padding: var(--atom-x5) 10px;\n  text-align: center;\n  font-size: 13px;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-multi-select__footer {\n  padding: 8px 10px 4px;\n  border-top: 1px solid var(--atom-border-subtle);\n  margin-top: 6px;\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-multi-select--size_small .atom-multi-select__trigger {\n  height: var(--atom-control-h-small);\n  font-size: 14px;\n}\n\n.atom-multi-select--size_small .atom-multi-select__caret {\n  width: 24px;\n  height: 24px;\n}\n\n.atom-multi-select--size_small .atom-multi-select__chip {\n  padding: 1px 3px 1px 8px;\n  font-size: 12px;\n}\n\n.atom-multi-select--size_large .atom-multi-select__trigger {\n  height: var(--atom-control-h-large);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n.atom-multi-select--size_large .atom-multi-select__chip {\n  font-size: 14px;\n}\n\n/* --- variants ------------------------------------------------------------ */\n.atom-multi-select--variant_outline .atom-multi-select__trigger {\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-control-border);\n}\n\n.atom-multi-select--variant_outline .atom-multi-select__chip {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n}\n\n/* --- display ------------------------------------------------------------- */\n/* One markup, two readings: the trigger carries both the chip row and the\n   count, and the modifier decides which one is shown. Swapping display must\n   not mean re-authoring the trigger. */\n/* chip is the default, so the count hides on the base class rather than on\n   --display_chip. Omitting the modifier has to give the documented default,\n   not both readings at once. */\n.atom-multi-select__count {\n  display: none;\n}\n\n.atom-multi-select--display_count .atom-multi-select__count {\n  display: block;\n}\n\n.atom-multi-select--display_count .atom-multi-select__chip,\n.atom-multi-select--display_count .atom-multi-select__overflow {\n  display: none;\n}\n\n.atom-multi-select[data-empty] .atom-multi-select__count,\n.atom-multi-select[data-empty] .atom-multi-select__chip,\n.atom-multi-select[data-empty] .atom-multi-select__overflow {\n  display: none;\n}\n\n.atom-multi-select:not([data-empty]) .atom-multi-select__placeholder {\n  display: none;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-multi-select__trigger:hover {\n  filter: brightness(0.96);\n}\n\n.atom-multi-select[data-state=\"open\"] .atom-multi-select__caret {\n  transform: rotate(180deg);\n}\n\n.atom-multi-select[data-state=\"closed\"] .atom-multi-select__listbox {\n  display: none;\n}\n\n.atom-multi-select__option[aria-selected=\"true\"] .atom-multi-select__option-box,\n.atom-multi-select__option[aria-checked=\"mixed\"] .atom-multi-select__option-box {\n  background: var(--atom-bg-neutral-solid);\n  box-shadow: inset 0 0 0 1.5px var(--atom-bg-neutral-solid);\n}\n\n.atom-multi-select__option[aria-selected=\"true\"]\n  .atom-multi-select__option-check {\n  opacity: 1;\n}\n\n.atom-multi-select__option[aria-checked=\"mixed\"]\n  .atom-multi-select__option-indeterminate {\n  opacity: 1;\n}\n\n.atom-multi-select__option:hover:not([aria-disabled=\"true\"]) {\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-multi-select__option[aria-disabled=\"true\"] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n/* One ring for every control — see the Control surface block in tokens.css.\n   The trigger is the focusable element here (Input hands focus to an inner\n   <input>), so base.css's outset ring is replaced rather than layered: an\n   outset ring with a 2px offset crosses the 4px gap to its neighbour when a\n   field puts this next to another control. Placed above the invalid rule so\n   invalid keeps outranking focus. */\n.atom-multi-select__trigger:focus-visible {\n  outline: none;\n  box-shadow: var(--atom-control-border-focus);\n}\n\n.atom-multi-select[data-invalid] .atom-multi-select__trigger {\n  box-shadow: var(--atom-control-border-invalid);\n}\n\n.atom-multi-select[data-disabled] {\n  opacity: 0.4;\n  pointer-events: none;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/multi-select.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/pagination.css",
      "content": "/* ============================================================================\n   Element — Pagination\n   ----------------------------------------------------------------------------\n   A row of page controls: first / previous / a window of numbers / next / last.\n   The window holds at most ten numbers and slides with the current page. There\n   is no ellipsis element, because a window that never grows past ten has\n   nothing to elide — adding one would be a promise the stylesheet cannot keep.\n\n   Every control is a <button>. They were <span>s while this lived inside the\n   Grid footer, which meant Tab never reached them; a control that changes what\n   the table shows has to be reachable without a pointer.\n\n   atom-pagination                            <nav aria-label=\"페이지\">\n     --size_{small|medium}                    medium is the default\n     __btn    one of the four edge controls — icon only, so each carries its\n              own aria-label\n     __page   one page number      [aria-current=\"page\"]\n\n   Both parts take [aria-disabled=\"true\"] and the [data-hover] documentation\n   hook.\n\n   The block carries no margin of its own. Where the row sits is the caller's\n   business — the Grid footer centres it against the Grid, a card list may leave\n   it at the inline start — and a margin here would fight both.\n   ========================================================================== */\n\n.atom-pagination {\n  display: flex;\n  align-items: center;\n  gap: 2px;\n}\n\n/* One shape for both kinds of control, so the row reads as a single strip\n   rather than icons with numbers dropped between them. min-width rather than\n   width: a four-digit page widens its own box instead of spilling out. */\n.atom-pagination__btn,\n.atom-pagination__page {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 30px;\n  height: 30px;\n  padding-inline: 6px;\n  border-radius: var(--atom-radius-r1);\n  font-size: 13px;\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease,\n    color var(--atom-duration-fast) ease;\n}\n\n.atom-pagination__btn svg {\n  width: 14px;\n  height: 14px;\n}\n\n/* The numbers under the cursor change as the user walks the set. tabular-nums\n   keeps 1 and 8 the same width so the row does not shift under the hand that\n   is about to press the same button again. */\n.atom-pagination__page {\n  font-variant-numeric: tabular-nums;\n}\n\n/* --- sizes ---------------------------------------------------------------- */\n/* medium is the default and needs no rule. */\n.atom-pagination--size_small .atom-pagination__btn,\n.atom-pagination--size_small .atom-pagination__page {\n  min-width: 26px;\n  height: 26px;\n  padding-inline: 5px;\n  font-size: 12px;\n}\n\n.atom-pagination--size_small .atom-pagination__btn svg {\n  width: 12px;\n  height: 12px;\n}\n\n/* --- states --------------------------------------------------------------- */\n/* `data-hover` is a documentation hook: it renders the hover treatment in a\n   demo or screenshot, where there is no pointer to produce it. */\n.atom-pagination__btn:hover,\n.atom-pagination__btn[data-hover],\n.atom-pagination__page:hover:not([aria-current=\"page\"]),\n.atom-pagination__page[data-hover]:not([aria-current=\"page\"]) {\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral);\n}\n\n/* The current page is filled rather than outlined. It is the one item in the\n   row that has to read as \"you are here\" at a glance instead of as another\n   target, and a border would put it in the same family as the rest. */\n.atom-pagination__page[aria-current=\"page\"] {\n  background: var(--atom-bg-neutral-solid);\n  color: var(--atom-fg-on-solid);\n  font-weight: 600;\n}\n\n/* The shared :focus-visible ring sits 2px outside the control, and the gap\n   between controls is 2px — so an outside ring lands on its neighbour. Pulled\n   inside, the way tabs.css does it for the same reason. */\n.atom-pagination__btn:focus-visible,\n.atom-pagination__page:focus-visible {\n  outline: 2px solid var(--atom-fg-brand);\n  outline-offset: -2px;\n}\n\n/* aria-disabled, not `disabled`: the control keeps its place in the tab order,\n   so a keyboard user reaching 처음으로 on page 1 is told it is there and\n   unavailable rather than finding a hole where it was. pointer-events is what\n   stops the click. */\n.atom-pagination__btn[aria-disabled=\"true\"],\n.atom-pagination__page[aria-disabled=\"true\"] {\n  opacity: 0.35;\n  pointer-events: none;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/pagination.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/select.css",
      "content": "/* ============================================================================\n   Element — Select\n   ----------------------------------------------------------------------------\n   A trigger that opens a listbox. Options carry an optional leading icon, a\n   label, a description and a selected check.\n\n   atom-select\n     --size_{small|medium|large}             medium is the default\n     --variant_{weak|outline}                weak is the default\n     [data-state=\"closed|open\"]  [data-invalid]  [data-disabled]\n     __trigger  __value  __caret\n     __listbox  __group-label  __option  __option-icon  __option-text\n     __option-label  __option-description  __option-check\n     __empty\n   ========================================================================== */\n\n.atom-select {\n  position: relative;\n  width: 100%;\n  min-width: 0;\n}\n\n/* --- trigger ------------------------------------------------------------- */\n.atom-select__trigger {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--atom-x3);\n  width: 100%;\n  height: var(--atom-control-h);\n  padding-inline: var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-neutral-weak);\n  font-size: 15px;\n  color: var(--atom-fg-neutral);\n  text-align: left;\n  transition: box-shadow var(--atom-duration-fast) ease,\n    filter var(--atom-duration-fast) ease;\n}\n\n.atom-select__value {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-select--empty .atom-select__value,\n.atom-select__trigger--empty .atom-select__value {\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-select__caret {\n  width: 24px;\n  height: 24px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n  transition: transform var(--atom-duration-base) var(--atom-ease-out);\n}\n\n/* --- listbox ------------------------------------------------------------- */\n.atom-select__listbox {\n  margin-top: 6px;\n  padding: 6px;\n  border-radius: var(--atom-radius-r3);\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-shadow-s2), inset 0 0 0 1px var(--atom-border-subtle);\n}\n\n.atom-select__group-label {\n  padding: 6px 10px 4px;\n  font-size: 11px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-select__option {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 9px 10px;\n  border-radius: var(--atom-radius-r2);\n  font-size: 14px;\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease;\n}\n\n.atom-select__option-icon {\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-select__option-text {\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  flex: 1;\n}\n\n.atom-select__option-label {\n  font-weight: 500;\n  color: var(--atom-fg-neutral);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-select__option-description {\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-select__option-check {\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral);\n  opacity: 0;\n}\n\n.atom-select__empty {\n  padding: var(--atom-x5) 10px;\n  text-align: center;\n  font-size: 13px;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-select--size_small .atom-select__trigger {\n  height: var(--atom-control-h-small);\n  font-size: 14px;\n}\n\n.atom-select--size_small .atom-select__caret {\n  width: 24px;\n  height: 24px;\n}\n\n.atom-select--size_large .atom-select__trigger {\n  height: var(--atom-control-h-large);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n/* --- variants ------------------------------------------------------------ */\n.atom-select--variant_outline .atom-select__trigger {\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-control-border);\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-select__trigger:hover {\n  filter: brightness(0.96);\n}\n\n/* One ring for every control — see the Control surface block in tokens.css.\n   The trigger is the focusable element here (Input hands focus to an inner\n   <input>), so base.css's outset ring is replaced rather than layered: an\n   outset ring with a 2px offset crosses the 4px gap to its neighbour when a\n   field puts this next to another control. Placed above the invalid rule so\n   invalid keeps outranking focus. */\n.atom-select__trigger:focus-visible {\n  outline: none;\n  box-shadow: var(--atom-control-border-focus);\n}\n\n.atom-select[data-state=\"open\"] .atom-select__caret {\n  transform: rotate(180deg);\n}\n\n.atom-select[data-state=\"closed\"] .atom-select__listbox {\n  display: none;\n}\n\n.atom-select__option[aria-selected=\"true\"] {\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-select__option[aria-selected=\"true\"] .atom-select__option-check {\n  opacity: 1;\n}\n\n.atom-select__option:hover:not([aria-disabled=\"true\"]) {\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-select__option[aria-disabled=\"true\"] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n.atom-select[data-invalid] .atom-select__trigger {\n  box-shadow: var(--atom-control-border-invalid);\n}\n\n.atom-select[data-disabled] {\n  opacity: 0.4;\n  pointer-events: none;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/select.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/text-input.css",
      "content": "/* ============================================================================\n   Element — Text Input & Textarea\n   ----------------------------------------------------------------------------\n   One box, two shapes. `--textarea` grows to a multi-line height; everything\n   else — sizes, prefix/suffix slots, states — is shared.\n\n   atom-text-input\n     --size_{small|medium|large}             medium is the default\n     --textarea\n     --variant_{outline|weak}                outline is the default\n     --focused  --invalid  --readonly\n     [disabled] on the inner control\n     __prefix  __suffix  __control  __placeholder  __caret  __clear\n   ========================================================================== */\n\n.atom-text-input {\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x2);\n  width: 100%;\n  min-width: 0;\n  min-height: var(--atom-control-h);\n  padding: var(--atom-x3) var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-control-border);\n  font-size: 15px;\n  line-height: 1.45;\n  color: var(--atom-fg-neutral);\n  transition: box-shadow var(--atom-duration-fast) ease;\n}\n\n/* The real <input>/<textarea>. Borderless — the wrapper draws the box. */\n.atom-text-input__control {\n  flex: 1;\n  min-width: 0;\n  border: 0;\n  padding: 0;\n  background: none;\n  outline: none;\n  font: inherit;\n  color: inherit;\n  resize: none;\n}\n\n.atom-text-input__control::placeholder {\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-text-input__prefix,\n.atom-text-input__suffix {\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n  font-size: 14px;\n}\n\n.atom-text-input__prefix svg,\n.atom-text-input__suffix svg {\n  width: 18px;\n  height: 18px;\n}\n\n.atom-text-input__clear {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-text-input__clear svg {\n  width: 12px;\n  height: 12px;\n}\n\n/* Static stand-ins so a demo can show placeholder / caret without focus. */\n.atom-text-input__placeholder {\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-text-input__caret {\n  display: inline-block;\n  width: 1.5px;\n  height: 19px;\n  flex-shrink: 0;\n  margin-right: 1px;\n  background: var(--atom-fg-neutral);\n  animation: atom-text-input-blink 1.1s steps(1) infinite;\n}\n\n@keyframes atom-text-input-blink {\n  50% {\n    opacity: 0;\n  }\n}\n\n/* --- textarea ------------------------------------------------------------ */\n.atom-text-input--textarea {\n  min-height: 92px;\n  align-items: flex-start;\n}\n\n.atom-text-input--textarea .atom-text-input__control {\n  min-height: 66px;\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-text-input--size_small {\n  min-height: var(--atom-control-h-small);\n  padding: var(--atom-x2) var(--atom-x3);\n  font-size: 14px;\n}\n\n.atom-text-input--size_small .atom-text-input__caret {\n  height: 17px;\n}\n\n.atom-text-input--size_large {\n  min-height: var(--atom-control-h-large);\n  padding-inline: var(--atom-x5);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n/* --- variants ------------------------------------------------------------ */\n.atom-text-input--variant_weak {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-text-input:hover:not(.atom-text-input--focused):not(\n    .atom-text-input--invalid\n  ):not(.atom-text-input--readonly) {\n  box-shadow: var(--atom-control-border-hover);\n}\n\n.atom-text-input--focused,\n.atom-text-input:focus-within {\n  box-shadow: var(--atom-control-border-focus);\n}\n\n.atom-text-input--invalid,\n.atom-text-input--invalid:focus-within {\n  box-shadow: var(--atom-control-border-invalid);\n}\n\n.atom-text-input--readonly {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-text-input:has(.atom-text-input__control[disabled]),\n.atom-text-input--disabled {\n  opacity: 0.45;\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n  cursor: not-allowed;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/text-input.css"
    }
  ],
  "categories": [
    "display"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "Grid"
    ],
    "docs": "poc/design-system/components/grid.md"
  }
}
