- TypeScript 98.5%
- JavaScript 1.2%
- HTML 0.3%
| src | ||
| .gitignore | ||
| eslint.config.js | ||
| index.html | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.app.json | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
elfa-tables
A port of atomic-server's spreadsheet-style table editor to run on NextGraph (RDF + CRDT graph store) instead of Atomic Data.
Remote origin:
git@git.elfaconsortium.eu:joepio/elfa-tables-atomic.gitThis README is the entrypoint for continuing the work. If a session is interrupted, read this top-to-bottom: it records the architecture, every non-obvious decision, and the exact remaining steps.
What this is
Atomic-server has a polished, keyboard-driven editable data grid. The grid
component itself (FancyTable) is completely headless — it knows nothing
about the backend; all data coupling lived in a separate integration layer.
The port therefore = lift the headless grid verbatim + rewrite the thin
data layer against NextGraph's React ORM (@ng-org/orm/react).
- Source grid:
atomic-server/browser/data-browser/src/chunks/TableEditor/ - Source integration (reference only, NOT copied):
atomic-server/.../chunks/TablePage/ - NextGraph reference example:
../nextgraph-rs/sdk/js/examples/test-react-sdk/(React +useShape)
Decisions (locked in with the user)
- Fresh standalone repo at
/Users/joep/dev/elfa-tables. - React (not Svelte) — lets us reuse the grid almost verbatim.
- Typed/static ShEx shape (MVP) — columns come from a codegen'd shape, not editable at runtime.
- Start minimal — display/edit/keyboard/copy-paste over one shape; advanced features deferred (see below).
The full plan lives at ~/.claude/plans/typed-floating-dewdrop.md.
Architecture
The bridge: ShEx shape → columns
A NextGraph ORM ShapeType is just { schema, shape } where schema[shape].predicates[]
carries each field's readablePredicate, valType, min/maxCardinality, and
(for enums) literals. That predicate list is the runtime bridge — it plays
the role Atomic's Property[] played, so the grid's generic columns: T[] prop
is filled dynamically without hardcoding field names. See src/table/columns.ts.
Dynamic schema — built at runtime, NO codegen
ShapeType/Schema are plain runtime objects; useShape only reads .schema
and .shape. So we build the shape in JS instead of authoring a .shex file
and running the rdf-orm codegen. src/table/defineShape.ts is a typed builder:
a concise FieldSpec[] → a full ShapeType (incl. the @type predicate and
per-literal enum dataTypes). src/shapes/taskShape.ts defines the demo shape
with it, using the same IRIs the old codegen produced (so existing data loads).
This is the foundation for user-editable columns: lift the FieldSpec[] into
state, rebuild the ShapeType on change, and useShape re-subscribes — the
analog of editing an Atomic Class's requires/recommends. (The editing UI +
where the schema persists is the next step; see Deferred.)
Data flow
useShape(TaskShapeType, graph) → DeepSignalSet<Task> (src/table/NgTablePage.tsx)
(graph = did:ng:<private_store_id>; new rows also carry @graph = graph)
│ │
│ deriveColumns(TaskShapeType) │ rows = [...set]
▼ ▼
ShapeColumn[] ───────────────► FancyTable (headless grid, src/table/TableEditor/)
│ children({index}) renders NgTableCell per column
▼
NgTableCell (src/table/NgTableCell.tsx)
│ read: row[col.key]
│ write: row[col.key] = v (auto-commits to CRDT)
│ new row: set.add({...})
▼
cell editors (src/table/cells.tsx)
Key facts about the NextGraph ORM (installed alpha versions)
- Versions (must match the deployed wallet — see version-skew bug below):
@ng-org/orm@0.1.2-alpha.19,@ng-org/alien-deepsignals@0.1.2-alpha.12,@ng-org/web@0.1.2-alpha.13,@ng-org/shex-orm@0.1.2-alpha.8(dev, TYPES only). useShape(shape, scope):scopemay be a string graph NURI or{graphs, subjects}. We passdid:ng:<private_store_id>(matches the canonicalexpense-tracker-rdfexample). Returns aDeepSignalSet<T>.scope === undefined→ a read-only set that throws on mutation.initNgis exported asinitNgSignals. Under the hood alpha.19 calls the WASM methodsorm_start_graph/graph_orm_update.- ORM objects are keyed by
readablePredicate(task.title), NOT by IRI. Columns therefore carry bothiri(React key) andkey(object access). - Writes = direct mutation:
task.title = 'x'auto-commits to the graph and triggers a rerender. NouseValue/useDebouncedSaveequivalent needed. - New row =
set.add({ '@graph': graph, '@type': shape, '@id': '', ... }).'@id': ''auto-assigns a fresh subject IRI. - Session/auth (
@ng-org/webinit): at top level it redirects to a NextGraph wallet/broker (nextgraph.net by default, or a local dev broker viaNG_DEV*env vars), which authenticates and reloads this app inside an iframe, posting the session back over a MessageChannel. No custom login UI required.
Repo layout
src/
main.tsx # initNg() then render <App/>
App.tsx # session gate + ThemeProvider + <NgTablePage/> ✅
styled.d.ts # styled-components DefaultTheme augmentation
ng/session.ts # NextGraph SDK bootstrap (sessionPromise, session)
hooks/ # useClickAwayListener, useControlLock (relative deps of the grid)
shapes/
taskShape.ts # the demo "Task" shape, built via defineShape (runtime, no codegen)
table/
TableEditor/ # COPIED headless grid (verbatim from atomic-server)
lib/{components,helpers,hooks}/ # COPIED transitive deps of the grid
theme.ts # minimal styled-components theme the grid needs
defineShape.ts # FieldSpec[] → runtime ShapeType (replaces rdf-orm codegen) ✅
columns.ts # deriveColumns(shapeType) → ShapeColumn[] ✅
cells.tsx # valType → {Display, Edit} editors + alignment ✅
NgTableCell.tsx # binds a column+row to the headless Cell ✅
NgTablePage.tsx # orchestrator (useShape + FancyTable) ✅
Path aliases (vite.config.ts + tsconfig.app.json)
To avoid editing imports in the copied grid, atomic's layout is mirrored and aliased:
@components/*→src/table/lib/components/*@helpers/*→src/table/lib/helpers/*@hooks/*→src/table/lib/hooks/*- relative
../../hooks/*imports resolve tosrc/hooks/*(useClickAwayListener, useControlLock) — placed there deliberately.
Progress tracker
| # | Step | Status |
|---|---|---|
| 1 | Scaffold repo (package.json, vite, tsconfig, eslint, index.html, main.tsx, ng/session.ts) | ✅ done |
| 2 | Copy headless TableEditor/ + transitive deps into lib/ + src/hooks/; theme + styled.d.ts |
✅ done |
| 3 | defineShape (dynamic schema, no build step) |
✅ done |
| 4 | Data layer: columns.ts, cells.tsx, NgTableCell.tsx, NgTablePage.tsx, App.tsx |
✅ done |
| 5 | pnpm typecheck ✅ + pnpm build ✅ + pnpm lint ✅; runs in browser ✅; table UI works ✅; persistence works ✅ |
✅ done |
v1 is DONE and verified end-to-end. pnpm typecheck, pnpm build, pnpm lint
all pass; the app boots, authenticates against a NextGraph wallet, the table
renders + edits, and rows survive a page reload (user-confirmed 2026-06-09,
after the version-skew fix below). The temporary diagnostic logging has been
removed.
Note: hard-reloading the
nextgraph.euauth page itself shows "Invalid request" — that's the wallet auth page's own error state for a stale/consumed request, NOT an app bug. Reload the app (localhost:5191) instead.
Browser-verified (2026-06-09)
- ✅ App boots; all 66 app modules load HTTP 200 inside the auth iframe.
- ✅
init()redirects to the NextGraph wallet auth flow as designed. - ✅ Created a real wallet and logged in; the wallet granted the app a session (banner: "Wallet opened for localhost:5191").
- ✅ The table UI works (confirmed by user screenshot in a headed browser): columns derived from the shape, inline cell editing, boolean checkboxes, and the trailing new-row all render and edit correctly.
Bug found & fixed: writes weren't persisting — ORM/wallet VERSION SKEW (2026-06-09)
Symptom: edits showed in the grid but vanished on refresh; broker logged
SENDING EVENTS FROM OUTBOX RETURNED: Ok(()) yet a reload read an empty table.
Real cause: stale dependency pins. We had pinned @ng-org/orm@0.1.2-alpha.5
(copied from test-react-sdk), which calls the old WASM method names
orm_start / orm_update. The deployed nextgraph.eu wallet runs a newer WASM
that only exposes orm_start_graph / graph_orm_update (confirmed against
nextgraph-rs/sdk/js/orm/src/connector/GraphOrmSubscription.ts and
sdk/js/lib-wasm/src/lib.rs). So the orm calls hit non-existent methods on the
live broker → reads returned nothing and writes never committed the ORM patch.
Fix: upgraded to the latest alphas so the protocol matches the live wallet:
@ng-org/orm@0.1.2-alpha.19, @ng-org/alien-deepsignals@0.1.2-alpha.12,
@ng-org/web@0.1.2-alpha.13 (shex-orm stays alpha.8 for codegen). The newer
useShape(shape, scope) accepts a string scope (did:ng:<private_store_id>),
matching the canonical expense-tracker-rdf example — that's what
NgTablePage now passes. (An earlier "empty scope" attempt was a red herring and
was reverted.)
Lesson: pin @ng-org/* to versions that match the broker you target; the
example apps in nextgraph-rs may lag the deployed wallet.
Status: ✅ FIXED & VERIFIED — rows persist across reloads (user-confirmed
2026-06-09). The temporary diagnostic logging has been removed.
Dev wallet (throwaway, on the free nextgraph.eu alpha broker) — reuse it to
finish the manual test:
- username:
elfatablesdev - password:
ElfaTables2026demo! - unlock method: username/password (RecoveryKit stored encrypted server-side; no pazzle). The wallet is also saved in the browser's localStorage after first login.
Automation note (for future debugging)
The app runs inside a cross-origin iframe (the wallet at nextgraph.eu
frames localhost:5191). Headless Chromium does not paint out-of-process
iframes into screenshots nor expose their DOM/AX tree to automation — so a
headless tool (e.g. Charlotte) sees only a placeholder glyph even when the app
loaded fine. Verify the table in a normal headed browser. To see the app's
real runtime errors, open the iframe's devtools console (right-click →
inspect the frame); the top page's console won't show them.
Commands
pnpm install # deps (already installed; esbuild build approved)
pnpm dev # vite dev server (will redirect to a NextGraph wallet)
pnpm typecheck # tsc -b --noEmit
pnpm build # tsc -b && vite build
pnpm lint
Running fully locally (no nextgraph.eu / .net) — optional
By default the app redirects to the hosted wallet at nextgraph.eu. The wallet
(not the app) holds keys, runs the WASM engine, and talks to a broker — @ng-org/web
delegates to it over a postMessage iframe, so the app can't run the engine itself.
"Local" therefore means running the wallet + broker on localhost and pointing
the app at them. Running locally also pins the WASM version under our control,
which eliminates the version-skew class of bug (see above).
Steps (one-time setup is the Rust/WASM build; then 3 terminals):
- Build the WASM + front-end in
../nextgraph-rs(needs Rust + LLVM 17 + NextGraph'swasm-packfork — seenextgraph-rs/DEV.md):pnpm buildfront # cargo run-script libwasm + builds wallet app + web SDK pnpm buildfrontdev # dev builds incl. sdk/js/web with NG_DEV_LOCAL_BROKER - Run the broker
ngd:cargo run -p ngd -- -vv --save-key -l 14400(or use the Docker images innextgraph-rs/bin/ngd/docker/). It prints an invitation link — open it (swap port14400→1421for the local front-end) to create a local wallet. - Run the local wallet app:
cd app/nextgraph && pnpm webdev(serves:1421). - Use the dev-built
@ng-org/web(built withNG_DEV_LOCAL_BROKER=1→ redirects tolocalhost:1421) in this repo — link../nextgraph-rs/sdk/js/webvia pnpmlink:/file:instead of the npm version. Thenpnpm devhere; the app will redirect to the LOCAL wallet.
Effort: moderate, front-loaded on the first Rust/WASM build (slow/finicky on
macOS with LLVM 17). After that it's just three run commands.
Gotchas & notes (hard-won)
- Pin
@ng-org/*to versions that match the deployed wallet. The example apps in nextgraph-rs may lag; an old@ng-org/ormcalls renamed WASM methods that the live broker no longer exposes → silent no-persistence. We run the latest alphas (orm alpha.19 etc.). See the version-skew bug above. - No codegen. The schema is built at runtime via
defineShape(see Dynamic schema above).@ng-org/shex-ormis still a devDependency but only for itsSchema/ShapeType/Predicatetypes. (Historical: itsalpha.2codegen CLI crashed in prettier;alpha.8worked — moot now that we don't codegen.) datekind →valType: "string"in the schema (the ORM has no dedicated date type), so theduecolumn renders as a plain text input in v1. A date input would need a heuristic (predicate-name match) — deferred.- tsconfig is intentionally lenient (
verbatimModuleSyntaxanderasableSyntaxOnlyare OFF) so the copied grid — which uses enums and value-style type imports — compiles unchanged. - The grid was written for atomic's React Compiler setup; here it runs without it. Watch for missing-memo perf issues (cheap to add if they surface).
react-window@2Row getsrole="listitem"; the grid strips it to userole="row". Don't "fix" that.
Deferred (NOT in v1)
- User-editable columns — the schema is now dynamic (
defineShape), so the remaining work is just the UI (aNewColumnButton+ add/edit/remove form that mutates theFieldSpec[]) and deciding where the schema persists (open question): in-applocalStorage, or — more Atomic-like — as its own document in NextGraph so the schema travels with the data. - Column order/size persistence, CSV export, expanded-row dialog, undo/history, rich relation/markdown/file cells, sorting UI, multi-graph scope selection, date-typed cells. The headless grid already contains hooks for most of these.
Verification checklist (step 5)
- Table renders one column per shape predicate with correct headers.
- Edit a cell → reload page → value survives (proves the ORM write committed).
- Typing in the trailing empty row creates a new row that persists.
- Arrow-key nav, Enter to edit, Esc to exit, Delete to clear, Ctrl+C/Ctrl+V work.
- Each datatype edits correctly (text / number / checkbox / enum-select).