{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "The ruled grid. Frozen panes, hidden columns, hover change, ⌘C.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useColumnWidths } from \"@/hooks/ledger/use-column-widths\";\nimport { useGridNav } from \"@/hooks/ledger/use-grid-nav\";\nimport { useScrollEdges } from \"@/hooks/ledger/use-scroll-edges\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatMoney, formatMoneyInput, formatPercent, parseMoneyInput, rawMoney } from \"@/lib/ledger/format-money\";\nimport type { Density, MoneyValue, Polarity } from \"@/lib/ledger/money.types\";\nimport { describeChange, type PeriodChange, periodChange, shareOfBase } from \"@/lib/ledger/period-change\";\nimport { ColumnManager, type ManagedColumn } from \"@/components/ledger/column-manager\";\nimport { Money } from \"@/components/ledger/money\";\n\n/* ── The shapes ──────────────────────────────────────────────────────── */\n\nexport type GridUnit = \"money\" | \"percent\" | \"points\" | \"count\" | \"ratio\" | \"days\";\n\nexport type GridRowVariant = \"line\" | \"subtotal\" | \"total\" | \"section\" | \"ratio\";\n\nexport interface PeriodColumn {\n  /**\n   * The column this one is measured against, by id. Defaults to the previous\n   * visible column, which is what a month-by-month grid wants.\n   *\n   * Naming it explicitly is what lets one grid be a budget-versus-actual: the\n   * actual column compares to budget rather than to the month before it, and\n   * the comparison survives the reader hiding the budget column — the\n   * relationship is a property of the model, not of what is on screen.\n   */\n  compare?: string;\n  /** Starts hidden. The reader can bring it back from the column menu. */\n  hidden?: boolean;\n  /** Extra context under the header, e.g. \"board-approved 12 Feb\". */\n  hint?: string;\n  id: string;\n  /**\n   * What the column holds. Drives the sub-label and the projected styling —\n   * a forecast column that looks like an actual is the single most expensive\n   * mistake this grid can make.\n   */\n  kind?: \"actual\" | \"forecast\" | \"budget\" | \"prior\" | \"derived\";\n  /** \"Oct\", \"Q3 FY26\", \"FY26 budget\". Resolved, never \"Current\". */\n  label: string;\n  /** Cannot be hidden — a grid with no periods left is not a grid. */\n  locked?: boolean;\n  width?: number;\n}\n\nexport interface PeriodRow {\n  /** A computed row. Marked, so nobody reconciles it to a source ledger. */\n  derived?: boolean;\n  id: string;\n  label: string;\n  /** 1–5. Indents the label; deeper than 5 is a chart-of-accounts problem. */\n  level?: number;\n  note?: string;\n  /**\n   * Required, no default. Every row prints its change, so every row's movement\n   * is read as good or bad news: `inverted` for cost, `neutral` for a headcount.\n   */\n  polarity: Polarity;\n  unit?: GridUnit;\n  /**\n   * One entry per column in `columns` — including hidden ones, so that hiding\n   * a column never renumbers the data behind it. `null` is \"we do not have\n   * this figure\". It is never 0.\n   */\n  values: (number | null)[];\n  variant?: GridRowVariant;\n}\n\n/**\n * Everything known about one cell, read once and handed to the three places\n * that speak about it — the figure, its sub-line and the formula bar. Three\n * call sites computing the same change three times is how a sub-line ends up\n * disagreeing with the row it sits on.\n */\nexport interface PeriodCellRead {\n  change: PeriodChange;\n  column: PeriodColumn;\n  /** The change cleared both materiality thresholds. */\n  material: boolean;\n  /**\n   * Worth printing without being asked. A grid that annotates all ninety of\n   * its cells equally has annotated none of them — the eye needs somewhere to\n   * land. Everything quieter than this waits for the row to be pointed at.\n   */\n  notable: boolean;\n  /** The column this cell's change is measured against, if there is one. */\n  priorLabel: string | null;\n  projected: boolean;\n  row: PeriodRow;\n  /** Share of the base row in the same column, when a base row is named. */\n  share: number | null;\n  unit: GridUnit;\n  value: number | null;\n}\n\n/**\n * What each cell carries under its figure.\n *\n * Not a view switch. The figure is always the figure — a grid whose numbers\n * are replaced by percentages when a tab is pressed is a grid the reader has\n * to keep re-reading to work out what they are looking at, and the question\n * \"what moved\" is never asked *instead of* \"how much is it\". Both lines are\n * on screen together, or the change line is off.\n */\nexport interface GridAnnotations {\n  /** Movement against the comparison column, under every figure. */\n  change: boolean;\n  /** Share of the named base row, in the same column. */\n  share: boolean;\n}\n\nexport interface PeriodGridProps {\n  /** Index in `columns` of the last actual. Everything after it is projection. */\n  actualThrough?: number;\n  annotations?: Partial<GridAnnotations>;\n  /** Names the table for a screen reader. Required — a grid of figures with no caption is a grid of figures. */\n  caption: string;\n  className?: string;\n  columns: PeriodColumn[];\n  currency: string;\n  defaultAnnotations?: Partial<GridAnnotations>;\n  density?: Density;\n  /**\n   * Which cells the reader may type into. Default: none — a grid over posted\n   * figures is a report, and a report that accepts typing is a report someone\n   * will believe they have changed.\n   *\n   * The obvious use is a driver model: the assumptions are editable, the\n   * actuals behind them are not.\n   */\n  editable?: (row: PeriodRow, column: PeriodColumn) => boolean;\n  /** Shown under the grid. The basis, the source, the rounding note. */\n  footnote?: React.ReactNode;\n  /** The frozen column's header. \"Line\", \"Driver\", \"Category\", \"Vendor\". */\n  labelHeader?: string;\n  labelWidth?: number;\n  /**\n   * Both thresholds must be exceeded before a change is flagged. A 200% swing\n   * on $12 is not news and $40,000 on $8M is not either, and a grid that\n   * flags both trains the reader to ignore the flag.\n   */\n  materiality?: { absolute: number; relative: number };\n  /**\n   * Caps the scroller so the header can freeze. Without a height the page\n   * scrolls instead and there is nothing for `position: sticky` to stick to.\n   */\n  maxHeight?: number;\n  onAnnotationsChange?: (annotations: GridAnnotations) => void;\n  /**\n   * A committed edit, in minor units. The grid does not hold the value — the\n   * caller owns the model and re-renders with the new figure, because the\n   * cells that *depend* on this one have to move at the same moment.\n   */\n  onCellEdit?: (edit: {\n    rowId: string;\n    columnId: string;\n    rowIndex: number;\n    columnIndex: number;\n    value: number;\n  }) => void;\n  rows: PeriodRow[];\n  /** The row every `share` percentage is taken against — usually revenue. */\n  shareBase?: string;\n  /** Offer the toggles that turn the two sub-lines on and off. */\n  showAnnotationToggles?: boolean;\n  showColumnManager?: boolean;\n  /** Persists hidden columns and widths per user per grid. */\n  storageKey?: string;\n  /** Extra controls in the toolbar — a scenario picker, a period range. */\n  toolbar?: React.ReactNode;\n}\n\n/* ── Density ─────────────────────────────────────────────────────────── */\n\nconst ROW_PADDING: Record<Density, string> = {\n  comfortable: \"px-4 py-3\",\n  compact: \"px-2.5 py-1\",\n  default: \"px-3 py-2\",\n};\n\nconst DEFAULT_ANNOTATIONS: GridAnnotations = { change: true, share: false };\n\n/* ── Formatting ──────────────────────────────────────────────────────── */\n\n/**\n * Non-money units are stored the way money is — as integers, scaled by 100 —\n * so that one grid can hold headcount, days sales outstanding and a gross\n * margin without three rounding conventions fighting inside one column.\n */\nfunction formatUnit(value: number, unit: GridUnit, currency: string): string {\n  switch (unit) {\n    case \"money\":\n      return formatMoney({ amount: value, currency }, { accounting: true });\n    case \"percent\":\n      /* `relative`, not `rate`: a rate is the three-decimal FX kind, and a\n         growth rate printed to three decimals (\"38.890%\") reads as a\n         precision the underlying figure does not have. */\n      return formatPercent({ kind: \"relative\", value: value / 100 }, { signed: true });\n    case \"points\":\n      return formatPercent({ kind: \"points\", value: value / 100 }, { signed: true });\n    case \"ratio\":\n      return `${(value / 100).toFixed(2)}×`;\n    case \"days\":\n      return `${Math.round(value / 100)} d`;\n    case \"count\":\n      return (value / 100).toLocaleString(undefined, { maximumFractionDigits: 1 });\n    default:\n      return String(value);\n  }\n}\n\nfunction rawOf(value: number, unit: GridUnit, currency: string): string {\n  return unit === \"money\" ? rawMoney({ amount: value, currency }) : String(value / 100);\n}\n\n/* ── The grid ────────────────────────────────────────────────────────── */\n\n/**\n * The spreadsheet an accountant already knows, with the discipline of a\n * statement underneath it.\n *\n * Everything that reads period-by-period — the forecast, budget versus\n * actual, the revenue tracker, the expense tracker, the driver model —\n * is a configuration of this one grid rather than another table written\n * from scratch. Six behaviours are the reason it exists at all, and every\n * one of them is something people already do in Excel within a minute of\n * opening a model:\n *\n * 1. **Freeze panes.** The top row and the left column. A wide grid whose\n *    account names scroll away is a grid of anonymous numbers, and the fix\n *    is the first thing anyone does to a workbook.\n * 2. **Ruled cells.** A period grid is read down a column as often as\n *    across a row; a column with no left edge is one the eye loses.\n * 3. **Hide columns.** Nobody reviews fourteen months at once. They hide\n *    the ones they are not asking about — and hiding is not deleting, so\n *    the data behind a hidden column keeps its index.\n * 4. **Drag the column edges.** Account names have no natural length.\n * 5. **The change under the figure.** The figure answers \"how much\"; the\n *    reader's next question is always \"versus what\", and asking it should\n *    not cost a hover, a click or a second table. It is printed under every\n *    figure, small and toned, so a row can be read across for its movement\n *    and down its column for its amount without either one moving.\n * 6. **Arrow keys and ⌘C.** They arrived from a spreadsheet and they are\n *    going back to one — the clipboard carries the value, not the\n *    presentation, so the paste sums.\n *\n * What it deliberately refuses to do:\n *\n * - **It does not animate a figure.** A value that moves under the eye is a\n *   value that has to be read twice. The crosshair is the only thing that\n *   changes on hover, and it is not a number the reader might take as truth.\n * - **It does not hide a fact behind the pointer.** The movement is on\n *   screen for every cell at once, because the reason anyone opens this grid\n *   is to compare, and a comparison you can only make one cell at a time is\n *   not a comparison.\n * - **It does not invent a percentage.** No ratio against a zero base, none\n *   against a negative base. The sub-line says which, rather than printing a\n *   number that reads backwards.\n * - **It does not treat a missing figure as zero.** A month that has not\n *   posted renders as `n/a`, never as `0`, and its change stays absent too.\n */\nexport function PeriodGrid({\n  caption,\n  columns,\n  rows,\n  currency,\n  labelHeader = \"Line\",\n  storageKey,\n  actualThrough,\n  annotations: controlledAnnotations,\n  defaultAnnotations,\n  onAnnotationsChange,\n  showAnnotationToggles = true,\n  showColumnManager = true,\n  shareBase,\n  labelWidth = 220,\n  density = \"compact\",\n  maxHeight = 520,\n  materiality,\n  editable,\n  onCellEdit,\n  toolbar,\n  footnote,\n  className,\n}: PeriodGridProps) {\n  /* ── Column visibility ─────────────────────────────────────────────\n     Held as the full ordered list rather than as a set of hidden ids, so\n     that the column menu can reorder as well as hide and the two operations\n     stay one piece of state. */\n  const [managed, setManaged] = React.useState<ManagedColumn[]>(() =>\n    columns.map((c) => ({\n      id: c.id,\n      label: c.label,\n      locked: c.locked,\n      visible: !c.hidden,\n    })),\n  );\n\n  /* A grid whose columns change shape — a scenario swap, a period range —\n     re-seeds rather than carrying a stale visibility map for columns that no\n     longer exist. Preferences for columns that survived are kept. */\n  React.useEffect(() => {\n    setManaged((current) => {\n      const seen = new Map(current.map((c) => [c.id, c]));\n      if (columns.length === current.length && columns.every((c) => seen.has(c.id))) {\n        return current;\n      }\n      return columns.map((c) => ({\n        id: c.id,\n        label: c.label,\n        locked: c.locked,\n        visible: seen.get(c.id)?.visible ?? !c.hidden,\n      }));\n    });\n  }, [columns]);\n\n  const byId = React.useMemo(() => new Map(columns.map((c, i) => [c.id, i])), [columns]);\n\n  /** Visible columns, in the reader's order, each carrying its source index. */\n  const visible = React.useMemo(\n    () =>\n      managed\n        .filter((m) => m.visible)\n        .map((m) => ({ column: columns[byId.get(m.id) ?? 0], index: byId.get(m.id) ?? 0 }))\n        .filter((c) => c.column != null),\n    [managed, columns, byId],\n  );\n\n  const hiddenCount = managed.length - visible.length;\n\n  /* ── Widths ────────────────────────────────────────────────────────── */\n  const widthSpecs = React.useMemo(\n    () => [\n      { id: \"__label\", max: 480, min: 120, width: labelWidth },\n      ...columns.map((c) => ({ id: c.id, max: 280, min: 72, width: c.width ?? 116 })),\n    ],\n    [columns, labelWidth],\n  );\n  const { widths, resizing, startResize, onResizeKeyDown, reset } = useColumnWidths({\n    columns: widthSpecs,\n    storageKey,\n  });\n\n  /* ── Annotations ───────────────────────────────────────────────────── */\n  const [uncontrolled, setUncontrolled] = React.useState<GridAnnotations>({\n    ...DEFAULT_ANNOTATIONS,\n    ...defaultAnnotations,\n  });\n  const merged: GridAnnotations = { ...uncontrolled, ...controlledAnnotations };\n  const setAnnotation = (key: keyof GridAnnotations, on: boolean) => {\n    const next = { ...merged, [key]: on };\n    setUncontrolled(next);\n    onAnnotationsChange?.(next);\n  };\n\n  const baseRow = React.useMemo(\n    () => (shareBase ? rows.find((r) => r.id === shareBase) : undefined),\n    [rows, shareBase],\n  );\n  /* \"% of\" is a question about a base that has to exist. Offering the toggle\n     with nothing to divide by would print a column of n/a and look broken. */\n  const annotations: GridAnnotations = {\n    change: merged.change,\n    share: merged.share && baseRow != null,\n  };\n\n  /* ── Hover ─────────────────────────────────────────────────────────── */\n  const [hover, setHover] = React.useState<{ row: number; col: number } | null>(null);\n\n  /* ── Editing ───────────────────────────────────────────────────────── */\n  const [editing, setEditing] = React.useState<{ row: number; col: number } | null>(null);\n  /* The figure is what gets typed into — never a sub-line. A change or a\n     share is arithmetic over two cells, and there is no single figure an\n     edit to one of them would mean. */\n  const canEdit = React.useCallback(\n    (rowIndex: number, visibleIndex: number) => {\n      if (!editable || !onCellEdit) return false;\n      const row = rows[rowIndex];\n      const column = visible[visibleIndex]?.column;\n      return row != null && column != null && editable(row, column);\n    },\n    [editable, onCellEdit, rows, visible],\n  );\n\n  const commitEdit = React.useCallback(\n    (rowIndex: number, visibleIndex: number, text: string) => {\n      setEditing(null);\n      const row = rows[rowIndex];\n      const source = visible[visibleIndex];\n      if (!row || !source || !onCellEdit) return;\n      /* The repo's own parser, so a cell accepts everything the amount input\n         does — \"1.2k\", \"(1,240)\", and \"=1200+340\", which is the habit every\n         reader of this grid brings from the spreadsheet it replaced. */\n      const parsed = parseMoneyInput(text, currency);\n      if (parsed == null) return;\n      /* Non-money units are held scaled by 100 too, so a headcount typed as\n         \"12\" is stored as 1200 — the same integer contract as money, and the\n         same single rounding at the end. */\n      onCellEdit({\n        columnId: source.column.id,\n        columnIndex: source.index,\n        rowId: row.id,\n        rowIndex,\n        value: parsed,\n      });\n    },\n    [rows, visible, onCellEdit, currency],\n  );\n\n  /* ── Scroll shadows ────────────────────────────────────────────────── */\n  const scrollerRef = useScrollEdges<HTMLDivElement>();\n\n  /* ── Keyboard ──────────────────────────────────────────────────────── */\n  const cellText = React.useCallback(\n    (rowIndex: number, colIndex: number) => {\n      const row = rows[rowIndex];\n      if (!row) return { text: \"\" };\n      /* Column 0 is the frozen label column; the figures start at 1. */\n      if (colIndex === 0) return { text: row.label };\n      const source = visible[colIndex - 1];\n      if (!source) return { text: \"\" };\n      const value = row.values[source.index] ?? null;\n      if (value == null) return { text: \"n/a\" };\n      const unit = row.unit ?? \"money\";\n      return { raw: rawOf(value, unit, currency), text: formatUnit(value, unit, currency) };\n    },\n    [rows, visible, currency],\n  );\n\n  /* The keyboard cursor, readable from a callback that was created before\n     the hook returned. `useGridNav` hands `onActivate` the row only, and\n     opening a cell for editing needs the column too. */\n  const cursorRef = React.useRef({ col: 0, row: 0 });\n\n  const nav = useGridNav({\n    colCount: visible.length + 1,\n    getCell: cellText,\n    /* Enter opens the focused cell, the way it does in every spreadsheet —\n       and only where the model says typing is allowed. */\n    onActivate: () => {\n      const { row, col } = cursorRef.current;\n      if (col > 0 && canEdit(row, col - 1)) setEditing({ col: col - 1, row });\n    },\n    onEscape: () => setHover(null),\n    rowCount: rows.length,\n  });\n  cursorRef.current = nav.cell;\n\n  /* ── Reading a cell ────────────────────────────────────────────────── */\n\n  /**\n   * Everything the chip, the formula bar and the `aria-label` say about one\n   * cell, computed once. Three call sites reading the same figure three ways\n   * is how a tooltip ends up disagreeing with the row it is sitting on.\n   */\n  const readCell = React.useCallback(\n    (rowIndex: number, visibleIndex: number): PeriodCellRead | null => {\n      const row = rows[rowIndex];\n      const here = visible[visibleIndex];\n      if (!row || !here) return null;\n\n      const unit = row.unit ?? \"money\";\n      const value = row.values[here.index] ?? null;\n\n      /* A declared comparison wins over position, and it is resolved against\n         the full column list rather than the visible one — hiding the budget\n         column must not silently re-point the variance at last month. */\n      const declared = here.column.compare != null ? byId.get(here.column.compare) : undefined;\n      const previous =\n        declared != null\n          ? { column: columns[declared], index: declared }\n          : visibleIndex > 0\n            ? visible[visibleIndex - 1]\n            : null;\n      const priorValue = previous ? (row.values[previous.index] ?? null) : null;\n      const change = periodChange(value, priorValue, row.polarity);\n      /* A share is a mix question, and it is only a mix question between two\n         figures of the same unit: \"51.8% of operating expense\" is a sentence\n         about money, and asking it of a margin row would divide a percentage\n         by a currency. */\n      const share =\n        baseRow && unit === \"money\" ? shareOfBase(value, baseRow.values[here.index] ?? null) : null;\n\n      const material =\n        materiality != null &&\n        change.absolute != null &&\n        Math.abs(change.absolute) >= materiality.absolute &&\n        change.relative != null &&\n        Math.abs(change.relative) >= materiality.relative;\n\n      return {\n        change,\n        column: here.column,\n        material,\n        /* With thresholds declared, the caller has already said what counts.\n           Without them, a tenth is the fallback: big enough that an FP&A\n           reader would ask about it, small enough that a real move is never\n           silently hidden. */\n        notable: materiality != null ? material : change.relative != null && Math.abs(change.relative) >= 10,\n        priorLabel: previous?.column.label ?? null,\n        projected: actualThrough != null && here.index > actualThrough,\n        row,\n        share,\n        unit,\n        value,\n      };\n    },\n    [rows, visible, columns, byId, baseRow, actualThrough, materiality],\n  );\n\n  const focusRead = nav.cell.col > 0 ? readCell(nav.cell.row, nav.cell.col - 1) : null;\n  const hoverRead = hover ? readCell(hover.row, hover.col) : null;\n  /* The formula bar prefers the pointer, because the pointer is where the\n     reader is looking; it falls back to the keyboard cursor so the bar is not\n     blank for someone who never touches the mouse. */\n  const barRead = hoverRead ?? focusRead;\n\n  const moneyFormat = React.useCallback(\n    (minor: number) => formatMoney({ amount: minor, currency }),\n    [currency],\n  );\n\n  const totalCols = visible.length + 1;\n\n  return (\n    <div className={cn(\"space-y-2\", className)} data-density={density} onPointerLeave={() => setHover(null)}>\n      {/* ── Toolbar ─────────────────────────────────────────────────── */}\n      {toolbar || showAnnotationToggles || showColumnManager ? (\n        <div className=\"flex flex-wrap items-center gap-3\">\n          {toolbar}\n\n          {showAnnotationToggles ? (\n            <div\n              aria-label=\"What is printed under each figure\"\n              className=\"inline-flex overflow-hidden rounded-sm border border-border\"\n              role=\"group\"\n            >\n              <AnnotationToggle\n                hint=\"Movement against the comparison column, under every figure\"\n                onClick={() => setAnnotation(\"change\", !annotations.change)}\n                pressed={annotations.change}\n              >\n                % change\n              </AnnotationToggle>\n              {baseRow ? (\n                <AnnotationToggle\n                  hint={`Share of ${baseRow.label}, within the same column`}\n                  onClick={() => setAnnotation(\"share\", !annotations.share)}\n                  pressed={annotations.share}\n                >\n                  % of {baseRow.label}\n                </AnnotationToggle>\n              ) : null}\n            </div>\n          ) : null}\n\n          {showColumnManager ? (\n            <span className=\"flex items-center gap-2\">\n              <ColumnManager columns={managed} onChange={setManaged} />\n              {/* The count is the recovery path: a reader who hid six columns\n                  last quarter needs to know this grid is not the whole story. */}\n              {hiddenCount > 0 ? (\n                <button\n                  className=\"text-caption text-text-secondary underline decoration-dotted underline-offset-2 hover:text-text-primary\"\n                  onClick={() => setManaged((current) => current.map((c) => ({ ...c, visible: true })))}\n                  type=\"button\"\n                >\n                  {hiddenCount} column{hiddenCount === 1 ? \"\" : \"s\"} hidden — show all\n                </button>\n              ) : null}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n\n      {/* ── The grid ────────────────────────────────────────────────── */}\n      <div className=\"overflow-hidden rounded-xl border border-border bg-surface\">\n        <div\n          aria-label={caption}\n          className=\"ledger-grid scrollbar-thin\"\n          onKeyDown={nav.onKeyDown}\n          ref={scrollerRef}\n          role=\"group\"\n          style={{ maxHeight }}\n          /* One tab stop, then arrow keys — the ARIA grid pattern, and the\n             only shape in which a 400-row grid is not 400 tab stops. */\n          tabIndex={0}\n        >\n          <table className=\"w-full\" style={{ minWidth: \"100%\", tableLayout: \"fixed\", width: \"max-content\" }}>\n            <caption className=\"sr-only\">{caption}</caption>\n\n            <colgroup>\n              <col style={{ width: widths.__label }} />\n              {visible.map((c) => (\n                <col key={c.column.id} style={{ width: widths[c.column.id] }} />\n              ))}\n            </colgroup>\n\n            <thead>\n              <tr>\n                <th\n                  className={cn(\"text-left text-label font-medium text-text-secondary\", ROW_PADDING[density])}\n                  data-frozen=\"left top\"\n                  scope=\"col\"\n                >\n                  {labelHeader}\n                  <ResizeHandle\n                    column=\"__label\"\n                    label={labelHeader}\n                    onResizeKeyDown={onResizeKeyDown}\n                    reset={reset}\n                    resizing={resizing}\n                    startResize={startResize}\n                    width={widths.__label}\n                  />\n                </th>\n\n                {visible.map((c, i) => {\n                  const projected = actualThrough != null && c.index > actualThrough;\n                  const first =\n                    projected &&\n                    (i === 0 || (actualThrough != null && visible[i - 1].index <= actualThrough));\n                  return (\n                    <th\n                      className={cn(\n                        \"text-right text-label font-medium text-text-secondary transition-colors duration-1 ease-standard\",\n                        ROW_PADDING[density],\n                      )}\n                      data-cross={hover?.col === i ? \"head\" : undefined}\n                      data-frozen=\"top\"\n                      data-projected={projected ? (first ? \"start\" : \"yes\") : undefined}\n                      key={c.column.id}\n                      scope=\"col\"\n                    >\n                      <span className=\"block truncate\">\n                        <span className=\"block truncate\">\n                          {c.column.label}\n                          {c.column.kind && c.column.kind !== \"actual\" ? (\n                            <span className=\"block text-caption font-normal text-text-tertiary\">\n                              {c.column.kind}\n                            </span>\n                          ) : null}\n                          {c.column.hint ? (\n                            <span className=\"block text-caption font-normal text-text-tertiary\">\n                              {c.column.hint}\n                            </span>\n                          ) : null}\n                        </span>\n                      </span>\n                      <ResizeHandle\n                        column={c.column.id}\n                        label={c.column.label}\n                        onResizeKeyDown={onResizeKeyDown}\n                        reset={reset}\n                        resizing={resizing}\n                        startResize={startResize}\n                        width={widths[c.column.id]}\n                      />\n                    </th>\n                  );\n                })}\n              </tr>\n            </thead>\n\n            <tbody>\n              {rows.map((row, rowIndex) => {\n                const variant = row.variant ?? \"line\";\n                const emphasised = variant === \"subtotal\" || variant === \"total\";\n                const section = variant === \"section\";\n\n                return (\n                  <tr\n                    className={cn(\"group\", emphasised && \"bg-surface-sunken\", section && \"bg-surface-sunken\")}\n                    data-cross={hover?.row === rowIndex ? \"\" : undefined}\n                    data-row-variant={variant}\n                    key={row.id}\n                    style={\n                      emphasised ? { [\"--ledger-row-tint\" as string]: \"var(--surface-sunken)\" } : undefined\n                    }\n                  >\n                    <th\n                      className={cn(\n                        \"text-left text-body font-normal transition-colors duration-1 ease-standard\",\n                        ROW_PADDING[density],\n                        emphasised && \"font-medium\",\n                        section && \"text-label font-medium uppercase tracking-wide text-text-secondary\",\n                      )}\n                      data-cross={hover?.row === rowIndex ? \"head\" : undefined}\n                      data-frozen=\"left\"\n                      data-level={row.level}\n                      scope=\"row\"\n                      style={{ paddingLeft: 10 + ((row.level ?? 1) - 1) * 14 }}\n                    >\n                      <span className=\"block truncate\" title={row.label}>\n                        {row.label}\n                        {row.derived ? (\n                          <span\n                            className=\"ml-1.5 text-caption text-text-tertiary\"\n                            title=\"Computed here, not read from the ledger\"\n                          >\n                            ƒ\n                          </span>\n                        ) : null}\n                      </span>\n                      {row.note ? (\n                        <span className=\"block truncate text-caption text-text-tertiary\">{row.note}</span>\n                      ) : null}\n                    </th>\n\n                    {visible.map((c, colIndex) => {\n                      if (section) {\n                        return (\n                          <td\n                            data-projected={\n                              actualThrough != null && c.index > actualThrough ? \"yes\" : undefined\n                            }\n                            key={c.column.id}\n                          />\n                        );\n                      }\n\n                      const read = readCell(rowIndex, colIndex);\n                      const hovered = hover?.row === rowIndex && hover.col === colIndex;\n                      const writable = canEdit(rowIndex, colIndex);\n                      const isEditing = editing?.row === rowIndex && editing.col === colIndex && writable;\n                      const focused = nav.cell.row === rowIndex && nav.cell.col === colIndex + 1;\n                      const projected = read?.projected ?? false;\n                      const first =\n                        projected &&\n                        (colIndex === 0 ||\n                          (actualThrough != null && visible[colIndex - 1].index <= actualThrough));\n\n                      return (\n                        <td\n                          className={cn(\n                            \"text-right align-middle transition-colors duration-1 ease-standard\",\n                            ROW_PADDING[density],\n                            focused && \"outline outline-2 -outline-offset-2 outline-[var(--accent)]\",\n                            writable && !isEditing && \"cursor-cell\",\n                          )}\n                          data-cell-hover={hovered ? \"\" : undefined}\n                          data-cross={hover?.col === colIndex ? \"\" : undefined}\n                          data-projected={projected ? (first ? \"start\" : \"yes\") : undefined}\n                          key={c.column.id}\n                          onDoubleClick={\n                            writable ? () => setEditing({ col: colIndex, row: rowIndex }) : undefined\n                          }\n                          onFocus={() => nav.setCell({ col: colIndex + 1, row: rowIndex })}\n                          onPointerEnter={() => setHover({ col: colIndex, row: rowIndex })}\n                        >\n                          {isEditing ? (\n                            <CellEditor\n                              initial={\n                                read?.value == null\n                                  ? \"\"\n                                  : (read.unit ?? \"money\") === \"money\"\n                                    ? formatMoneyInput(read.value, currency)\n                                    : String(read.value / 100)\n                              }\n                              onCancel={() => setEditing(null)}\n                              onCommit={(text) => commitEdit(rowIndex, colIndex, text)}\n                            />\n                          ) : (\n                            <span\n                              className={cn(\n                                writable &&\n                                  \"underline decoration-dotted decoration-[var(--accent-border)] underline-offset-4\",\n                              )}\n                            >\n                              <CellFigure\n                                annotations={annotations}\n                                currency={currency}\n                                emphasised={emphasised}\n                                read={read}\n                              />\n                            </span>\n                          )}\n                        </td>\n                      );\n                    })}\n                  </tr>\n                );\n              })}\n            </tbody>\n          </table>\n        </div>\n\n        {/* ── The formula bar ─────────────────────────────────────────\n            Reserved height, always present. A strip that appears on hover\n            would shift the page every time the pointer crossed the grid. */}\n        <div\n          aria-live=\"polite\"\n          className=\"flex min-h-[34px] flex-wrap items-center gap-x-3 gap-y-1 border-t border-border bg-surface-sunken px-3 py-1.5 text-caption text-text-secondary\"\n        >\n          {barRead ? (\n            <>\n              <span className=\"font-medium text-text-primary\">\n                {barRead.row.label} · {barRead.column.label}\n              </span>\n              <span className=\"numeric\">\n                {barRead.value == null ? \"n/a\" : formatUnit(barRead.value, barRead.unit, currency)}\n              </span>\n              <span>\n                {describeChange(barRead.change, moneyFormat, barRead.priorLabel ?? \"the previous column\")}\n              </span>\n              {barRead.share != null ? (\n                <span className=\"numeric\">\n                  {barRead.share.toFixed(1)}% of {baseRow?.label}\n                </span>\n              ) : null}\n              {barRead.projected ? (\n                <span className=\"rounded-xs bg-surface px-1.5 py-0.5 text-text-tertiary\">\n                  projection, not an actual\n                </span>\n              ) : null}\n            </>\n          ) : (\n            <span className=\"text-text-tertiary\">\n              Point at a row for its movement. Arrow keys navigate, ⌘C copies values.\n            </span>\n          )}\n        </div>\n      </div>\n\n      {footnote ? <div className=\"text-caption text-text-tertiary\">{footnote}</div> : null}\n      <span className=\"sr-only\">\n        Grid of {rows.length} rows and {totalCols - 1} periods.\n      </span>\n    </div>\n  );\n}\n\n/* ── The toolbar toggles ─────────────────────────────────────────────── */\n\n/**\n * On or off, not one-of-four. Each toggle adds a line under the figures; it\n * never replaces them, so the reader can never end up looking at a grid whose\n * numbers are not the numbers.\n */\nfunction AnnotationToggle({\n  pressed,\n  onClick,\n  hint,\n  children,\n}: {\n  pressed: boolean;\n  onClick: () => void;\n  hint: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      aria-pressed={pressed}\n      className={cn(\n        \"h-8 border-r border-border px-2.5 text-label transition-colors duration-1 ease-standard last:border-r-0\",\n        pressed ? \"bg-accent text-accent-fg\" : \"bg-surface text-text-secondary hover:bg-surface-hover\",\n      )}\n      onClick={onClick}\n      title={hint}\n      type=\"button\"\n    >\n      {children}\n    </button>\n  );\n}\n\n/* ── Cell contents ───────────────────────────────────────────────────── */\n\n/**\n * One cell: the figure, and under it what the figure is being compared to.\n *\n * The figure never moves and never changes shape — it is the same money, in\n * the same place, whatever the toggles say. The sub-line is smaller, toned\n * down and reserved: turning it on must not make the reader hunt for the\n * amount they already had their eye on.\n */\nfunction CellFigure({\n  read,\n  annotations,\n  currency,\n  emphasised,\n}: {\n  read: PeriodCellRead | null;\n  annotations: GridAnnotations;\n  currency: string;\n  emphasised: boolean;\n}) {\n  if (!read) return null;\n  const { value, unit } = read;\n  const size = emphasised ? \"sm\" : \"xs\";\n  const tone = read.projected ? \"secondary\" : \"primary\";\n\n  const figure =\n    value == null ? (\n      <Money size={size} value={null} />\n    ) : unit === \"money\" ? (\n      <Money accounting size={size} statement tone={tone} value={{ amount: value, currency } as MoneyValue} />\n    ) : (\n      <span\n        className={cn(\n          \"numeric text-figure-xs\",\n          emphasised && \"font-medium\",\n          read.projected && \"text-text-secondary\",\n        )}\n        data-raw={String(value / 100)}\n      >\n        {formatUnit(value, unit, currency)}\n      </span>\n    );\n\n  /* A row that can never have a share does not get a column of placeholder\n     dashes for one. */\n  const withShare = annotations.share && unit === \"money\";\n  if (!annotations.change && !withShare) return figure;\n\n  return (\n    <span className=\"block\">\n      <span className=\"block\">{figure}</span>\n      {/* One line, right-aligned under the figure it belongs to, with a\n          reserved height so a row whose change is unavailable does not sit\n          shorter than the row above it. */}\n      <span className=\"flex min-h-[15px] items-baseline justify-end gap-1.5 leading-tight\">\n        {annotations.change ? <ChangeNote currency={currency} read={read} /> : null}\n        {annotations.change && withShare ? (\n          <span\n            aria-hidden\n            className={cn(\n              \"text-caption text-border\",\n              !read.notable && \"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100\",\n            )}\n          >\n            ·\n          </span>\n        ) : null}\n        {withShare ? <ShareNote read={read} /> : null}\n      </span>\n    </span>\n  );\n}\n\n/**\n * The movement.\n *\n * Printed outright where it is big enough to be the reason someone opened the\n * grid, and revealed on the row under the pointer everywhere else. Annotating\n * all ninety cells equally annotates none of them: the quiet months become\n * noise the eye has to filter before it can find the month that moved. The\n * space is reserved either way, so nothing reflows when a row lights up.\n *\n * Percentage rather than amount, because the amounts are already on screen\n * directly above and to the left — the percentage is the thing the eye\n * cannot do for itself while scanning seven columns. A percentage row is the\n * exception: the change in a margin is points, and calling a move from 51.8%\n * to 53.0% \"+2.3%\" is the most common way a deck lies by accident.\n */\nfunction ChangeNote({ read, currency }: { read: PeriodCellRead; currency: string }) {\n  const { change, unit, priorLabel } = read;\n\n  /* Not `hidden`: the note keeps its box so the row height is the same\n     whether or not the pointer is on it. */\n  const reveal = read.notable\n    ? \"\"\n    : \"opacity-0 transition-opacity duration-1 ease-standard group-hover:opacity-100 group-focus-within:opacity-100\";\n\n  const toneClass =\n    change.direction === \"favourable\"\n      ? \"text-positive\"\n      : change.direction === \"unfavourable\"\n        ? \"text-negative\"\n        : \"text-text-tertiary\";\n\n  /* No comparison column at all — the first period on screen. An en dash,\n     not a zero: nothing moved because there is nothing to have moved from. */\n  if (priorLabel == null) {\n    return (\n      <span\n        className={cn(\"numeric text-caption text-text-tertiary\", reveal)}\n        title=\"no earlier column on screen\"\n      >\n        –\n      </span>\n    );\n  }\n\n  if (unit === \"percent\" || unit === \"points\") {\n    if (change.absolute == null) return <NoRatio change={change} className={reveal} />;\n    return (\n      <span className={cn(\"inline-flex items-baseline gap-0.5 text-caption\", toneClass, reveal)}>\n        {read.material ? <MaterialMark /> : null}\n        <span className=\"numeric\" title={`vs ${priorLabel}`}>\n          {formatUnit(change.absolute, \"points\", currency)}\n        </span>\n      </span>\n    );\n  }\n\n  if (change.relative == null) return <NoRatio change={change} className={reveal} />;\n\n  return (\n    <span className={cn(\"inline-flex items-baseline gap-0.5 text-caption\", toneClass, reveal)}>\n      {read.material ? <MaterialMark /> : null}\n      <span\n        className=\"numeric\"\n        title={`vs ${priorLabel}${\n          change.absolute == null ? \"\" : ` · ${formatUnit(change.absolute, read.unit, currency)}`\n        }`}\n      >\n        {change.relative > 0 ? \"+\" : change.relative < 0 ? \"−\" : \"\"}\n        {Math.abs(change.relative).toFixed(1)}%\n      </span>\n      {change.signFlip ? (\n        <span aria-hidden className=\"text-warning\" title=\"crossed zero\">\n          ⌁\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\n/** The share of the base row. Never toned — a share is a fact, not news. */\nfunction ShareNote({ read }: { read: PeriodCellRead }) {\n  if (read.share == null) {\n    return (\n      <span\n        className=\"numeric text-caption text-text-tertiary\"\n        title=\"no share — the base is zero or missing\"\n      >\n        —\n      </span>\n    );\n  }\n  return <span className=\"numeric text-caption text-text-tertiary\">{read.share.toFixed(1)}%</span>;\n}\n/**\n * One cell, open for typing.\n *\n * It commits on Enter and on blur, and abandons on Escape — the three\n * gestures a spreadsheet has trained into everyone who will use this. It\n * selects its contents on mount, because the reader who opened a cell holding\n * 412,000 almost always means to replace it rather than to append to it.\n *\n * The parsing is `parseMoneyInput`, so the cell accepts \"1.2k\", \"(1,240)\" and\n * \"=1200+340\" — and a value it cannot parse commits nothing rather than\n * committing zero. A mistyped cell that silently becomes 0 is the worst\n * outcome available here.\n */\n\nfunction CellEditor({\n  initial,\n  onCommit,\n  onCancel,\n}: {\n  initial: string;\n  onCommit: (text: string) => void;\n  onCancel: () => void;\n}) {\n  const ref = React.useRef<HTMLInputElement>(null);\n  React.useEffect(() => {\n    ref.current?.focus();\n    ref.current?.select();\n  }, []);\n\n  return (\n    <input\n      aria-label=\"Edit value\"\n      className=\"numeric w-full rounded-xs border border-accent bg-surface px-1 py-0.5 text-right text-figure-xs outline-none\"\n      defaultValue={initial}\n      inputMode=\"decimal\"\n      onBlur={(e) => onCommit(e.currentTarget.value)}\n      onKeyDown={(e) => {\n        e.stopPropagation();\n        if (e.key === \"Enter\") {\n          e.preventDefault();\n          onCommit(e.currentTarget.value);\n        } else if (e.key === \"Escape\") {\n          e.preventDefault();\n          onCancel();\n        }\n      }}\n      ref={ref}\n    />\n  );\n}\n\n/**\n * The materiality flag. The same ▸ `VarianceCell` uses, because a reader who\n * has learned it in one table should not have to learn it again in another.\n */\nfunction MaterialMark() {\n  return (\n    <span\n      aria-label=\"material variance\"\n      className=\"text-[0.85em] leading-none\"\n      role=\"img\"\n      title=\"Exceeds both the absolute and the relative materiality threshold\"\n    >\n      ▸\n    </span>\n  );\n}\n\n/**\n * The honest render of a percentage that does not exist. An em dash with the\n * reason on hover, never 0%, never ∞, never the absolute value dressed up as\n * a rate — each of which someone downstream would paste into a board deck.\n */\nfunction NoRatio({ change, className }: { change: PeriodChange; className?: string }) {\n  const why =\n    change.ratioUnavailable === \"zero-base\"\n      ? \"no percentage — the base was zero\"\n      : change.ratioUnavailable === \"sign-flip\"\n        ? \"no percentage — the figure crossed zero\"\n        : \"nothing to compare against\";\n  return (\n    <span className={cn(\"numeric relative text-figure-xs text-text-tertiary\", className)} title={why}>\n      <span aria-hidden=\"true\">—</span>\n      <span className=\"sr-only select-none\">{why}</span>\n    </span>\n  );\n}\n\n/* ── The drag handle ─────────────────────────────────────────────────── */\n\nfunction ResizeHandle({\n  column,\n  label,\n  width,\n  resizing,\n  startResize,\n  onResizeKeyDown,\n  reset,\n}: {\n  column: string;\n  label: string;\n  /** The column's current width: a focusable separator is a range, and has to say where it is. */\n  width: number | undefined;\n  resizing: string | null;\n  startResize: (column: string, event: React.PointerEvent) => void;\n  onResizeKeyDown: (column: string, event: React.KeyboardEvent) => void;\n  reset: (column: string) => void;\n}) {\n  return (\n    <span\n      aria-label={`Resize ${label}`}\n      aria-orientation=\"vertical\"\n      aria-valuenow={width === undefined ? undefined : Math.round(width)}\n      aria-valuetext={width === undefined ? undefined : `${Math.round(width)} pixels`}\n      className=\"ledger-col-resizer\"\n      data-resizing={resizing === column ? \"\" : undefined}\n      onDoubleClick={() => reset(column)}\n      onKeyDown={(e) => onResizeKeyDown(column, e)}\n      onPointerDown={(e) => startResize(column, e)}\n      role=\"separator\"\n      tabIndex={0}\n    />\n  );\n}\n",
      "path": "components/ledger/period-grid.tsx",
      "target": "components/ledger/period-grid.tsx",
      "type": "registry:component"
    }
  ],
  "meta": {
    "license": "MIT"
  },
  "name": "period-grid",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/column-manager",
    "@lopifi/format-money",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/period-change",
    "@lopifi/use-column-widths",
    "@lopifi/use-grid-nav",
    "@lopifi/use-scroll-edges"
  ],
  "title": "PeriodGrid",
  "type": "registry:component"
}
