Skip to contentAccountingby Cynco

Journals

@cynco/journals renders journal entries and account registers. The core is framework-free TypeScript drawing into a <journals-container> custom element with an open shadow root; React wrappers only manage lifecycle. Amounts are integer minor units end to end — no floats ever touch money.

Installation

Install with the package manager of your choice. React and react-dom are optional peer dependencies — the vanilla API works without them.

pnpm add @cynco/journals
Entry pointWhat it exports
@cynco/journalsVanilla classes (JournalEntry, Register, LedgerView, EntryDiff), pure HTML renderers, and utilities like formatMinorUnits and diffEntryVersions
@cynco/journals/reactThin React components wrapping the vanilla classes, plus the hydration glue
@cynco/journals/ssrpreloadJournalEntryHTML, preloadRegisterHTML, preloadLedgerViewHTML, preloadReconciliationHTML, and preloadEntryDiffHTML for server prerendering

Core types

The whole suite agrees on three shapes. Balanced entries sum to exactly zero per currency; the renderer displays unbalanced input but flags it — the data layer never silently repairs it.

/** Integer minor units (sen, cents). Never floats. */
export type MinorUnits = number;

export interface Posting {
  /** Canonical colon-delimited path, e.g. `Assets:Current:Cash-Maybank`. */
  account: string;
  /** Signed integer minor units. Positive = debit, negative = credit. */
  amount: MinorUnits;
  /** ISO 4217 or commodity code, e.g. `MYR`, `USD`. */
  currency: string;
}

export interface LedgerEntry {
  id: string;
  date: string; // ISO `YYYY-MM-DD`
  flag: 'cleared' | 'pending' | 'flagged' | 'void';
  payee: string | null;
  narration: string;
  tags: readonly string[];
  links: readonly string[];
  postings: readonly Posting[];
}

export interface RegisterRowData {
  entry: LedgerEntry;
  posting: Posting;
  /** Running balance per currency after this posting, in minor units. */
  runningBalance: ReadonlyMap<string, MinorUnits>;
}

Vanilla API

Each component is a plain class: construct with options, call render with data and a mount target, call cleanUp when done. Rendering commits whole windows of pure HTML strings with single innerHTML writes.

JournalEntry

import { JournalEntry } from '@cynco/journals';

const card = new JournalEntry({ showLineNumbers: true });
card.render({
  entry, // a LedgerEntry
  parentNode: document.querySelector('#host')!,
});

// Later: re-render with new data (no-op when the entry is unchanged) …
card.render({ entry: nextEntry });
// … and tear down.
card.cleanUp();

Register

A virtualized single-account register (bank-statement style) with a sticky balance header. Give the host element a fixed height to turn the internal scroller into a window.

The options below drive the live register — the snippet is the exact options object it was constructed with:

const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  density: 'comfortable',
  groupBy: 'none',
  selectionMode: 'single',
});

500 seeded entries · 188 register rows · 0 selected — in range mode, shift-click or Shift+/ extends from the anchor. Option changes remount via React key; selection, focus, and callbacks stay in entry-index space under grouping.

import { Register } from '@cynco/journals';

const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  density: 'comfortable', // or 'compact' (one line per row)
  onRowSelect(row, index) {
    console.log(row.entry.id, index);
  },
});

register.render({
  rows, // readonly RegisterRowData[]
  parentNode: document.querySelector('#host')!,
});

// Swap the data in place; the window re-renders, scroll stays put.
register.setRows(nextRows);
register.setSelectedRow(3);
register.cleanUp();

LedgerView

import { LedgerView } from '@cynco/journals';

// Several account registers sharing one scroll container and one
// Virtualizer — sections that are offscreen cost nothing.
const view = new LedgerView({
  density: 'comfortable',
  onRowSelect(account, row, index) {
    console.log(account, row.posting.amount, index);
  },
});

view.render({
  sections: [
    { account: 'Assets:Current:Cash-Maybank', rows: cashRows },
    { account: 'Income:Sales:Services-Consulting', rows: salesRows },
  ],
  parentNode: document.querySelector('#host')!,
});

Keyboard navigation & ARIA

The register is an ARIA grid: role="grid" with aria-label (the label option, defaulting to the account path), aria-rowcount counting model rows (interleaved group headers included), rows with role="row" + aria-rowindex + aria-selected, cells with role="gridcell", and — in range mode — aria-multiselectable. Group header rows are real grid rows spanning every column via aria-colspan, but stay non-interactive. Focus is virtual: the grid is the single tab stop and aria-activedescendant points at the focused row, so navigation lives in entry-index space and group headers are skipped without any bookkeeping.

KeyAction
/ Move focus one entry row. At a section edge inside a LedgerView, focus hands off to the neighboring section (the onFocusBoundary hook); standalone registers clamp.
Home / EndFirst / last entry row.
PageDown / PageUpOne viewport’s worth of entry rows.
Enter / SpaceSelect the focused row — exactly a plain click, one shared code path, so pointer and keyboard can never drift apart.
Shift+/Range mode: extend the selection from the anchor (a shift-click on the new row).
/Ctrl+ARange mode: select every entry row. Single mode leaves the browser’s select-all alone.
EscapeClear the selection (no-op when nothing is selected).

Keys consumed by an active IME composition (Enter confirming a candidate, Escape dismissing one) never drive navigation or selection. Keyboard navigation ships on by default — the breaking-ish part of this feature: the register becomes a tab stop on every page that embeds it. That is the right default (a pointer-only data grid is inaccessible), but hosts composing their own focus management get an escape hatch:

