TheAccessibleOrgChart — Multiple-Chart Support Design
Design date: 2026-07-13. No code changes — this is a design/best-practice proposal for how the product should support multiple org charts. Grounded in a review of the current data model (packages/org-chart-shared/src/types.ts), the import/generate pipeline (workers/api/src/routes/org-chart/), and the render-from-managerId templates. Follows on from docs/admin/org-chart-competitive-analysis.md (Phase 3/4 work).
Problem
We want the product to support “multiple charts” from three requested angles:
- Parse spreadsheets with multiple tabs — should each tab become its own chart?
- Let a user designate any node as a root and create a new chart from it.
- Embed charts inside other charts.
Answered naively, each of these produces duplicated, drifting data — the exact weakness of today’s single-owner, one-dataset-per-chart model. This doc recommends a single architectural move that makes all three easy and safe.
Current model (verified in code)
OrgChartconflates three concerns: the dataset (people + relationships), the presentation (template, density, colors, branding), and the commercial unit (one Stripe finalization + one 1-year hosting window + one owner).Person.managerIdpoints to another person in the same chart. The tree is derived at render time frommanagerId— it is not a stored, materialized hierarchy.- Versioning, sharing, hosting, and payment are all per-
OrgChart.
The render-from-edges fact is the unlock: “a different chart” is usually just “a different starting node and filter over the same edges.”
Core recommendation: separate the org from the chart
Split today’s OrgChart into two layers.
| Layer | What it is | Cardinality |
|---|---|---|
| Org (dataset) | Canonical people + reporting/relationship edges. Source of truth. | One per real organization |
| Chart (view) | { orgId, rootPersonId?, maxDepth?, filter, templateId, density, colors, branding } — a saved lens onto the org. Cheap, live, derived. | Many per org |
Guiding invariant: people/edges are stored once, per org. A chart is a view, never a copy. The failure mode to avoid at all costs is duplicating people into N chart rows so one title change must be made in five places.
This is additive to the existing model — OrgChart today already holds the dataset; the change introduces a lightweight “view” record (root + filter + presentation) that references it, and lets one org own many views.
Current import surface (verified in code)
Before designing multi-tab support, note what exists today:
- Accepted inputs: image (
png/jpeg/bmp) andpdf→ AI vision extraction;csv→ structured import; manual editor; developer REST API (POST /api/v1/charts/generate). - Spreadsheets: CSV only. There is no
.xlsx/.xlssupport. The API MIME allowlist (packages/org-chart-shared/src/constants.ts:15) isapplication/pdf+image/png|jpeg|bmponly; CSV is a separate text path. The uploadacceptfilter isimage/*,application/pdf,.csv,text/csv. - Structure is a hybrid — a downloadable CSV template with canonical headers (
name,title,department,email,phone,location,employeeId,managerName,secondaryManagerNames,photoUrl,linkUrl,editor/page.tsx:1191) plus forgiving fuzzy column matching (workers/api/src/utils/csv-parser.ts:STANDARD_FIELDS+FIELD_MAP+ Levenshtein) that maps aliases likefullname→name,jobtitle/position/role→title,dept/division→department,reportsto/supervisor→manager,dottedline/additionalmanagers→secondaryManagers. - Hierarchy is by manager name reference (each row names its manager, matched to another row’s name) — not by ID, parent column, or indentation. Only
titleis strictly required;nameis optional (position-only charts). - Known bug (Critical #4, competitive analysis): export joins secondary managers with
;but the parser splits on,, so dotted lines silently vanish and CSVs don’t round-trip.
Format & library decision
No spreadsheet library exists in the monorepo today — this is a greenfield choice, and the library determines whether legacy .xls is nearly free or a real burden.
- Support
.xlsx(+ existing.csv)..xlsis not required..xls(binary BIFF8) has been deprecated since Excel 2007; Excel, Google Sheets, and every modern HRIS (Workday, BambooHR, Rippling) export.xlsx/.csv. XML-based.xlsx(a ZIP of OOXML) is far easier and safer to parse than the proprietary, more CVE-prone binary.xls. - Use SheetJS (
xlsx). One API reads.xlsx,.xls, and.odsthrough the same code path — so.xls/.odslong-tail support (relevant for the gov/edu buyer running legacy or LibreOffice systems) is essentially free. Accept.xlspassively (parse if it arrives) but market only.xlsx/.csv. - Fallback if SheetJS is unacceptable: ExcelJS is XML-only (
.xlsx+.csv, cleaner API) but reads no.xls. In that case ship.xlsx-only and show a “Save as .xlsx or .csv and re-upload” message on.xls— do not build a bespoke binary.xlsparser. - Runtime: parse spreadsheets server-side on the Node API (
.4), not at the edge. SheetJS’s community build is heavy CJS; if any import path ever runs in a Cloudflare Worker, verify the bundle there separately. - Security (any format): read cell values only; ignore macros (
.xlsm/.xlscan carry them); apply the same formula-injection sanitization flagged for CSV export; cap file size in the allowlist change.
1. Multi-tab spreadsheets
Hard prerequisite: XLSX ingestion does not exist yet. We accept CSV only, and CSV is single-sheet by definition — it cannot carry tabs. This feature therefore starts with adding real spreadsheet parsing (SheetJS/
xlsx— see the Format & library decision above) to both theacceptfilter and the API MIME allowlist, not merely a parser tweak. Sequence this ahead of any tab-handling logic.
Do not hardcode “one tab = one chart.” Tabs are used inconsistently in the wild — sometimes a tab is a department, sometimes a lookup/legend table, sometimes a prior-year snapshot.
- Make import a mapping step with a preview (we need this UI regardless — today’s fuzzy matching guesses silently and import errors just say “check console”). The preview should show the detected column→field mapping and let the user correct it. Offer: combine all tabs into one org (tab name →
department/groupcolumn), create a separate chart per tab, or ignore selected tabs. - Heuristic default: tabs with matching column schema → propose “one org, tab = department”; differing schemas → propose “separate charts.” Always confirm; never silently create N charts.
- When combined, the tab name becomes a filter dimension that feeds per-department views (§2) — one import, many charts, no duplication.
- Fix the
;/,delimiter bug and consider ID-based hierarchy (employeeId+managerId) as an unambiguous alternative to name-matching while touching the import path — name-matching breaks on duplicate names, which is common at large-org scale.
2. Designate any node as a new root
Yes — as a lightweight view, not a data fork. This is the ChartHop/Workday “focus on this person’s org” pattern; render-from-managerId supports it for free by starting traversal at rootPersonId.
- Default action — “Create view from here”: stores
{ orgId, rootPersonId, maxDepth?, template }. Live: when the org changes, every departmental view updates. This is how you get a dozen departmental charts from one university import. - Explicit, rare action — “Fork to independent chart”: copies the subtree into a new dataset. Reserve for what-if / scenario / archival snapshot, and label it clearly as a point-in-time copy that will drift.
Enforce the distinction in the UI: re-root is cheap and live; fork is heavy and diverges. The live view is the obvious default.
3. Embed charts inside other charts
“Embed” means three different things — keep them separate:
- (a) Composition / transclusion (the valuable one). A node in the master org references the root of another org maintained by a different team (central comms owns the university-wide chart; each college maintains its own and “plugs in” under its VP). A reference node transcludes another org’s subtree, resolved at render into one coherent tree. Genuine Institution-tier differentiator: distributed maintenance, one published artifact.
- (b) Drill-down / lazy-load (a scale technique). Expanding a node loads that department’s subtree on demand instead of rendering 20,000 nodes at once. Worth doing for large orgs regardless.
- (c) Visual iframe embed. Already exists for external pages. Nesting one iframe inside another is the pattern to avoid.
Hard accessibility guardrail (this is the moat): compose into a single, coherent ARIA tree with one keyboard/focus model. Never nest iframes-within-iframes or stitch multiple independent ARIA trees — it breaks arrow-key nav and screen-reader announcement across the boundary. Transclusion must flatten into one tree at render time. An accessibility product cannot ship a chart that fails its own standard.
Cross-cutting decisions (decide deliberately, not after)
- Monetization: payment/hosting is per-
OrgCharttoday. If charts become cheap views, choose whether to charge per published view or per org. Lines up with the Phase 3 pricing plan: Individual = 1 org / few charts; Department/Institution = unlimited orgs, views, and composition. - Versioning & sharing grain: currently per-chart. With the org/view split, prefer versioning the org (the data) and treating views as disposable configs. Decide before building.
- Accessibility test coverage: every new render path (re-rooted view, transcluded tree, lazy-loaded subtree) must be added to
test:a11yaxe fixtures — same discipline as Phase 2.
Recommended sequencing
- XLSX ingestion (prerequisite for multi-tab) — add SheetJS (
xlsx) parsing on the Node API for.xlsx(+ free.xls/.ods); wire into theacceptfilter and API MIME allowlist; read values only + sanitize + size-cap. Fix the;/,secondary-manager delimiter bug and consider ID-based hierarchy in the same pass. Without this, multi-tab is impossible (CSV is single-sheet). See “Format & library decision” in §1. - Import mapping/preview UI + multi-tab handling (also fixes the silent fuzzy-guess + “check console” error UX). Standalone value; no schema change required if tabs collapse into the existing single dataset with a
department/groupcolumn. - Org/View split — introduce the view record (
rootPersonId+ filter + presentation) referencing the existing dataset; “Create view from here” as the default multi-chart path. Migration required. - Fork-to-snapshot as an explicit, clearly-labeled action.
- Transclusion / composition (reference nodes) — Institution-tier feature; depends on the org/view split and single-tree render guarantee.
- Lazy-load subtrees for large-org scale, as needed.
Steps 2–4 pair naturally with Phase 3 (tiering/workspaces) from the competitive-analysis plan — the same migration/entitlement surface.
Bottom line
Don’t build “multiple charts” as multiple datasets. Build one org → many live views (re-root + filter), add a mapping step for multi-tab import that feeds those views, support transclusion for cross-team composition, and keep copy/fork as a rare, clearly-labeled snapshot — all rendering into a single accessible tree. This delivers every requested capability without the data-drift trap and reinforces the compliance moat rather than fracturing it.