a hand working in an app's tables as steps: a window to the front, a cell, keys, Enter
npx shadcn add @ag/table-sessionAlready installed? Preview an update
npx shadcn add @ag/table-session --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-06
- New: a hand working in an app's tables as steps (a window brought to the front, a cell selected, keys typed and Enter), on one clock, each commit written by the consumer's own engine once when the timeline is made (
tableTimeline(files, steps, write)), so a frame at any instant is a lookup (tableFrameAt: the window in front, every file's text as its last commit wrote it, the hand's cursor and the keys typed so far);sceneOf(frame, path)is a window's part, for table'sscene. The timeline'splayabledrives playback, and a still at any progress is the live frame. Awrittenstep is a file changed from outside the app (a script on its schedule, a sync), read again by its window with no hand moving.
shelf devices & demos
uses playable
the demo above, verbatim
import { Sample } from "@/app/samples"
import {
type TableFile,
type TableStep,
type TableWrite,
tableFrameAt,
tableTimeline,
} from "@/registry/base-nova/lib/table-session"
// #region session
// A plain CSV, written by a writer of the demo's own: a consumer with an engine (Cells)
// writes through it instead, storing what each commit reaches.
const write: TableWrite = (file, cell, value) => {
const [head = "", ...rows] = file.text.trimEnd().split("\n")
const k = head.split(",").indexOf(cell.column)
if (k < 0) throw new Error(`demo: no column ${cell.column}`)
const width = head.split(",").length
const row = (rows[cell.row] ?? ",".repeat(width - 1)).split(",")
row[k] = value
const next = [...rows]
next[cell.row] = row.join(",")
return `${[head, ...next].join("\n")}\n`
}
const FILES: TableFile[] = [
{ path: "trip.csv", text: "item,cost\nflights,412.00\n" },
]
const STEPS: TableStep[] = [
{ kind: "select", row: 1, column: "item", delay: 300 },
{ kind: "type", text: "dinner" },
{ kind: "select", row: 1, column: "cost" },
{ kind: "type", text: "96.75" },
]
// #endregion
export default function Demo() {
const timeline = tableTimeline(FILES, STEPS, write)
// The frame at a few instants, as a window would be told it.
const instants = [400, 900, 1300, timeline.total]
return (
<div className="space-y-12">
<Sample
name="frames"
glance="wide"
title="the frame at four instants"
with="session"
>
<div className="grid gap-3 font-mono text-xs sm:grid-cols-2">
{instants.map((ms) => {
const f = tableFrameAt(timeline, ms)
return (
<div
key={ms}
className="space-y-2 rounded-xl border border-border bg-sidebar p-3"
>
<div className="text-muted-foreground">
{ms} ms ·{" "}
{f.hand
? `cursor ${f.hand.cell.column} ${f.hand.cell.row + 1}${f.hand.draft !== null ? `, typing "${f.hand.draft}"` : ""}`
: "no cursor"}
</div>
<pre className="whitespace-pre-wrap text-foreground">
{f.texts["trip.csv"]}
</pre>
</div>
)
})}
</div>
</Sample>
</div>
)
}the source, every file an install writes
// A TABLE WORKED IN BY HAND, as data: what a person does in an app's tables (a window
// brought to the front, a cell chosen, keys typed into it and Enter), on one clock, and
// what every window shows at any instant of it. table's `scene` draws the hand (its
// cursor, the text being typed), and each file's text is the one its last commit
// wrote. A commit is written by the consumer's own engine (`write`: a Cells file through
// its engine, which stores what the commit reaches), worked out once when the timeline
// is made, so a frame is a lookup and a still at any `progress` is the live frame.
//
// const t = tableTimeline(files, steps, write)
// const f = tableFrameAt(t, ms) // f.texts[path], f.active, f.scene(path)
//
// Framework-free.
import type { Chapter, Playable } from "@/registry/base-nova/lib/playable"
/** A file a window shows: its path and its text. */
export interface TableFile {
path: string
text: string
}
/** A cell of a file's table: its data row (from 0, the first under the header; one past
* the last is a new row) and its column by name. */
export interface TableCell {
row: number
column: string
}
/** What the hand does, in order, each after `delay` ms (or none). */
export type TableStep =
/** A window brought to the front. */
| { kind: "open"; path: string; delay?: number }
/** The cursor to a cell of the window in front. */
| { kind: "select"; row: number; column: string; delay?: number }
/** Keys typed into the cursor's cell, then Enter: the cell is committed (`write`). */
| { kind: "type"; text: string; delay?: number }
/** A file written from outside the app (a script on its schedule, a sync), its whole
* new text: its window reads it again, no hand moving. */
| { kind: "written"; path: string; text: string; delay?: number }
/** A commit, as the consumer writes it: `file`'s text with `value` in `cell`, read with
* the other files as they stand (a reference to one of them checked against it). */
export type TableWrite = (
file: TableFile,
cell: TableCell,
value: string,
others: readonly TableFile[],
) => string
/** How long the hand takes, ms: a window brought up, the cursor moved, a key, the
* Enter that commits. */
export const TABLE_MS = {
open: 350,
select: 300,
key: 70,
commit: 220,
} as const
/** Each step, planned: when it starts and ends, and what it leaves. */
interface Planned {
step: TableStep
start: number
end: number
/** A select's cell, in the file in front. */
cell?: TableCell & { path: string }
/** A type's text written, at `end`: every file's text after the commit. */
texts?: Record<string, string>
}
export interface TableTimeline {
files: readonly TableFile[]
plans: Planned[]
/** ms */
total: number
/** The session as a playable: one chapter per thing done. */
playable: Playable
}
/** The hand's steps on `files`, timed, each commit written by `write`. The first file
* is in front until a step opens another. A step on a file no window shows, a type
* with no cell chosen, throws. */
export function tableTimeline(
files: readonly TableFile[],
steps: readonly TableStep[],
write: TableWrite,
): TableTimeline {
const first = files[0]
if (!first) throw new Error("table-session: no file to work in")
const texts: Record<string, string> = Object.fromEntries(
files.map((f) => [f.path, f.text]),
)
let front = first.path
let cell: (TableCell & { path: string }) | null = null
let at = 0
const plans: Planned[] = []
const chapters: Chapter[] = []
for (const step of steps) {
const start = at + (step.delay ?? 0)
let end = start
const plan: Planned = { step, start, end }
switch (step.kind) {
case "open":
if (!(step.path in texts))
throw new Error(
`table-session: ${step.path} is no file of the session`,
)
front = step.path
cell = null
end = start + TABLE_MS.open
chapters.push({ start, title: step.path })
break
case "select":
cell = { path: front, row: step.row, column: step.column }
plan.cell = cell
end = start + TABLE_MS.select
break
case "type": {
if (!cell)
throw new Error("table-session: keys typed with no cell chosen")
const path = cell.path
const others = files
.filter((f) => f.path !== path)
.map((f) => ({ path: f.path, text: texts[f.path] as string }))
texts[path] = write(
{ path, text: texts[path] as string },
cell,
step.text,
others,
)
plan.texts = { ...texts }
end = start + [...step.text].length * TABLE_MS.key + TABLE_MS.commit
chapters.push({ start, title: step.text, code: true })
break
}
case "written":
if (!(step.path in texts))
throw new Error(
`table-session: ${step.path} is no file of the session`,
)
texts[step.path] = step.text
plan.texts = { ...texts }
chapters.push({ start, title: `${step.path} written` })
break
}
plan.end = end
plans.push(plan)
at = end
}
return { files, plans, total: at, playable: { duration: at, chapters } }
}
/** What the windows show at `ms`: the file in front, every file's text as its last
* commit wrote it, and the hand in the front window (its cursor, and the keys typed so
* far into a cell not yet committed). */
export interface TableFrame {
active: string
texts: Record<string, string>
hand: { path: string; cell: TableCell; draft: string | null } | null
}
export function tableFrameAt(t: TableTimeline, ms: number): TableFrame {
const texts: Record<string, string> = Object.fromEntries(
t.files.map((f) => [f.path, f.text]),
)
let active = (t.files[0] as TableFile).path
let hand: TableFrame["hand"] = null
for (const p of t.plans) {
if (ms < p.start) break
const s = p.step
if (s.kind === "open") {
active = s.path
hand = null
} else if (s.kind === "select" && p.cell) {
const { path, ...cell } = p.cell
hand = { path, cell, draft: null }
} else if (s.kind === "written" && p.texts) {
Object.assign(texts, p.texts)
} else if (s.kind === "type" && hand) {
const at: NonNullable<TableFrame["hand"]> = hand
if (ms >= p.end - TABLE_MS.commit && p.texts) {
Object.assign(texts, p.texts)
hand = { ...at, draft: null }
} else {
const keys = Math.floor((ms - p.start) / TABLE_MS.key) + 1
hand = { ...at, draft: [...s.text].slice(0, keys).join("") }
}
}
}
return { active, texts, hand }
}
/** The hand as `path`'s window draws it (table's `scene`), or none where it is not. */
export function sceneOf(
frame: TableFrame,
path: string,
): { cursor: TableCell | null; draft: string | null } | null {
return frame.hand && frame.hand.path === path
? { cursor: frame.hand.cell, draft: frame.hand.draft }
: null
}