// Keyboard navigation ships ON: the register grid is a tab stop
// (tabindex="0") and one delegated keydown handler owns the whole map.
// Hosts composing their own focus management can opt out entirely:
const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  disableKeyboardNavigation: true, // no keydown listener, no tabindex
});

// Focus is virtual — aria-activedescendant on the grid points at the
// focused row's id; the row itself is patched with data-focused.
register.focusRow(12); // programmatic counterpart (reveals + focuses)

Range selection

selectionMode: 'single' (default) preserves the original one-row behavior exactly. 'range' is contiguous line selection: click selects one row and sets the anchor, shift-click extends anchor→target contiguously, meta/ctrl-click toggles a row in or out. Keyboard mirrors pointer exactly. onRowSelect keeps firing for the primary (last-clicked) row for back-compat.

const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  selectionMode: 'range', // 'single' (default) | 'range'
  onSelectionChange({ indexes, rows }) {
    // Sorted entry indexes + their rows, fired on every user-driven
    // change (pointer AND keyboard). Fires in both modes; single mode
    // reports a 0/1-length selection.
    console.log(indexes, rows.length);
  },
});

// RegisterSelection: the shift-range anchor plus the selected entry
// indexes (always entry-index space — group headers are never selectable).
const { anchor, indexes } = register.getSelection();

// Programmatic selection does NOT fire callbacks (original behavior):
register.setSelectedRow(3);

Period grouping

groupBy interleaves period header rows — month, quarter, or year — into the virtual row space; none keeps the flat pure-arithmetic fast path. Each header shows the period label, distinct-entry count, and net change per currency. Selection, data-row-index, and every callback stay in entry-index space regardless of grouping.

const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  groupBy: 'month', // 'none' (default) | 'month' | 'quarter' | 'year'
  stickyGroupLabels: true, // default whenever groupBy !== 'none'
});

// Each group header renders a RegisterGroupSummary:
// { key: '2026-03', label: 'March 2026', entryCount: 14,
//   netChange: Map { 'MYR' => 152_300 } } — integer minor units, built in
// one O(n) pass per data update.

With grouping active, the current period’s label pins as a slim strip just below the register’s sticky header (stickyGroupLabels defaults to true). It is a mirror of the real group row — aria-hidden and pointer-inert, because position: sticky cannot work on rows a virtualized window evicts and recreates — updated from the prefix-sum row model in O(log n) per scroll frame, with DOM writes only when the period changes. In a LedgerView it pins below the owning section’s sticky header.

Register filter

Register takes a projection-level filter — the same philosophy as the accounts tree’s hide-non-matches search: canonical rows are never touched, only which rows are visible changes. Matching is a case-insensitive substring test on fields, which defaults to ['description'] — the payee/narration pair the description cell renders, matched as separate lines so a query can never match across their boundary; 'date' and 'flag' opt in.

const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  filter: { query: 'coffee' }, // optional initial filter
  onFilterResult({ matched, total }) {
    readout.textContent = `${matched} of ${total}`;
  },
});

// Per keystroke — the visible rows reshape in place:
register.setFilter({ query: input.value });
// Opt extra fields in; 'description' (payee + narration) is the default:
register.setFilter({ query: '2026-03', fields: ['description', 'date'] });
register.setFilter(null); // clear — back to the unfiltered fast path
  • Identity is full-data everywhere public: selection, focus, callbacks, scrollToRow, and row ids keep their original entry indexes. The filter never mutates selection — filtered-out selected rows simply are not rendered, and reappear (still selected) once the filter releases them. aria-rowcount / aria-rowindex describe the presented (filtered) grid.
  • Grouping stays honest: period headers survive only for periods containing matches, and their count / net-change summaries are recomputed over the matched rows — the summary describes what’s shown, not the period’s full total.
  • Highlighting: matched substrings in text cells wrap in <mark data-filter-match> (themed via the match/accent color family, or the --journals-bg-filter-match override). Construction is escape-safe: match positions are found on the raw string, then the before / match / after slices are escaped separately and joined with the mark tags — no search ever runs over escaped HTML, so a query like amp can never split an &amp; entity.
  • Keyboard navigation walks matched rows only; if the focused row gets filtered out, focus clears and aria-activedescendant is removed.
  • Parity: the filter crosses the worker protocol and the SSR preload (preloadRegisterHTML(rows, { filter })), so worker, sync, and server HTML stay byte-identical.

An empty query or null is “no filter” and keeps the unfiltered fast path — no corpus, no model allocation. The lazy lowercase corpus is built on the first application, reused across query changes, and dropped on setRows. onFilterResult fires after each application of an active filter (entry rows only, never group headers); clearing the filter fires nothing, so hosts reset their readout when they clear.

Scroll APIs

scrollToRow, scrollToDate (first row dated on or after, by binary search), and the LedgerView counterparts share one options shape. align defaults to nearest for rows (minimal movement; a no-op when the row is already visible) and start for sections, which accounts for the sticky header overlaying the viewport top. behavior defaults to auto (instant) — smooth is opt-in everywhere. Targets come from the same data-derived offsets the virtualizer uses, so no layout reads happen before a scroll; out-of-range rows, unknown accounts, and dates past the last row are graceful no-ops.

// Every scroll-to API takes the same options:
// { align?: 'start' | 'center' | 'nearest'; behavior?: 'smooth' | 'auto' }
register.scrollToRow(500, { align: 'center', behavior: 'smooth' });
register.scrollToDate('2026-03-01'); // first row dated on/after (binary search)
ledgerView.scrollToSection('Assets:Current:AR', { behavior: 'smooth' });
ledgerView.scrollToRow('Assets:Current:AR', 12, { align: 'start' });

