← ui

cells-code

lib

Cells in code, read by its engine: a colour per column, a formula's names in theirs

npx shadcn add @ag/cells-code
Already installed? Preview an update
npx shadcn add @ag/cells-code --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-06
  • New: Cells in code, read by its engine's tokens, so a .cells file (or a ``` cells fence) is coloured the way a table is read: a column's name in the header, every cell under it whatever it spells, and every name a formula reads (cost in =cost * qty) in that column's colour, six in turn (CELLS_COLUMNS, 4.5:1 on light and 8.5:1 on dark), the header's names bold. Colour means a column and nothing else: what is read (a formula's operators, numbers and functions, a table's name) is the text's colour, what describes (types, bounds, punctuation) CELLS_MUTED, and only an error and a comment wear the theme's colours; a script in its fence is coloured as its own language. The engine's WebAssembly loads when a Cells file is first coloured. cell names no language: a file is a Cells file and a cell only ever a table's, so a .cell file or a cell fence is drawn plain. Migration from code's own Cells: rename .cell files to .cells and cell fences to cells`.
Updated 2026-11-06 · release notes

also called cells highlighting

shelf text & code

uses cells, code

Each column one colour, six in turn: its name in the header, every cell under it, and every name a formula reads. Nothing else wears a hue: a formula's operators and numbers in the text's colour, the types and the punctuation muted.

budget.cells
item:text!,cost:EUR>=0,qty:int>=1,total:EUR=cost * qty,paid:yes/no,on:date,running:EUR=running[-1] + total
fuel,84.20,1,84.20=,yes,2026-08-14,84.20=
split dinner,32.25=96.75 / 3,1,32.25=,no,2026-08-15,116.45=
invoice 42,80.00,1,80.00=,yes,2026-08-16,196.45=

eur reads usd; share reads eur, with a range; first reads eur through TABLE. rates is a table and .eur its column in another file, so neither wears a column's colour.

fx.cells
---
import rates from "./rates.cells"
---
day:date!,usd:USD,eur:EUR=usd * rates['2026-08-14'].eur,share:pct=eur / SUM(eur[FIRST:LAST]) * 100,first:EUR=TABLE[FIRST].eur
2026-08-14,100.00,92.10=,61.40=,92.10=
2026-08-15,62.00,57.90=,38.60=,92.10=

The fence names the language, and its code is coloured as that language's.

rates.cells
---
rates = run every day at 18:00 in Europe/Madrid
```ts
export default async () => {
  const r = await fetch("https://api.frankfurter.app/latest?from=USD")
  return [{ day: (await r.json()).date, eur: 0.92 }]
}
```
---
day:date!,eur:num
2026-08-14,0.921

a file mid-edit

The engine reads a broken file too: what does not read is marked where it stands, the rest keeps its columns.

budget.cells
item:text!,cost:EURO,qty:int,total:EUR=cost * qty
fuel,84.20,1,

the demo above, verbatim

cells-code.demo.tsx
import { Sample } from "@/app/samples"
import "@/registry/base-nova/lib/cells-code"
import { Code } from "@/registry/base-nova/ui/code"

// #region table
// A table is read by its columns: the header's names, every cell under each whatever
// it spells, and the names a formula reads (`cost` and `qty` in `total`'s) share that
// column's colour. A computed value is stored before its `=`; a cell's own formula
// follows it.
const BUDGET = `item:text!,cost:EUR>=0,qty:int>=1,total:EUR=cost * qty,paid:yes/no,on:date,running:EUR=running[-1] + total
fuel,84.20,1,84.20=,yes,2026-08-14,84.20=
split dinner,32.25=96.75 / 3,1,32.25=,no,2026-08-15,116.45=
invoice 42,80.00,1,80.00=,yes,2026-08-16,196.45=
`
// #endregion

