Component File Partitions Must Preserve Locality
When deep extraction is preferred, when a single-consumer partition is allowed for navigation, and which seams still destroy locality
Amendment — 2026-09-28 (owner)
File splits are welcome. The owner relaxed this decision: splitting a large
component file by concern is ordinary work and needs no separate design
review. Where the split pieces share many values, bundle them into one named
Interface (a React context or one typed object) rather than threading them
through props; with that bundle, splitting a render into child components is
allowed. The GB-84 split of drafting-editor.tsx (CanvasCardContext,
canvas-block-card.tsx, canvas-module-card.tsx) is the model. What remains
refused: a file that only forwards props unchanged, and cuts at arbitrary line
counts. The text below is kept as the earlier record; where it is stricter
than this amendment, this amendment wins.
Decision
Prefer deep extraction, but permit a single-consumer file partition when it materially improves navigation and keeps a small, cohesive Interface. A file partition may be organisational rather than a claim that the child is an independently deep CodeModule. That is acceptable; the test is whether the partition preserves locality instead of replacing one large file with a props-shaped conduit between files.
A convenience partition is permitted when:
- the extracted file owns its subtree completely;
- its Interface is a modest set of meaningful inputs — roughly 8–10 is a review prompt, not a mechanical limit;
- mutable refs, state setters and render closures do not cross the seam; and
- an ordinary change to the extracted concern can be understood and completed without reading and editing both files.
Single-consumer status is therefore not a refusal by itself. It removes the reuse argument, but navigation and context size can still justify the file if the locality test passes. Three specific shapes remain rejected repo-wide:
| Rejected split | Why it is refused |
|---|---|
Moving a component's props type to a sibling *.types.ts | The props type is the CodeModule's Interface. Separating it from the Implementation leaves two files that must be read together to answer any question about either. |
| Splitting a component's render into per-branch child components | Branches that share state, a closure or a wrapper can only be split by threading that state through props — the conduit-layer disease, restated inside one folder. |
| Extracting a private subtree through a large forwarding Interface | Same threading cost, no locality gain: the reader still needs both files, while the props list calcifies an incidental parent closure. |
What a component file does give up first: pure clusters with their own tests, rendering vocabularies with more than one consumer, and self-contained subtrees with their own props contract. Those are deep extractions — the file loses a concern entirely and the reader gains a CodeModule they can understand alone. A convenience partition is the secondary tool for a coherent subtree that improves scanning or fits working context better without pretending to have the same architectural leverage.
The *.types.ts pattern shown in
Codebase Versioning is not adopted
for component props. It has zero uses in the component tree. That doc's concern is
cross-version type sharing between v1/ and v2/, which is a real problem
when two versions must agree on a wire shape. It is not a licence to file a
component's own props away from the component, and adopting it for that
reason would set a repo-wide precedent on the strength of one file's line
count.
Context
Amendment — 2026-08-03
The original decision treated a single consumer as sufficient reason to refuse every private subtree extraction. The owner rejected that absolute: smaller files can be useful for human navigation and agent context even when reuse is not the goal.
The amendment does not approve arbitrary splitting and does not reverse the
specific BlockCard findings below. It changes the deciding question from
“does a second caller exist?” to “does this file seam preserve locality with a
small cohesive Interface?” The earlier refusals remain evidence about those
particular candidate seams, not a prohibition on convenience partitions as a
class.
Candidate 9 in the architecture-deepening plan brought block-card/v4 up for
decomposition: block-card.tsx at 1,925 lines and block-fields-view.tsx at
846, the two largest files in the repo and both well past the deepening
trigger in File Size.
The grill accepted six extractions and refused three splits. The accepted six
each pass that doc's honest test — after the move, a reader answers a question
about the extracted concern by reading only the new module. chunkContent is
a pure markdown reader with its own node test. getDefaultDisplaySource is a
pure function with its own node test. The field-type marks are a pure,
string-keyed lookup already tested through. DataFieldRow is a self-contained
row with its own props.
The three refusals all failed the same test, from different directions.
BlockCardProps is ~185 lines and it is the Interface. Moving it out
would shrink block-card.tsx by a tenth and buy nothing: nobody can review a
prop's meaning without the component that consumes it, and nobody can review
the component without the props. The two would be read together always,
edited together always, and versioned together always — which is the
definition of one CodeModule.
BlockCard's content and data branches share the renderControlPanel
closure and the candidate wrapper. Splitting them into ContentBlockCard
and DataBlockCard would mean passing a render closure down as props. That is
the shape the
BlockAuthoringActions grouping
was a reaction against: files whose whole content is restating a signature
and forwarding it on.
An earlier draft of this decision also named the facet-height measurement
state as shared by both branches. That was wrong, and re-grilling the seam
on 2026-08-02 established the correction: the data branch contains zero
references to contentFacetRef, facetScrollerRef, contentFacetHeight,
grownHeight or isContentFacetActive. The machinery is confined to the
content branch.
The correction strengthens the refusal rather than weakening it. It means the
height state entangles the content branch specifically — extracting that one
would have to thread a ref and a setState across a props seam while the
effect reading them stayed behind, which is a shared mutable cell with a
props-shaped hole in it, not an interface. And the data branch, freed of this
objection, still fails on its own terms: it takes 20 free variables, 12 of
them pure forwards to BlockFieldsView. Counted at 1,435 lines, the file's
size remains the right shape.
FieldRow in block-fields-view.tsx is 370 stateless lines — 44% of its
file, and superficially the most extractable thing in the folder. It takes
~20 props from BlockFieldsView and has exactly one consumer. Extracting it
converts an internal call into a 20-prop interface that no second caller will
ever use, and a reader chasing a field-row question still ends up in both
files. It comes out if and when a second consumer appears; not before.
When a convenience partition still fails
The cleanup does not happen and the Interface calcifies. Once
FieldRow has a 20-prop exported contract, every later change to
BlockFieldsView negotiates with it, and the props list grows rather than
dissolving. Convenience is not enough to justify refs, setters, render
closures, or a large forwarding object crossing the seam. The cheap moment to
refuse a bad seam is before it exists.
Why not leave the files alone entirely
Because six of the nine candidate seams are genuinely deep, and two of them
were already being reached through: content-chunks.node.test.ts and
default-display-source.node.test.ts both deep-imported
src/entities/block/ui/block-card to
get at pure functions. The test surface had already found the module boundary
that the file layout was refusing to draw. Refusing the three bad splits is
not an argument for refusing the six good extractions.
Why not a v5/
The Interface does not change — the barrel index.ts is untouched and all
five external consumers keep importing exactly what they imported before.
Codebase Versioning versions on
Interface change, and candidate 4 already set this precedent: a pure internal
reshape stays in place. No subfolders either; the repo has zero precedent for
nesting inside a vN/ directory.
One Domain Rulebook, Concept-Sliced Convex, Standard FSD
Why the codebase uses one runtime-free canonical domain package shared by frontend and Convex, concept-sliced Convex adapters, and standard Feature-Sliced Design for the frontend — and which structural rules each brand promise obliges
Container Screens Share One Anatomy
Owner rulings 2026-09-14 — Workspace and Project are one screen, built from neutral compound parts; the parent folio owns the portal holding its children; container screens keep the Goldie sidebar; a child card opens on click while its own controls keep their actions; manual tag entry is retired.