// Spring tuning (Register and LedgerView options, or SmoothScroller
// directly for your own containers):
const settings: SmoothScrollSettings = {
  omega: 0.015, // rad/ms; 99% settle ≈ 6.6 / omega (~440ms here)
  epsilonPx: 0.5, // remaining distance below which it may settle
  epsilonVelocity: 0.01, // velocity below which it may settle
};
new Register({ account, smoothScrollSettings: settings });

Smooth scrolling is a critically-damped spring — it approaches the target as fast as possible without ever overshooting, because scroll positions must never bounce past a row and come back. User input wins: wheel, touch, scrollbar drags, and scroll keys cancel an in-flight animation instantly (listeners exist only while animating), and prefers-reduced-motion turns every smooth scroll into an instant jump. Keyboard focus reveal stays instant so it never lags typing.

Reconciliation

Hover or tap a matched pair to accept or reject it, or accept every exact match at once. Matching is deterministic — exact 1:1 pairs plus bounded sum matches.

The accounting analog of a merge-conflict resolver: statement lines on the left, book postings on the right, proposed matches as tinted pairs with accept / reject in a center gutter. proposeMatches is deterministic and runs three passes — an exact match shares amount, currency, and date; a suggested match shares amount and currency within dateWindowDays; a sum match covers one statement line with 2..maxGroupSize postings (bounded search, capped at 10,000 combinations per line, so adversarial inputs stay cheap). Sum pairs render the group stacked in one book cell with a Σ total row. Unmatched lines keep a create entry affordance and unmatched postings read as outstanding. The header difference (statement total − accepted book total) is integer minor-unit math and flips to jade only at exactly zero.

import { proposeMatches, Reconciliation } from '@cynco/journals';

// Deterministic proposals, three passes: exact (amount + currency + date),
// nearest-date within ±dateWindowDays, then sum matching — one statement
// line covered by 2..maxGroupSize postings in the same currency and window
// (kind 'sum', rendered as a stacked group with a Σ total row).
const matches = proposeMatches(statementLines, postings, {
  dateWindowDays: 3, // default
  maxGroupSize: 3, // default; pass 1 to disable sum matching
});

const reconciliation = new Reconciliation({
  account: 'Assets:Current:Cash-Maybank',
  periodLabel: 'Jul 2026',
  statementLines, // StatementLine[] — parsed bank lines, integer minor units
  postings, // BookPostingRef[] — { entry, postingIndex }
  matches, // optional; this is the default
  onAccept(match) {
    console.log('cleared', match.id);
  },
  onCreateEntry(line) {
    // The component never writes entries — that is your data layer's job.
    openEntryForm(line);
  },
});
reconciliation.render({ parentNode: document.querySelector('#host')! });

// Imperative controls mirror the gutter buttons. Match ids are
// deterministic: m-<lineId>-<entryId>-<postingIndex>, '+'-joined for sums.
reconciliation.acceptMatch('m-l1-e1-0');
const { matches: current, difference } = reconciliation.getState();
// Each match carries postings: readonly BookPostingRef[] (one entry for
// exact/suggested, 2..maxGroupSize for sums).
// difference: Map<currency, MinorUnits> — statement − accepted, exact.
  • acceptMatch / rejectMatch / undoMatch(id) — the same transitions the gutter buttons drive; each fires its callback with the transitioned match.
  • getState() — current matches plus the per-currency difference map.
  • <Reconciliation options ssrHTML /> from @cynco/journals/react and preloadReconciliationHTML from @cynco/journals/ssr follow the same hydration contract as the other components.

EntryDiff

Server-rendered via preloadEntryDiffHTML — hydration adopts this exact DOM with zero writes. Both versions balance to exactly zero.

The audit-trail view: the diff between two versions of a journal entry, rendered like a file diff. diffEntryVersions is pure data — scalar header fields (date, flag, payee, narration) classify as unchanged / changed / added / removed with word-level segments for changed text (adjacent changed runs separated by a single space merge, so highlights read as phrases, not confetti); tags and links diff as sets; postings pair by (account, currency), so both sides of an amount change share account and currency. null on either side models creation or deletion.

import { diffEntryVersions, EntryDiff } from '@cynco/journals';

// Pure data: the ledger analog of a file diff. Header fields get
// word-level segments, tags/links diff as sets, postings align by
// (account, currency) — amounts stay integer minor units end to end.
const diff = diffEntryVersions(before, after);
// diff.kind: 'created' | 'deleted' | 'modified' | 'unchanged'
// diff.postings: [{ kind: 'amount-changed', account, currency,
//                   beforeAmount, afterAmount }, ...]

// The component follows the JournalEntry lifecycle exactly:
const card = new EntryDiff();
card.render({
  before, // null models entry creation (everything added)
  after, // null models deletion/void (everything removed)
  parentNode: document.querySelector('#host')!,
});
card.cleanUp();

Client renders and SSR preloads share the same string builder, so hydration adopts the server DOM verbatim with zero writes. The diff card is a read-only audit artifact: posting annotation slots are deliberately not supported.

LedgerView v2

setSections reconciles incrementally instead of rebuilding, and focus and selection are per-register state keyed by entry index, so they survive whenever their section survives:

// Incremental reconciliation, keyed by account path: unchanged sections
// keep their Register instance and DOM, data-changed sections update in
// place (structural row equality, so fresh-but-identical arrays from
// immutable stores are "unchanged"), added sections mount, removed
// sections clean up, and order changes reorder DOM nodes without
// recreating anything.
view.setSections([
  { account: 'Assets:Current:Cash-Maybank', rows: cashRows },
  { account: 'Assets:Current:AR', rows: arRows },
]);