// #region references
// A name reads its own table's column bare, with a row (`total[-1]`) or after TABLE's
// `.`; another table (`rates` below) and its column are not this table's, and wear no
// column's colour.
const REFERENCES = `---
import rates from "./rates.cells"
---
day:date!,usd:USD,eur:EUR=usd * rates['2026-08-14'].eur,share:pct=eur / SUM(eur[FIRST:LAST]) * 100,first:EUR=TABLE[FIRST].eur
2026-08-14,100.00,92.10=,61.40=,92.10=
2026-08-15,62.00,57.90=,38.60=,92.10=
`
// #endregion

// #region script
// A table a script fills, and the script in its fence, coloured as its own language.
const SCRIPT = `---
rates = run every day at 18:00 in Europe/Madrid
\`\`\`ts
export default async () => {
  const r = await fetch("https://api.frankfurter.app/latest?from=USD")
  return [{ day: (await r.json()).date, eur: 0.92 }]
}
\`\`\`
---
day:date!,eur:num
2026-08-14,0.921
`
// #endregion

// #region broken
// A file as it is typed: a type the engine does not know (EURO) is marked as an error
// where it stands, and the rest still reads by its columns.
const BROKEN = `item:text!,cost:EURO,qty:int,total:EUR=cost * qty
fuel,84.20,1,
`
// #endregion

export default function CellsCodeDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="table"
        title="a table, read by its columns"
        description="Each column one colour, six in turn: its name in the header, every cell under it, and every name a formula reads. Nothing else wears a hue: a formula's operators and numbers in the text's colour, the types and the punctuation muted."
        with="table"
      >
        <Code filename="budget.cells">{BUDGET}</Code>
      </Sample>
      <Sample
        name="references"
        title="the column a formula reads"
        description="eur reads usd; share reads eur, with a range; first reads eur through TABLE. rates is a table and .eur its column in another file, so neither wears a column's colour."
        with="references"
      >
        <Code filename="fx.cells">{REFERENCES}</Code>
      </Sample>
      <Sample
        name="script"
        title="a script, in its own language"
        description="The fence names the language, and its code is coloured as that language's."
        with="script"
      >
        <Code filename="rates.cells">{SCRIPT}</Code>
      </Sample>
      <Sample
        name="broken"
        title="a file mid-edit"
        description="The engine reads a broken file too: what does not read is marked where it stands, the rest keeps its columns."
        with="broken"
      >
        <Code filename="budget.cells">{BROKEN}</Code>
      </Sample>
    </div>
  )
}

the source, every file an install writes

cells-code.ts
// CELLS IN CODE: `cells` (a `.cells` file, a ```` ```cells ```` fence) registered with
// code, read by the Cells engine (`tokens`, the wasm build), never by a pattern. The
// engine knows what every part of a file is, so a file is coloured the way a table is
// read, by its columns, as Rainbow CSV colours one: a column's name in the header,
// every cell under it whatever it spells, and every name a formula reads (`cost` in
// `=cost * qty`) share that column's colour. In a table colour means a column, so nothing else wears a hue: what is read
// is the text's own colour, what describes (types, bounds, punctuation) is muted, and
// only an error and a comment take the theme's colours; a script in its fence is its
// own language's. Import the module for its effect once in each
// place code runs and Cells is shown (a server layout for `Code`, the client module
// that draws an editor): code itself holds no language.
//
// The same reading reaches an editor as the language server's semantic tokens (`cells
// lsp`): one engine, every surface.

import type { Token, TokenKind } from "@/registry/base-nova/lib/cells"
import {
  type CodeSpan,
  registerLanguage,
} from "@/registry/base-nova/lib/code-tokens"

/** A column's colour, six in turn as the grammars cycle them: one hue each, apart from
 *  its neighbours and from red, which only an error wears, and at one lightness per
 *  scheme so no column shouts. Each clears 4.5:1 on code's light surface and 8.5:1 on
 *  its dark one (OKLCH L 0.50 C 0.15 light, L 0.80 C 0.12 dark, hues 200 295 60 145
 *  335 95). */
export const CELLS_COLUMNS = [
  { light: "#007a85", dark: "#43d5dc" },
  { light: "#6b4cae", dark: "#c3aeff" },
  { light: "#9e4700", dark: "#f6ab6b" },
  { light: "#107823", dark: "#8bd28d" },
  { light: "#943b84", dark: "#eea0dc" },
  { light: "#7e5f00", dark: "#d6bd5c" },
] as const

