Architectural decisions and patterns for contributors. Not a tutorial. If you're getting started, see the README first.
Four packages, strict dependency direction: core <- engine <- vanilla <- react. No lateral imports.
| Package | Purpose | DOM? |
|---|---|---|
core |
Types, theme, colors, a11y, locale, text measurement | No |
engine |
Headless computation: spec in, layout out. Pure math. | No |
vanilla |
Imperative DOM rendering (SVG charts, HTML tables) | Yes |
react |
React components wrapping vanilla with lifecycle management | Yes |
Where does new code go? If it touches the DOM, it belongs in vanilla or react. If it does math or data transformation, it belongs in engine. If it's a type definition, color utility, or theme primitive, it belongs in core.
Each package re-exports core types for convenience so consumers don't need to import from multiple packages. The canonical type definitions always live in core/src/types/.
core <-- engine <-- vanilla <-- react
engineimports fromcore(types, theme, colors, layout utilities)vanillaimports fromengineandcorereactimports fromvanilla,engine, andcorecoreimports from nothing (zero internal dependencies)
Never import upstream. If core needs something from engine, the design is wrong.
Each package has an src/index.ts barrel that defines its public API. Named exports only, no default exports.
- Public: Exported from
index.ts. Consumers can depend on these. - Internal: Not in
index.ts. Available via deep imports but considered unstable.
When adding to the public API, use explicit named exports (not export * from implementation files). Group exports by domain with comment headers. Re-export core types at the bottom for consumer convenience.
Chart types follow a registry pattern. Each chart registers itself via side effects when its module is imported.
File structure (use packages/engine/src/charts/bar/ as a template):
charts/
<type>/
index.ts # Registry hook + public exports
compute.ts # Mark computation (the core algorithm)
labels.ts # Label positioning for this chart type
__tests__/
compute.test.ts
Some chart types have additional files for variants (e.g., line/area.ts, scatter/trendline.ts).
Steps:
- Create the directory under
packages/engine/src/charts/<type>/ - Write
compute.tswith a function that takes(spec, scales, chartArea, strategy)and returnsMark[] - Write
labels.tswith a function that computesResolvedLabel[]for the marks - Write
index.tsthat:- Defines a
ChartRendererfunction composing compute + labels - Calls
registerChartRenderer('<type>', renderer) - Exports the compute/label functions
- Defines a
- Add
import './charts/<type>/index'topackages/engine/src/index.ts(the import triggers registration) - Export the compute functions from
packages/engine/src/index.ts - Add the type string to
ChartTypeunion incore/src/types/spec.ts - Add encoding rules in
core/src/types/encoding.ts - Write tests in
__tests__/compute.test.ts
Reference: charts/bar/ is the simplest. charts/line/ shows multi-renderer registration (line + area).
Table cells use a discriminated union on the cellType field. Each cell type has a type definition, a computation function, and a renderer.
Steps:
- Add the interface to
core/src/types/layout.ts:- Extend
TableCellBase - Set
cellType: '<name>'as a literal discriminant - Add any type-specific fields (e.g.,
barWidth,sparklineData)
- Extend
- Add the new type to the
TableCellunion incore/src/types/layout.ts - Add the cell type string to the
cellTypeunion inResolvedColumn - Write computation in
engine/src/tables/<name>.ts(returns aMap<number, CellStyle>or type-specific data) - Wire it into
engine/src/tables/compile-table.ts(follows the existing precedence chain) - Write the renderer in
vanilla/src/renderers/table-cells.ts - Add CSS classes (prefixed
oc-table-<name>) to CSS partials incore/src/styles/ - If there's a user-facing config, add it to
core/src/types/table.tsas aColumnConfigfield
Reference: BarTableCell for a cell with extra computed fields. HeatmapTableCell for a cell that only needs styling.
| File | Purpose |
|---|---|
index.ts |
Barrel exports for a module. Also registration for chart types. |
compute.ts |
Core computation logic (marks, cells, layout) |
labels.ts |
Label positioning and collision resolution |
types.ts |
Types internal to that module (not public) |
utils.ts |
Shared helpers within a package |
__tests__/*.test.ts |
Co-located tests matching the source file name |
Discriminated unions: Mark types (LineMark, RectMark, etc.) and table cell types (TextTableCell, BarTableCell, etc.) use a type or cellType string literal discriminant. Always narrow with the discriminant before accessing type-specific fields.
Spec vs layout types: core/src/types/spec.ts defines user input (lots of optionals, permissive). core/src/types/layout.ts defines engine output (fully resolved, all required fields, computed positions). Don't mix them up.
JSDoc: Required on all public types and exported functions. Brief description, then @param / @returns where useful. Skip JSDoc on internal helpers unless the intent isn't obvious.
All layout type fields are required unless there's a clear reason for optionality (e.g., a connector line that only exists when labels are displaced).
- Framework: Vitest with workspace projects (root
vitest.config.tsreferences per-package configs) - Location: Co-located
__tests__/directories next to the source - File naming:
<source-file>.test.ts(e.g.,compute.ts->__tests__/compute.test.ts) - Environment:
nodefor core and engine (no DOM needed),happy-domfor vanilla and react - Pattern: Create fixture factory functions at the top of the file, then
describeblocks per function under test - Imports: Import from vitest (
describe,it,expect), then from the module under test using relative paths
Run all tests: bun run test from the repo root.
The theme system has two layers:
- CSS custom properties in
core/src/styles/tokens.cssfor visual styling - TypeScript theme objects in
core/src/theme/for computed values the engine needs
CSS custom property naming: Always prefix with --oc-. Use descriptive names: --oc-tooltip-bg, --oc-text-secondary. Dark mode overrides go in the .oc-dark class.
CSS class naming: Always prefix with oc-. Use oc-[block]-[element] for children (e.g. oc-table-sort-btn) and oc-[block]--[modifier] for modifiers (e.g. oc-table--compact).
Adding a theme property:
- Add the CSS custom property to
.oc-root, .oc-table-wrapper(light default) intokens.cssand.oc-darkindark.css - If the engine needs the value for computation, add it to the
Theme/ResolvedThemetypes incore/src/types/theme.ts - Set the default in
core/src/theme/defaults.ts - Handle dark mode adaptation in
core/src/theme/dark-mode.ts
Font stack: Inter as the primary font, system-ui fallback. Monospace uses JetBrains Mono with fallbacks. Defined in --oc-font-family and --oc-font-mono.
- Package manager: Bun (workspace root and all packages)
- Bundler: tsup (configured per-package)
- Test runner: Vitest
- Linter: ESLint 9
- Formatter: Prettier
- TypeScript: Strict mode, each package has its own
tsconfig.json
Key commands from the repo root:
bun run build # Build all packages in dependency order
bun run test # Run all tests
bun run typecheck # Type-check all packages
bun run lint # Lint all packages
bun run dev # Start examples dev server (Ladle)