Across setSections the scroll position anchors to what the user sees: the topmost visible section + entry row is captured before the update and restored after, so sections growing, shrinking, appearing, or disappearing above it never shift the content in view. If the anchor section itself was removed, the nearest surviving neighbor takes its place (preceding first, then following), falling back to the raw scrollTop only when nothing survives.

import { preloadLedgerViewHTML } from '@cynco/journals/ssr';

const ssrHTML = await preloadLedgerViewHTML(sections, { id: 'ledger' });

// Client: pass the SAME id so ARIA row ids agree.
const view = new LedgerView({ id: 'ledger' });
view.hydrate({ sections, container }); // falls back to render when
// the markup is missing

The preload emits the shared scroller, every section’s sticky header, and each section’s leading rows — capped per section (128) and across the view (512 total, leading sections first) — with exactly sized spacers, so pre-hydration scrollbar geometry matches what the hydrated client computes. <LedgerView sections options ssrHTML /> from @cynco/journals/react takes the preload just like Register.

EntryStream

Renders journal entries live from a ReadableStream<LedgerEntry> (or any async iterable). Arrivals are buffered through the shared animation-frame queue: however many entries land within one frame, the DOM sees exactly one append — never more than one layout per frame. A sticky footer strip tracks the running count, and stick-to-bottom autoscroll releases the moment the user scrolls up (and re-engages when they return to the bottom). Also available as <EntryStream options /> from @cynco/journals/react.

import { createEntryStreamFromArray, EntryStream } from '@cynco/journals';

const stream = new EntryStream({
  // ReadableStream<LedgerEntry> or any AsyncIterable<LedgerEntry>;
  // consumed exactly once.
  stream: entrySource,
  total: 500, // optional: footer shows "n / 500"
  autoScroll: true, // stick to bottom until the user scrolls up
  onEntry(entry, index) {},
  onDone(count) {},
});
stream.render({ parentNode: document.querySelector('#host')! });

// Entries arriving within one frame commit as ONE DOM write; the sticky
// footer tracks the running count. cleanUp() cancels the reader.
stream.cleanUp();

// Demo/test helper: array -> stream on a fixed cadence.
const entrySource = createEntryStreamFromArray(entries, { delayMs: 40 });

Live-region announcements

Dynamic surfaces announce state changes to screen readers through visually-hidden aria-live="polite" regions — one per component instance, kept outside the re-rendered markup so re-renders never re-announce. Live regions are created empty on both render and hydrate, so SSR output never replays a stale announcement.

// Reconciliation: every accept / reject / undo announces the resulting
// per-currency difference ("MYR difference 42.00", or "All currencies
// reconciled" at exactly zero) — one announcement per discrete change.
const reconciliation = new Reconciliation({
  account,
  statementLines,
  postings,
  disableAnnouncements: true, // opt out when the host narrates itself
});

// EntryStream announces exactly two moments: stream start
// ("Streaming entries…") and completion ("N entries loaded"). The visual
// footer count keeps ticking but is deliberately not a live region.

disableAnnouncements is a Reconciliation option: by default every accept / reject / undo announces the resulting per-currency difference, because the header’s difference figures would otherwise change silently. Hosts that narrate reconciliation state themselves flip it on so users never hear the same change twice. EntryStream’s two announcements (start and completion) are always on — intermediate per-flush counts stay visual-only, since announcing a streaming count would be an unusable firehose.

Worker pool

@cynco/journals/worker moves the heavy pure computations — register window HTML and reconciliation proposals — off the main thread. The renderers are DOM-free string builders, so the worker runs the exact same code the sync path runs; results are deduped by key, LRU-cached, and committed on the next animation frame with spacer geometry already in place, so scroll position never jumps. If workers cannot start (or die mid-job) the pool transparently computes on the main thread instead — a failed pool is a performance regression, never a correctness one.

import { getOrCreateWorkerPoolSingleton } from '@cynco/journals/worker';
// Vite: the fully-bundled portable worker entry.
import JournalsWorker from '@cynco/journals/worker/worker-portable.js?worker';

const pool = getOrCreateWorkerPoolSingleton({
  workerFactory: () => new JournalsWorker(),
  // poolSize: min(2, hardwareConcurrency); resultCacheSize: 200 (LRU)
});

// Components accept the pool as an option; without one (or after any
// worker failure) they use the synchronous path — same renderer, same
// output, so workers are purely a performance upgrade.
new Register({ account, workerPool: pool });
new Reconciliation({ account, statementLines, postings, workerPool: pool });

// Direct API, resolved off-thread with dedupe + LRU caching:
const html = await pool.renderRegisterWindow({ rows, range, selectedIndex });
const matches = await pool.proposeMatches({ statementLines, postings });
pool.subscribeToStatChanges((stats) => console.log(stats.busyWorkers));
  • @cynco/journals/worker/worker.js — plain module worker entry for bundlers that can follow package imports inside workers.
  • @cynco/journals/worker/worker-portable.js — fully-bundled variant (no imports) for bundler worker plugins like Vite's ?worker.
  • React: WorkerPoolProvider / useWorkerPool() from @cynco/journals/react thread one pool through a subtree; components work without it.

React API

The React components are deliberately thin: a ref callback owns exactly one vanilla instance, and a layout effect pushes prop changes into it after every committed render. All rendering logic stays in the vanilla classes.

import { JournalEntry, LedgerView, Register } from '@cynco/journals/react';

