Workbook Component Architecture
How the Sheets-tab component (apps/hr-system/src/sheets/) is structured. The component is registered as Alpine.data("_workbook", workbookSheetComponent) in apps/hr-system/src/main.ts and drives the entire spreadsheet UI (sheet list, grid render, cell editing, payroll commit, import/export, …).
Shape
workbook.sheet.ts is a ~70-line composition manifest — it imports the slices and assembles them:
export function workbookSheetComponent() {
return compose(createStateSlice(), createCoreSlice(), createMemoSlice(), /* … */);
}All behaviour lives in concern slices under apps/hr-system/src/sheets/workbook/. Each slice is a factory returning a plain object of state + methods + getters for one concern. There is no shared closure scope; slices only ever talk to each other through this.
The three load-bearing pieces
1. compose(...slices) — workbook/compose.ts. Merges the freshly-created slice objects into one component instance using Object.getOwnPropertyDescriptors + Object.defineProperties.
⚠️ It must NOT use object spread or
Object.assign. The component has ~70 getters; spreading invokes each getter at merge time and copies a frozen value, silently breaking Alpine reactivity (computed columns, filtered sheets, row counts stop updating). Descriptor-merge preserves the liveget/setaccessors. Guarded byworkbook/compose.test.tsandworkbook/component.test.ts.
2. WorkbookComponent — workbook/types.ts. The standalone interface every slice types this against, so this.openSheet(), this.engine, etc. type-check across files. It was generated from the original component via the TypeScript compiler API, not derived from the slices.
Do NOT try
type WorkbookComponent = typeof slicesorThisType<typeof-self>— it cycles (TS2456 / TS7023).WorkbookComponentis a concrete interface; the build is the safety net (any cross-slice typo is a compile error).
3. Slice factories — workbook/compose.ts workbookSlice(). Each slice is createXSlice = () => workbookSlice({...}). workbookSlice<S>(s: S & ThisType<WorkbookComponent>): S is an identity wrapper that pins this to the full component. Factories (not singletons) give each component instance fresh state.
Adding / changing code
- Find a method/getter: names are unchanged from the pre-split component —
grep -rn '<name>' apps/hr-system/src/sheets/workbook/. - Add a member to a slice: add it to the slice object and add its signature to the
WorkbookComponentinterface intypes.ts(elsethis.<new>won't type-check). - New concern: add
workbook/<concern>.tsexportingcreateXSlice, import + addcreateXSlice()to thecompose(...)call inworkbook.sheet.ts. - State lives together in
state.ts(reactive fields); it is not distributed per-slice. Where state lives doesn't affect correctness — it's all onthis.
Slices
| Group | Files |
|---|---|
| Infrastructure | compose.ts, types.ts, state.ts, core.ts (lifecycle: init/destroy, loadSheets, openSheet, renderToBody, setPeriod, core getters) |
| Sheet list / nav | sidebar.ts, groups.ts, sheet-crud.ts, templates.ts |
| Grid interaction | selection.ts, clipboard.ts, search.ts, filters-sort.ts, fill-handle.ts, menus.ts, cell-styles.ts |
| Cell editing | cell-edit.ts, cell-edit-autocomplete.ts |
| Columns / formulas | columns.ts, header-formula.ts |
| Rows / records | rows.ts, drafts.ts, bulk-add.ts, carry-forward.ts, memos.ts |
| Payroll / sources | payroll.ts, commit-compute.ts, import-export.ts |
| Domain-specific | treatment.ts, incentive.ts, attendance-summary.ts |
Cell editing
onCellDblClick → _isCellEditableForRow gate → _resolveCellEditDispatch (enum options + delegation to specialised editors) → _beginEdit, which builds the in-cell editor via one of eight per-kind builders returning { input, mountNode }:
_buildPlainTextEditor, _buildEnumSelectEditor, _buildDateEditor, _buildTimeEditor, _buildTimePeriodEditor, _buildMultiselectEditor, _buildEmployeeSelectEditor, _buildTreatmentCountEditor.
The commit/cancel wiring (blur/Enter/Escape) and _saveCellEdit (the SDK write + engine reload) stay in _beginEdit / cell-edit.ts.
History: this layout came from the 2026-06 refactor that split an 18,010-line workbook.sheet.ts into these slices (PR #360).