/** The ink of what describes a column rather than holds it (its type, a bound, a mark,
 *  the punctuation between cells): the text's own colour, quieter, so the names and the
 *  values lead. Over 5:1 on code's surface in either scheme. */
export const CELLS_MUTED = { light: "#57606a", dark: "#8b949e" } as const

/** What a part wears when no column colours it. In a table colour means a column, so
 *  the rest is ink, never a hue of the theme's that a column could be mistaken for: what
 *  is read (a formula's operators, numbers, functions and the words in capitals, a
 *  table's name, the prelude's statements) in the text's own colour, what describes in
 *  `CELLS_MUTED`, a comment in the theme's comment colour, and only an error in the
 *  theme's colour for one. */
const INKS: Record<
  Exclude<TokenKind, "code">,
  "plain" | "muted" | { scope: string }
> = {
  fence: "muted",
  comment: { scope: "comment.line" },
  keyword: "plain",
  name: "plain",
  type: "muted",
  mark: "muted",
  bound: "muted",
  format: "muted",
  choice: "muted",
  punct: "muted",
  number: "plain",
  reference: "plain",
  operator: "plain",
  cellText: "plain",
  cellNumber: "plain",
  cellBool: "plain",
  cellDate: "plain",
  cellChoice: "plain",
  cellLink: "plain",
  cellId: "plain",
  error: { scope: "invalid.illegal" },
  string: "plain",
  table: "plain",
}

/** The column a part is coloured by: a header's name and a cell by the column they
 *  stand in, a formula's name by the one it reads; none for the rest. */
function columnOf(t: Token): number | null {
  if (t.kind === "reference") return t.reads
  if (t.kind === "name" || t.kind.startsWith("cell")) return t.column
  return null
}

/** A fence's language, from its opening backticks (`` ```ts ``). */
const fenceLang = (spelled: string) =>
  /^`{3,}[ \t]*([A-Za-z][\w+-]*)/.exec(spelled)?.[1]

/** A file's tokens (the engine's `tokens(text)`) as code's spans: each part by its
 *  column or by what it is, and a script's lines (each a `code` token) joined back
 *  into one span in the language its fence names, so a string across them reads as
 *  one. */
export function cellsSpans(text: string, read: readonly Token[]): CodeSpan[] {
  const out: CodeSpan[] = []
  let lang: string | undefined
  let script: { from: number; to: number } | null = null
  const close = () => {
    if (script && lang) out.push({ ...script, lang })
    script = null
  }
  for (const t of read) {
    if (t.kind === "code") {
      if (!lang)
        throw new Error(
          `cells-code: a script at ${t.from} with no fence before it`,
        )
      script = script
        ? { from: script.from, to: t.to }
        : { from: t.from, to: t.to }
      continue
    }
    close()
    if (t.kind === "fence") lang = fenceLang(text.slice(t.from, t.to))
    const column = columnOf(t)
    if (column !== null)
      out.push({
        from: t.from,
        to: t.to,
        ink: CELLS_COLUMNS[
          column % CELLS_COLUMNS.length
        ] as (typeof CELLS_COLUMNS)[number],
        bold: t.kind === "name",
      })
    else {
      // Plain is what no span covers: the text's own colour.
      const ink = INKS[t.kind]
      if (ink === "muted")
        out.push({ from: t.from, to: t.to, ink: CELLS_MUTED })
      else if (ink !== "plain")
        out.push({ from: t.from, to: t.to, scope: ink.scope })
    }
  }
  close()
  return out
}

// The engine (its WebAssembly, half a megabyte as a module) loads when a Cells file is
// first coloured, never with the page that registers it.
registerLanguage("cells", {
  read: () =>
    import("@/registry/base-nova/lib/cells").then(
      ({ tokens }) =>
        (text: string) =>
          cellsSpans(text, tokens(text)),
    ),
})