export function CashRegister({ rows }: { rows: RegisterRowData[] }) {
  return (
    <Register
      rows={rows}
      options={{
        account: 'Assets:Current:Cash-Maybank',
        density: 'compact',
        onRowSelect: (row, index) => select(row, index),
      }}
      style={{ height: 480 }}
    />
  );
}
  • <JournalEntry entry options ssrHTML /> — one entry card.
  • <Register rows options ssrHTML /> — one virtualized account register.
  • <LedgerView sections options ssrHTML /> — several registers in one scroll container.
  • <EntryDiff before after options ssrHTML /> — the audit-trail diff card.

SSR

The preload functions run in Node (server components, route handlers) and return shadow-root HTML — component stylesheet inlined, markup produced by the same string builders the client uses. Pass the result to the matching React component's ssrHTML prop: the server emits a declarative shadow DOM template, the browser attaches a styled shadow root before any JS runs, and hydration adopts the parsed DOM without re-rendering.

app/page.tsx
// A React Server Component
import { JournalEntry } from '@cynco/journals/react';
import {
  preloadJournalEntryHTML,
  preloadRegisterHTML,
} from '@cynco/journals/ssr';

export default async function Page() {
  const ssrHTML = await preloadJournalEntryHTML(entry, {
    showLineNumbers: true,
  });
  return (
    <JournalEntry
      entry={entry}
      options={{ showLineNumbers: true }}
      ssrHTML={ssrHTML}
    />
  );
}

preloadRegisterHTML(rows, options) renders every row (the server cannot know the viewport), so prefer it for bounded registers and let large ones render client-side.

Theming

The stylesheet uses zero class selectors — data attributes only — and every color resolves through a three-step custom property chain, so one DOM serves both modes:

