Cells in code, read by its engine: a colour per column, a formula's names in theirs
npx shadcn add @ag/cells-codeAlready installed? Preview an update
npx shadcn add @ag/cells-code --dry-runYour 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.cellsfile (or a ```cellsfence) 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 (costin=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.cellnames no language: a file is a Cells file and a cell only ever a table's, so a.cellfile or acellfence is drawn plain. Migration from code's own Cells: rename.cellfiles to.cellsandcellfences tocells`.
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.
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.
---
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 = 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.921The engine reads a broken file too: what does not read is marked where it stands, the rest keeps its columns.
item:text!,cost:EURO,qty:int,total:EUR=cost * qty
fuel,84.20,1,the demo above, verbatim
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 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)),
),
})