ui_primitives overhaul — implementation brief
Audit finding: web/src/components/ui_primitives/ is a naming layer over MUI,
not a design system. The import policy is enforced (never import raw MUI) but
the visual decisions were never made, so screens built 100% from primitives
still look amateur by default. This brief lists the verified defects with
file:line evidence, then the fixes in priority order.
Ground rules for the implementing agent:
- All work in
web/. After changes:cd web && npm run typecheck && npm run lint && npm test. - Keep the 4px grid (
spacing.ts), design tokens (docs/DESIGN.md), and the primitives-first policy (web/src/components/ui_primitives/STRATEGY.md). - Fix defaults in the primitives, not by adding props at call sites. The
measure of success: a bare
<Panel><FlexColumn><TextInput/><SelectField/></FlexColumn></Panel>with no styling props should look like a professional form. - Reference implementation of the target form look: the storyboard header in
web/src/components/storyboard/StoryboardBoard.tsx(localFieldwrapper, labels above controls, uniform 36px control heights, bordered panel). That file hand-fixes everything this brief systematizes; once the primitives are fixed, migrate it to the shared versions and delete the local workarounds.
Defect inventory (verified)
1. No control-level tokens
tokens.ts exports FONT_WEIGHT, FONT_SIZE_*, TYPOGRAPHY, MOTION,
Z_INDEX, BORDER_RADIUS — but no control height, control padding, or field
radius. Every primitive invents its own:
| Component | Value | Source |
|---|---|---|
| EditorButton | 24 / 28 raw px |
editor_ui/EditorButton.tsx:55 |
| Editor selects | 28px / 32px |
theme.editor.heightNode/heightInspector, themes/components/editorControls.ts:104,124 |
| TagInput | 48px |
ui_primitives/TagInput.tsx:51 |
| SearchInput radius | 8px |
ui_primitives/SearchInput.tsx:46 |
| Editor control radius | 6px |
themes/ThemeNodetool.tsx:76 |
| MuiButton radius | theme.rounded.buttonLarge |
themes/ThemeNodetool.tsx:241 |
Consequences: a compact EditorButton (24px) next to an editor select (28px)
misaligns by 4px; next to an inspector select by 8px. Four independent radius
sources. A second, parallel control scale lives on theme.editor that
EditorButton does not read.
Off-grid values inside the primitives whose own docs ban them
(spacing.ts:23 forbids 0.25/0.75/1.25 steps):
Slider.tsx:45—13pxpaddingNodeSlider.tsx:56—3pxmarginPanel.tsx:39—comfortable: 2.5units;Panel.tsx:151divides padding to1.25units (5px)
2. Unrelated defaults across primitives
TextInput.tsx:52→variant="outlined",size="medium"SelectField.tsx:98→size="medium",variant="standard"
A no-props TextInput above a no-props SelectField renders a boxed field above an underline-only field — the default composition is broken.
Surfaces:
Panel.tsx:88-90→background.default,bordered=false. Darkbackground.defaultis#08090A(themes/paletteDark.ts:268) — same as the page. A default Panel is invisible.Surface.tsx:64-68→background.paper,bordered=false,elevation=0, explicitboxShadow: "none". Different background and radius source (theme.rounded[...]) than Panel (theme.shape.borderRadius).
size semantics diverge: TextInput maps compact→MUI small;
SelectField’s standard variant barely responds to size;
EditorButton.tsx:65 does height: size ? undefined : height — passing
size silently discards the density height.
Flex containers: FlexRow.tsx:55, FlexColumn.tsx:53, Stack.tsx:61 all
default gap/spacing to 0, so naive composition glues controls together.
3. Four label treatments, none shared
- MUI floating label —
TextInput, hand-correcting MUI’s transform (TextInput.tsx:96translate(14px, 17px)), forced to 15px,opacity: 0.6. - MUI
InputLabel—SelectField.tsx:123-129, 15px, no opacity, no transform fix. Renders differently from TextInput’s at rest. Label.tsx:79-90—Typography component="label", 13px, weight 500,text.secondary, bakedmarginBottom: 2px.FormField.tsx:85-95— reimplements the same thing (does NOT useLabel), duplicating the required-asterisk logic (FormField.tsx:98-108vsLabel.tsx:16).
The theme sets a fifth treatment on MuiFormLabel
(ThemeNodetool.tsx:249-259): capitalize, 13px, padding: 0 0 8px 0 —
which TextInput/SelectField then override back to 15px, defeating the theme’s
label token exactly where labels matter.
The floating-label choice is also a bug class: the shrunk label renders above
the input box, and Panel.tsx:136 is overflow: hidden, so a TextInput at
the top of a Panel gets its label clipped. (This shipped: the storyboard
title. Worked around in StoryboardBoard.tsx with overflow: visible.)
4. No usable form composition primitive
FormFieldexists but perSTRATEGY.md:47is used in 1 file, with 14+ places doing label+input+helper by hand.FormFielddouble-labels: it renders its own label while TextInput/SelectField render theirs.SelectFieldhashideLabel(SelectField.tsx:48);TextInputhas no escape hatch.- No
FormRow,FormSection,FieldGroup, or grid primitive. Only flex helpers. - Spacing ownership is incoherent:
SelectField.tsx:117comments “spacing is the parent’s job” whileLabel.tsx:88,FormField.tsx:120,AutocompleteTagInput.tsx:110,EmptyState,ProgressBar,SectionHeader, andPanelbake their own margins. Stacking three fields yields four different gaps. TextInput additionally inherits MUI’s default helper-text margin (3px 14px 0) which nothing overrides — off-grid and different from SelectField’smt: 0.5(SelectField.tsx:158).
5. The type scale is fake
Text.tsx:14 advertises 8 sizes; ThemeNodetool.tsx:40-46 collapses them to
~4 real values: fontSizeBigger = fontSizeBig = 18px; fontSizeSmaller =
fontSizeTiny = fontSizeTinyer = 11px. <Text size="tiny"> and
<Text size="smaller"> are indistinguishable.
6. Zero-opinion passthroughs masquerading as primitives
Box.tsx— 2-line re-export.muiReexports.ts:108-137re-exportsTabs,ToggleButton,Accordion,LinearProgress,Modaletc. with a comment claiming “ThemeNodetool already styles them” — but the theme has noMuiTabs,MuiToggleButton,MuiAccordion, orMuiLinearProgressoverrides (onlyMuiButton,MuiFormLabel,MuiFormControl,MuiPopover,MuiModal,MuiToolbar,MuiDialogTitle,MuiTooltip,MuiDivider—ThemeNodetool.tsx:234-324). Those re-exports render stock MUI.- Contrast:
MuiTooltip(ThemeNodetool.tsx:301-312) gets backdrop blur, a divider border, and a layered shadow — more surface craft than any panel, card, or input receives.
Inputs have no default field background: only editor-scoped controls get
Paper.overlay (editorControls.ts:23, gated on a marker class).
SearchInput.tsx:48 gives itself action.hover — so search boxes read as
fields and plain text inputs don’t.
Fixes, in priority order
Do them as separate commits/PRs in this order. 1–3 are mechanical and improve every existing screen without call-site changes.
Fix 1: control tokens
Add to tokens.ts:
export const CONTROL = {
height: { compact: 28, normal: 36, comfortable: 44 }, // px
paddingX: { compact: 8, normal: 12 }, // px, on-grid
radius: BORDER_RADIUS.sm, // one field radius
} as const;
Wire it into: EditorButton (replace raw 24/28), SelectField, TextInput,
SearchInput (replace 8px radius), TagInput (replace 48px), and
theme.editor.heightNode/heightInspector/controlRadius (derive from CONTROL
so the two scales can’t drift). Fix the off-grid values in Slider.tsx:45,
NodeSlider.tsx:56, Panel.tsx:39/151 while touching them.
Acceptance: a compact EditorButton, a small SelectField, a small TextInput, and the ModelSelectButton in one FlexRow all share one height, one radius, one horizontal padding.
Fix 2: one label treatment
Standardize on label above the control: 13px (fontSizeSmall), weight
500, text.secondary, no text-transform, 4px gap to the control.
- Remove the floating-label path:
TextInputno longer forwardslabelto MUI; it renders the standard label above (via the sharedLabel). Delete the transform-correction sx (TextInput.tsx:88-101). SelectFieldsame: dropInputLabel, render the shared label above.FormFieldusesLabelinstead of its ownTypographyblock; delete the duplicated asterisk logic.- Remove
textTransform: "capitalize"and the 8px label padding fromMuiFormLabelinThemeNodetool.tsx:249-259(labels are no longer MUI-managed). - This kills the clipped-label bug class; the
overflow: visibleworkaround inStoryboardBoard.tsxstyles can then go.
Acceptance: TextInput, SelectField, and a labelled ModelSelectButton render pixel-identical labels; no label ever renders outside the control’s box.
Fix 3: coherent defaults
SelectField: defaultvariant="outlined"(matches TextInput). Audit the ~few call sites that relied onstandard.- Inputs get a default field background (
Paper.overlayin dark; verify light) so fields read as fields outside the editor scope, matching SearchInput. Panel: defaultbackground="paper"andbordered— a default Panel must be visible on the page background. Align its radius source withSurface(pick one:theme.roundedorshape.borderRadius).FlexRow/FlexColumn/Stack: keepgap=0default (too many call sites assume it) — instead fix via Fix 4’s form primitives which own their gaps.EditorButton: passingsizemust not discard density height — makesizemap onto theCONTROL.heightscale or remove the prop.
Acceptance: the bare no-props composition in the ground rules renders as a visible bordered panel containing two identically-styled fields.
Fix 4: real form primitives
- Rework
FormField: it owns the label (children render label-less), the 4px label gap, and the helper/error line with a fixed on-grid margin. EnsureTextInput/SelectFieldcompose without double labels (either via context flag or by convention: never passlabelto a control insideFormField). - Add
FormSection(optional uppercase group label — see.group-labelinStoryboardBoard.tsx— plus aFlexColumn gap={3}body) andFormGrid(n-column CSS grid with a stack breakpoint, like.header-gridthere). - Migrate
StoryboardBoard.tsxto these, deleting its localFieldand grid CSS. Migrate 2–3 of the 14+ hand-rolled label+input sites listed in STRATEGY.md as proof.
Fix 5: honest type scale
Either give the collapsed sizes real values (a modular scale: e.g. 11/12/13/15/18/22)
or remove the aliased size names from Text and fix call sites. Don’t leave
8 names for 4 values.
Fix 6: passthrough honesty
For each raw re-export in muiReexports.ts that the theme does not style
(Tabs, ToggleButton, Accordion, LinearProgress at minimum): either add
the theme override or move it out of the “themed” comment block so the file
stops claiming coverage it doesn’t have. Low priority; do last.
Verification
cd web && npm run typecheck && npm run lint && npm test- Visual: storyboard header (
/workspacestoryboard tab), node inspector, settings dialogs — before/after screenshots for each fix. - Grep gates after Fix 1: no raw
minHeight: "2[48]px"in primitives; noborderRadius: "8px"/"6px"literals inui_primitives/oreditor_ui/; no13px/3px/5pxpaddings in primitives.