/* Every color resolves override → theme → built-in default: */
--journals-debit: var(
  --journals-debit-override,
  var(--journals-theme-ledger-debit, light-dark(#199f43, #5ecc71))
);
/* One-off overrides: set the override hook on any ancestor. */
journals-container {
  --journals-font-family: var(--font-mono);
  --journals-accent-override: #009fff;
  --journals-debit-override: oklch(0.72 0.19 150);
}
import { journalsThemeVariables } from '@cynco/journals';
import { dark } from '@cynco/theme';

// Whole-palette theming: map @cynco/theme roles onto the
// --journals-theme-* layer (sits between overrides and defaults).
const variables = journalsThemeVariables(dark);
for (const [name, value] of Object.entries(variables)) {
  host.style.setProperty(name, value);
}

Layout hooks follow the same pattern: --journals-font-family, --journals-font-size (default 13px), and --journals-line-height (default 20px). Amounts always render with tabular-nums.

Every demo on this site renders in Paper Mono (SIL OFL 1.1) via --journals-font-family. The package bundles no font — download Paper Mono from its repo and set the hook to match this look, or point it at your own mono stack.

Virtualization

Generating 10,000 entries

10,000 entries · register rows · a seeded fixture, so every visit renders byte-identical data.

Fixed row heights make the window math pure arithmetic: the rendered range and spacer heights derive from the scroll position with no per-row measurement, so 100k-row registers render the same handful of nodes as 100-row ones.

const register = new Register({
  account: 'Assets:Current:Cash-Maybank',
  // Pixel height of one text line. Must match the effective
  // --journals-line-height (default 20px) or spacer heights drift.
  lineHeight: 20,
  // Sticky header height; must match the header CSS (default 44px).
  headerHeight: 44,
  // Extra rows rendered above and below the pixel window. Default 10.
  overscanRows: 10,
});

Standalone registers own their Virtualizer; LedgerView shares one across sections and supplies per-section offsets, so offscreen sections keep only their two spacers in the DOM.

API reference

The full export surface of @cynco/journals. Types are transcribed from the source; defaults that live in constants.ts cite the constant's value.

Register options

RegisterOptions extends RegisterRenderOptions — the render subset also drives preloadRegisterHTML and the worker, so SSR, worker, and client agree.

OptionTypeDefaultDescription
accountstringrequiredCanonical colon-delimited path of the account this register shows.
density'comfortable' | 'compact''comfortable'Row density; affects CSS and JS row height, so it needs a full re-render to change.
groupBy'none' | 'month' | 'quarter' | 'year''none'Interleaves period group header rows into the virtual row space.
idstringauto-generatedStable instance id baked into row ids; pass the same id to preloadRegisterHTML.
labelstringthe account pathAccessible name for the grid (aria-label).
selectionMode'single' | 'range''single'range adds shift-click extension and meta/ctrl toggling.
disableKeyboardNavigationbooleanfalseOpts out of the keydown listener and the grid's tabindex="0".
filterRegisterFilternoneProjection-level row filter; empty/absent query keeps the unfiltered fast path.
maxSsrRowsnumberrender allCaps SSR-emitted rows; ignored when a filter or grouping is active; <= 0 = all.
emptyLabelstringDEFAULT_REGISTER_EMPTY_LABELGuidance text for the zero-row state (No transactions in this view).
amountFormatAmountFormatAMOUNT_FORMAT_COMMA_DOTSeparator/grouping descriptor for every amount the register renders.
stickyGroupLabelsbooleantrue when groupBy !== 'none'Pins the current period's label below the sticky header.
colorScheme'light' | 'dark' | 'system''system'Pins how light-dark() colors resolve on the host element.
lineHeightnumber20 (DEFAULT_LINE_HEIGHT)Pixel height of one text line; must match --journals-line-height.
headerHeightnumber44 (DEFAULT_HEADER_HEIGHT)Sticky header height in px; must match the header CSS min-height.
overscanRowsnumber10 (DEFAULT_OVERSCAN_ROWS)Extra rows rendered above and below the pixel window.
virtualizerVirtualizerowned instanceShared Virtualizer (LedgerView passes one per scroll container).
getOffsetTop() => numbercontent top (0)Top offset of this section within the scroll content, in px.
smoothScrollerSmoothScrollerowned instanceShared spring so sections never fight over one scrollTop.
smoothScrollSettingsSmoothScrollSettingsDEFAULT_SMOOTH_SCROLL_SETTINGSSpring tuning for the owned scroller; ignored when a shared one is supplied.
workerPoolWorkerPoolManagernone (sync path)Off-thread window HTML; any failure falls back to the identical sync path.
onRowSelect(row: RegisterRowData, index: number) => voidFired when a row is clicked, after the selection attribute is applied.
onFocusChange(entryIndex, row) => voidFired whenever the keyboard-focused row changes; null when focus leaves the rows.
onFocusBoundary(direction: 1 | -1) => booleanCross-section focus handoff hook (LedgerView); return true when focus moved.
onSelectionChange(selection: RegisterSelectionChange) => voidFired on every user-driven selection change with sorted entry indexes plus rows.
onFilterResult(result: RegisterFilterResult) => voidFired after each application of an active filter with { matched, total }.

JournalEntry options

JournalEntryOptions extends EntryRenderOptions (the render subset shared with preloadJournalEntryHTML).

OptionTypeDefaultDescription
showLineNumbersbooleanfalseRenders a 1-based posting number gutter before the account path.
amountFormatAmountFormatAMOUNT_FORMAT_COMMA_DOTSeparator/grouping descriptor for posting amounts and imbalance figures.
colorScheme'light' | 'dark' | 'system''system'Pins how light-dark() colors resolve on the host element.
renderPostingAnnotation(posting: Posting, index: number) => HTMLElement | nullPer-posting annotation slot, called after every render; client-side only.

LedgerView options

LedgerViewOptions:

OptionTypeDefaultDescription
colorScheme'light' | 'dark' | 'system''system'Pins how light-dark() colors resolve on the host element.
density'comfortable' | 'compact''comfortable'Row density shared by every section.
lineHeightnumber20Must match --journals-line-height.
headerHeightnumber44Must match the header CSS min-height.
overscanRowsnumber10Extra rows rendered above/below the window per section.
idstringauto-generatedStable view id threaded into per-section ARIA row ids; required for SSR.
emptyLabelstringDEFAULT_REGISTER_EMPTY_LABELZero-row guidance text shared by every section.
amountFormatAmountFormatAMOUNT_FORMAT_COMMA_DOTAmount separators/grouping shared by every section.
smoothScrollSettingsSmoothScrollSettingsDEFAULT_SMOOTH_SCROLL_SETTINGSSpring tuning for the shared smooth-scroll engine (one per view).
onRowSelect(account, row, index) => voidFired when any row is clicked, with the owning account path.

Sections are LedgerSection values: { account: string; rows: readonly RegisterRowData[] }.

Reconciliation options

ReconciliationOptions:

OptionTypeDefaultDescription
accountstringrequiredCanonical colon-delimited path of the account being reconciled.
periodLabelstringOptional period caption shown next to the account, e.g. Jul 2026.
statementLinesreadonly StatementLine[]requiredParsed bank lines, integer minor units.
postingsreadonly BookPostingRef[]requiredBook-side posting references ({ entry, postingIndex }).
matchesreadonly ReconciliationMatch[]proposeMatches(statementLines, postings)Initial match set; pass your own (kind manual allowed) to seed a session.
dateWindowDaysnumber3Suggestion window forwarded to the default proposeMatches.
maxGroupSizenumber3Sum-pass group cap; pass 1 to disable sum matching.
amountFormatAmountFormatAMOUNT_FORMAT_COMMA_DOTDescriptor for every figure the view renders and announces.
workerPoolWorkerPoolManagernone (inline)Off-thread proposals; both paths produce the identical deterministic match set.
colorScheme'light' | 'dark' | 'system''system'Pins how light-dark() colors resolve on the host element.
disableAnnouncementsbooleanfalseOpts out of the built-in per-currency difference announcements.
onAccept(match: ReconciliationMatch) => voidFired after a match flips to accepted.
onReject(match: ReconciliationMatch) => voidFired after a match flips to rejected; state updates synchronously.
onUndo(match: ReconciliationMatch) => voidFired after an accepted/rejected match returns to proposed.
onCreateEntry(line: StatementLine) => voidFired from the create-entry affordance; the component never writes entries.

getState() returns ReconciliationState: the current matches plus the per-currency difference map.

EntryStream options

EntryStreamOptions extends EntryRenderOptions, so showLineNumbers and amountFormat apply per streamed card.

OptionTypeDefaultDescription
streamReadableStream<LedgerEntry> | AsyncIterable<LedgerEntry>requiredSource of entries; consumed exactly once, cancelled on cleanUp.
totalnumberExpected total, shown as n / total in the sticky footer.
autoScrollbooleantrueStick-to-bottom until the user scrolls up; re-engages at bottom.
colorScheme'light' | 'dark' | 'system''system'Pins light-dark() resolution on the host.
onEntry(entry: LedgerEntry, index: number) => voidFired per entry after it is queued for render (0-based index).
onDone(count: number) => voidFired when the stream closes with the number of entries seen.

EntryDiff options

EntryDiffOptions extends EntryDiffRenderOptions.

OptionTypeDefaultDescription
amountFormatAmountFormatAMOUNT_FORMAT_COMMA_DOTDescriptor for posting amounts and imbalance figures in the diff.
colorScheme'light' | 'dark' | 'system''system'Pins how light-dark() colors resolve on the host element.

proposeMatches options

ProposeMatchesOptions, the third argument of proposeMatches:

OptionTypeDefaultDescription
dateWindowDaysnumber3Max |book − statement| days for the suggestion and sum passes; 0 = exact only.
maxGroupSizenumber3Max postings a sum match may group; 1 (or 0) disables sum matching.
maxSumCombinationsnumber10_000Cap on combinations explored per statement line during the sum pass.

Utilities

Every remaining runtime export, one line each. Entries marked internal are plumbing shared by the client, worker, and SSR paths — stable exports, but not the intended host API.

Formatting

ExportPurpose
formatMinorUnits(amount, currency, options?)Integer minor units to a display string via digit slicing; FormatMinorUnitsOptions carries sign: 'auto' | 'always' | 'never' and amountFormat.
getCurrencyDecimals(currency)Minor-unit decimal places from CURRENCY_DECIMALS; unknown codes use 2.
resolveAmountFormat(locale)Resolves a locale tag to an AmountFormat preset once at the host boundary — renderers never consult Intl.
formatPeriodLabel(key)Locale-free English label for a period key (2026-03March 2026).
getRegisterPeriodKey(date, groupBy)Deterministic period key (2026-03, 2026-Q1, 2026) for an ISO date.
escapeHtml(value)HTML-escapes interpolated text; every renderer routes through it.
renderAccountPathHTML(account) internalAccount path markup with punctuation-colored separators.
renderFilterHighlightHTML(text, lowerQuery) internalEscape-safe <mark data-filter-match> wrapping of filter matches.

Data & diffing

ExportPurpose
diffEntryVersions(before, after)Pure EntryVersionDiff between two entry versions; null on either side models creation/deletion.
diffWords(before, after)Word-level LCS diff (WordDiff of WordDiffSegment runs); space-separated changed runs merge.
getEntryImbalances(entry)Per-currency posting sums that are not exactly zero, as Map<string, MinorUnits>.
proposeMatches(lines, postings, options?)Deterministic reconciliation proposals: exact, suggested, then bounded sum matching.
toRegisterRowData(row)Converts a @cynco/ledger-core register row (LedgerCoreRegisterRow) to RegisterRowData.
createEntryStreamFromArray(entries, options?)Demo/test helper: array → ReadableStream on a fixed cadence (CreateEntryStreamFromArrayOptions.delayMs, default 0).
computeReconciliationRows(state) internalDerives the split-view rows (ReconciliationRow, typed by ReconciliationRowType) from a ReconciliationRenderState.
computeReconciliationTotals(state) internalStatement / cleared / difference maps per currency (ReconciliationTotals), integer math.
getReconciliationRowKey(row) internalStable row key for the reconciliation leave-animation reconciler.

Equality fast paths

ExportPurpose
areEntriesEqual(a, b)Structural LedgerEntry equality — the re-render skip for JournalEntry.
areEntryDiffInputsEqual(prevBefore, prevAfter, nextBefore, nextAfter)The same skip for EntryDiff's before/after pair.
areRegisterRowArraysEqual(a, b)Structural row-array equality, so fresh-but-identical arrays count as unchanged.
areVirtualWindowSpecsEqual(a, b)Pixel-window equality (VirtualWindowSpecs).

Row models & windowinginternal: the pure math between scroll position and rendered range.

ExportPurpose
buildRegisterRowModel(rows, groupBy)One O(n) pass building the grouped RegisterVirtualRow[] model.
buildFilteredRegisterRowModel(rows, groupBy, matchedEntryIndexes)The grouped model restricted to matches, with recomputed group summaries.
buildRegisterFilterCorpus(rows)Lazy lowercase search corpus (RegisterFilterCorpus), built once per data set.
computeRegisterFilterMatches(rows, filter, corpus?)Matched entry indexes for a RegisterFilter.
prepareRegisterFilter(filter)Normalizes a filter to a PreparedRegisterFilter (lowercased query, field set).
computeRowModelOffsets(model, entryRowHeight, groupRowHeight)Prefix-sum pixel offsets (Float64Array) for the two-height row space.
computeRowWindow(props) / computeGroupedRowWindow(props)Visible RowRange from a pixel window (ComputeRowWindowProps / ComputeGroupedRowWindowProps); flat is pure arithmetic, grouped is binary search.
createWindowFromScrollPosition(props)VirtualWindowSpecs from scroll metrics (WindowFromScrollPositionProps).
findRowIndexAtOffset(offsets, y)Binary search: model index at a pixel offset.
findFirstRowOnOrAfterDate(rows, isoDate)Binary search behind scrollToDate; null past the last row.
finalRegisterBalances(rows)Closing per-currency balances for the sticky header.
commitRegisterRowsHTML(rowsElement, html)Commits a window with a single write; returns the RegisterCommitMode used ('replace' | 'morph').

HTML string buildersinternal: DOM-free renderers shared verbatim by client, worker, and SSR, which is what makes byte parity provable. renderEntryHTML, renderEntryFooterHTML, renderFlagDotHTML, renderEntryDiffHTML, renderRegisterHTML, renderRegisterHeaderHTML, renderRegisterRowHTML, renderRegisterRowsHTML, renderRegisterVirtualRowsHTML, renderRegisterWindowHTML, renderRegisterGroupRowHTML, renderRegisterEmptyStateHTML, renderStickyGroupLabelHTML, renderStickyGroupContainerHTML, renderReconciliationHTML, renderReconciliationHeaderHTML, and renderReconciliationRowHTML.

Infrastructure

ExportPurpose
VirtualizerScroll-window engine; VirtualizerConfig carries overscrollSize and intersectionObserverMargin, registered instances implement VirtualizedInstance.
SmoothScrollerCritically-damped spring animator; SmoothScrollerOptions carries settings and an onScrollFrame hook.
InteractionManagerDelegated row hover/click wiring; InteractionManagerOptions callbacks receive RowSelectModifiers.
queueRender(cb) / dequeueRender(cb)The shared animation-frame render queue every component commits through — one layout per frame.
createLiveRegion(parent)Visually-hidden aria-live="polite" region (LiveRegion: announce, destroy), created empty.
applyHostColorScheme(container, colorScheme)Applies or removes the inline color-scheme pin on a host element.
prefersReducedMotion()True when the OS requests reduced motion; every smooth scroll and leave animation checks it.
isComposingEvent(event)True for keydown events inside an active IME composition (including legacy keyCode === 229).
journalsThemeVariables(roles)Maps a @cynco/theme role set onto the --journals-theme-* variable layer.

Minor-unit boundary guards — the fail-loud rail for non-integer amounts:

ExportPurpose
isValidMinorUnits(value)True for safe-integer minor-unit amounts.
warnInvalidMinorUnits(context, value)Warn-once console report for an unsafe amount, keyed by context.
warnIfInvalidRegisterRows(rows)Boundary sweep over register rows entering a render.
warnIfInvalidEntryAmounts(entry)Boundary sweep over one entry's posting amounts.
resetInvalidMinorUnitsWarnings()Resets the warn-once state — exported for tests.

Constants

ExportValueMeaning
JOURNALS_TAG_NAME'journals-container'The custom element every component renders into.
DEFAULT_FONT_SIZE / DEFAULT_LINE_HEIGHT13 / 20Type metrics mirroring --journals-font-size / --journals-line-height.
DEFAULT_HEADER_HEIGHT44Sticky register header height (1lh + 24px padding).
DEFAULT_OVERSCAN_ROWS10Extra rows rendered above/below the visible window.
DEFAULT_VIEWPORT_HEIGHT400Fallback viewport height when the scroller reports no layout.
GROUP_HEADER_EXTRA_HEIGHT / DEFAULT_GROUP_HEADER_HEIGHT8 / 28Group header padding beyond one line, and the resulting default row height.
REGISTER_EMPTY_EXTRA_HEIGHT56Empty-state height beyond one line; feeds section offset estimates.
DEFAULT_REGISTER_EMPTY_LABEL'No transactions in this view'Default zero-row guidance text.
RECON_VERDICT_LEAVE_MS180Reconciliation leave-animation duration; mirrors --journals-verdict-duration.
MAX_FIELD_DIFF_LENGTH1000Field length cap for word-level diffing; longer fields render wholly changed.
DEFAULT_SMOOTH_SCROLL_SETTINGS{ omega: 0.015, epsilonPx: 0.5, epsilonVelocity: 0.05 }Default spring tuning (~440ms glide to 99% settle).
MAX_SMOOTH_SCROLL_FRAME_DT50Frame-delta clamp (ms) so tab-wake never teleports a glide.
SSR_MAX_PRELOADED_ROWS_PER_SECTION / SSR_MAX_PRELOADED_TOTAL_ROWS128 / 512preloadLedgerViewHTML row caps, per section and across the view.
REGISTER_COLUMN_COUNT5Gridcell columns per entry row; group headers span all of them.
MINUS_SIGN'\u2212'Proper minus for credit amounts and negative balances.
AMOUNT_FORMAT_COMMA_DOT / AMOUNT_FORMAT_DOT_COMMA / AMOUNT_FORMAT_SPACE_COMMA / AMOUNT_FORMAT_APOSTROPHE_DOT / AMOUNT_FORMAT_INDIANfrozen presets1,234.56, 1.234,56, 1 234,56 (U+202F), 1'234.56, 12,34,567.89.
CURRENCY_DECIMALS26-entry tableISO 4217 minor-unit exceptions; unlisted codes use 2.
JournalsContainerLoadedtrueCustom-element registration flag — component wiring, not host API.

Types

Component lifecycle props: JournalEntryRenderProps / JournalEntryHydrateProps, RegisterRenderProps / RegisterHydrateProps / RegisterHydrateSectionProps, LedgerViewRenderProps / LedgerViewHydrateProps, ReconciliationRenderProps / ReconciliationHydrateProps, EntryDiffRenderProps / EntryDiffHydrateProps, and EntryStreamRenderProps — each pairs the component's data with an optional container / parentNode mount target.

Everything else, grouped:

  • Core dataMinorUnits, EntryFlag ('cleared' | 'pending' | 'flagged' | 'void'), Posting, LedgerEntry, RegisterRowData, StatementLine, BookPostingRef, AmountFormat, ColorScheme.
  • RegisterRegisterDensity, RegisterGroupBy, RegisterGroupSummary, RegisterVirtualRow (= RegisterGroupVirtualRow | RegisterEntryVirtualRow), RegisterFilterField ('description' | 'date' | 'flag'), RegisterFilterResult, RegisterSelectionMode, RegisterSelection, RegisterSelectionChange, RegisterSelectedRows (number | ReadonlySet<number> | null), RowRange, ScrollToRowOptions, SmoothScrollSettings.
  • ReconciliationMatchKind ('exact' | 'suggested' | 'sum' | 'manual'), MatchStatus ('proposed' | 'accepted' | 'rejected'), ReconciliationMatch, ReconciliationState.
  • DiffFieldChangeKind, WordDiffSegment, EntryFieldDiff, EntryListItemDiff, EntryListDiff, PostingDiff, PostingDiffKind ('unchanged' | 'amount-changed' | 'added' | 'removed'), EntryVersionDiff, EntryVersionDiffKind ('created' | 'deleted' | 'modified' | 'unchanged').
  • ViewLedgerSection, VirtualWindowSpecs.