feat(Box): add a general-purpose Box layout primitive - #1936
Merged
Merged
Conversation
Adds `<Box />`, a container that exposes Easy UI's design tokens as props, so consuming applications no longer need one-off CSS modules and inline `style` objects for layout and surface styling. An audit of easypost-web-app found 55 of its 93 SCSS modules contain nothing but properties a Box can express. The organizing rule is: constrain what has a token scale, leave free what does not. Space, color, border, and shadow props are typed to their scales; sizing and positioning accept free CSS values, because Easy UI has no size token scale. Implemented on the existing responsive-prop mixin and utilities/css.ts helpers, so no new styling mechanism is introduced — the static CSS-Modules-plus-custom-properties model is what keeps Box off the runtime-cost path that led GitHub Primer to remove theirs (ADR-016). Notable details: - Property cascades resolve with `??`, so a `0` token is honored: paddingTop > paddingY > padding, and likewise for margin, gap, overflow, and inset. - The stylesheet declares only longhands. An unset component token resolves to `unset`, which resets a property to its initial value, so a `flex-grow` declaration alongside `flex` would silently undo the shorthand. `flex` is expanded in JS and border widths cascade per side. - Border style is inferred rather than exposed: a border color or width implies `solid`, and a color alone implies a width of `1`. - `as` renders any element; button, a, ul, ol, fieldset, and legend get an automatic unstyled reset. - No `className` and no `style`, by design and at runtime. Built library CSS grows 230,117 -> 285,022 bytes raw (+23.9%) and 31,997 -> 35,893 gzipped (+12.2%), from 222 `--ezui-c-box-*: initial` declarations. Ten properties are deliberately non-responsive to hold this down.
Contributor
A component token left unset is guaranteed-invalid, so the declaration that reads it resolves to `unset` — and for a non-inherited property that is the CSS initial value, not "no declaration at all". `display: var(--ezui-c-box-display-xs)` therefore rendered every `<Box />` as `inline`, which fell apart for any composition with block or flex children: `max-width` and vertical padding were ignored and the background fragmented across line boxes, which is what made the docs recipes render wrong. Adds an optional `$fallback` to the `responsive-prop` mixins and passes `revert` for `display`, `overflow-x`, `overflow-y`, and `text-align`, so a `<Box />` defers to the user agent until a value is set. Without this, `overflow` also let an `objectFit="cover"` image paint outside an `<img />` and `text-align` dropped a `<button />`'s centering. Passing no fallback leaves the generated CSS byte-identical, so no other component changes. Verified in Chromium, WebKit, and Firefox that a `revert` fallback survives `var()` substitution, and that an unstyled `<Box />` now computes identically to a bare element for `div`, `span`, `a`, `li`, and `img` — the only remaining differences are the resets `as` applies on purpose. The new tests assert the stylesheet rather than a computed style, since jsdom cannot resolve `var()`; they fail if the fallback is removed. Also fixes two recipes: the ruled row left `borderColor` in place on the last row, so the color-implies-a-border inference drew a box around it instead of no rule, and the loading-overlay card had no room for its spinner.
The StickyToolbar and ScrollContainer recipes each scroll but held no focusable child, so a keyboard-only user could not reach their content. axe flags this as scrollable-region-focusable. Both now take tabIndex, plus a role and label so the region is announced when focused.
The previous recipes were invented. Sweeps of easypost-web-app show the app already leans on VerticalStack (334 files), HorizontalStack (210), Card (198), and HorizontalGrid (77), so the CSS modules that remain are almost entirely width caps, hairline rules, position, object-fit, button resets, and the flex:1 / min-height:0 pair. Replaces 14 invented recipes with 11 drawn from shipped modules, each carrying the source and how many times the shape is copy-pasted: - five recipes had no counterpart in the app at all (notification dot, tag list, field group, full-bleed callout, ruled-row last-child suppression — there is no :last-child border suppression anywhere) - three duplicated components we already ship (Card, SectionCard, CheckableCard), so a Box story for them taught hand-rolling - four were rewritten against the real source, and four are new Also documents the three details a Box cannot absorb — the token-only opacity scale, all-corners-only borderRadius, and a ::before rail — as token and API gaps rather than reasons to keep a stylesheet.
`borderRadius` applied to all four corners at once, so a banner that rounds only its bottom two — a shape three modal modules in the web app hand-roll — had to stay in a stylesheet. Adds eight siblings: `borderRadiusTop`/`Bottom`/`Left`/`Right` for edge pairs and `borderRadiusTopLeft`/`TopRight`/`BottomRight`/`BottomLeft` for single corners. The position goes at the end of the name, rather than infixed as in `borderTopWidth`, so all nine group under `borderRadius` in autocomplete. A corner beats an edge pair, a horizontal edge beats a vertical one, and both beat the base. The stylesheet declares the four corner longhands rather than the `border-radius` shorthand. An unset component token resolves to `unset`, so a shorthand sitting beside the longhands would wipe out a per-corner value. No fallback is needed here, since `unset` on a corner longhand is `0`. Costs +286 bytes gzipped (+0.8%) in the built library CSS, from 18 extra `--ezui-c-box-*: initial` declarations (222 to 240).
The root tsconfig.json is solution-style — `files: []` plus a project reference to ./easy-ui-react — so react-docgen-typescript resolved every component through the emitted `dist/**/*.d.ts` whenever a build was present. Props extract fine from a plain function component's emitted declaration but not from `ForwardRefExoticComponent<…>`, so forwardRef components reported no props at all, and their Properties tables rendered Storybook's "couldn't find or generate any controls" empty state. `<Box />` surfaced it because it is the one component with no hand-written argTypes to fall back on. Pointing reactDocgenTypescriptOptions at the library's own tsconfig — real sources, no project references — moves all 423 docgen entries from dist to src and takes Box from 0 to 71 documented properties. Also replaces Box.mdx's markdown table with a list. This Storybook has no remark-gfm, so the table rendered as literal pipes.
A second audit widened the scope to the legacy styling layer — 97 CSS modules, 238 non-module stylesheets, and 80 inline `style` objects across 61 files. Coverage holds at that scale: 52% of the legacy stylesheets' leaf declaration blocks and 76% of the modules' simple class rules contain only properties a Box can express. Four shapes it turned up were not already covered by a recipe: - FlexFillPanel, the `flex: 1` / flex-column / colored-header panel that opens 11 selectors across 10 modules - CenteredStateRegion, the fill-and-center region 6 modules use for empty and loading states - AspectRatioMedia, the `width` + `height` + `aspectRatio` image, which is 22 of the app's 80 inline style objects - LogoDisc, the circular or rounded logo crop from 4 modules Records the verified limitations in the docs page and the spec: opacity crossfades, pseudo-classes and transitions, pseudo-elements, `text-decoration`, transforms, `:global()` reach-throughs, computed colors, and custom breakpoints. Two things that read like Box gaps are not: a raw hex with a token behind it is fine (`#fff` is `neutral.000`, `#061340` is `primary.800`), and `boxShadow` has three levels.
Corrects the hover figure: 44 stylesheets use `:hover` and 25 use `transition`, but only 5 are CSS modules — this is a fact about the app's legacy global layer rather than a Box adoption blocker. Records which three gaps are worth closing and why, on grounds independent of occurrence count. `opacity` needs `0` and `1` aliases, a tokens change that costs no CSS. `textDecoration` is the one gap Box causes rather than inherits: `as="a"` applies `unstyled.link`, which sets `text-decoration: none` with no way to restore it, so a Box link in body copy is distinguishable by color alone — a WCAG 1.4.1 failure with no escape hatch, in the component that is meant to be one. `overscrollBehavior` is coherent with `overflow` but has one consumer. The other six stay out, four of them because the fix belongs elsewhere: hover to an interactive Card variant, `:global()` reach-throughs to the components that will not accept layout props, three of the four transforms to margins and inset that already work, and the custom breakpoints to migrating the app off Bootstrap's 768/992.
The recipe TSDoc had accumulated occurrence counts, source-file attribution, and justification for each shape. That reasoning belongs in Box.mdx and the specification, not in the story file, where it obscures the API notes a reader is actually there for. Each block now carries only what is not obvious from the code: the API constraint the recipe demonstrates, or a `Missing:` line naming the gap that keeps the recipe from being complete.
Three properties the web app sweep found no way to express. `transform` and `transformOrigin` are free strings, since neither has a token scale, and both are responsive: a scaled preview or a decorative offset is exactly the sort of thing that differs by viewport. A value given per breakpoint replaces the whole transform list rather than adding to it, as CSS does. `overscrollBehavior` is a three-value enum and is not responsive — a scroll container contains its scroll at every width. It is kept as the shorthand rather than the `-x`/`-y` longhands because no per-axis prop is exposed; adding one would have to switch to longhands, the way `overflow` does. None of the three needs the `revert` fallback that `display`, `overflow`, and `text-align` need. An unset variable resolves its declaration to `unset`, which for these is already the default: `none`, `50% 50%`, and `auto`. Verified in a browser as well as in jsdom. Compiled CSS grows 177 gzipped bytes, nearly all of it the two responsive props — a responsive property costs about 65 gzipped bytes because it emits six custom-property declarations and five media blocks, against about 15 for a non-responsive one. Most application `transform` uses did not need this prop: `translate(-50%, -50%)` centering is `inset="0"` with `marginX`/`marginY="auto"`, and `translateY(-48px)` is `marginTop="-6"`. Both of those move the surrounding layout with the box, which is usually what was wanted. `transform` earns its place for the cases where it should not.
The 177-byte figure was measured on Box's stylesheet compiled in isolation. In the built library stylesheet the three properties cost 3,151 raw and 105 gzipped bytes, because gzip shares the responsive-prop boilerplate with the properties already there.
Box could become a grid container but could not define tracks or place children, so grid was the one layout mode it supported at half the depth of flexbox. It now supports both to the same depth. Container-side: gridTemplateColumns, gridTemplateRows, gridTemplateAreas, gridAutoFlow, gridAutoColumns, gridAutoRows. Child-side: gridColumn, gridRow, gridArea. Three properties shared by both layout modes are added at the same time, so that grid does not end up the better-served of the two: justifyItems, justifySelf, and alignContent — the last of which flexbox needs too, for a container whose children wrap. The track formatter moves from HorizontalGrid to utilities/grid so that a primitive does not import from a higher-level component. HorizontalGrid's public Columns, ColumnsType, and ColumnsAlias types become aliases of the shared ones; its API is unchanged. gridArea, gridColumn, and gridRow expand in TypeScript into their four placement longhands rather than being declared as shorthands, for the same reason flex and border-radius do: three props writing the same longhands means whichever shorthand came later, resolving to unset, would reset the placement its neighbour had just set. Expanding them also makes the cascade explicit — gridColumn and gridRow win over gridArea on the axis they name. The expansion follows CSS's own omitted-value rule, where an omitted end line is copied only if the start is a custom-ident, so gridArea="sidebar" spans the named area while gridArea="1" occupies one track. Costs 21,283 raw and 1,105 gzipped bytes in the built library stylesheet.
textDecoration stays out. Box is layout; type styling belongs to Text, and a <Box as="a"> is an unstyled hit area rather than something that reads as a link. The spec argued the opposite, so it now records the decision and the boundary behind it — a rule that can be applied without a judgment call at each site is worth more than the one property. Recorded rather than argued: Text has no decoration property either and there is no Link component, so nothing in the library renders an underlined inline link today. The boundary relocates that gap rather than creating it, and the fix belongs in Text or a Link. The docs page's "What these recipes could not absorb" becomes "What a Box cannot do" — a high-level list of the limits themselves, without the occurrence counts, file names, and reasoning that belong in the spec. It also moves from a recipe subsection to a section of its own, since it describes Box rather than the recipes.
Stephen Watkins (stephenjwatkins)
marked this pull request as ready for review
September 16, 2026 16:16
Stephen Watkins (stephenjwatkins)
requested review from
a team
as code owners
September 16, 2026 16:16
Rane Gridley (ranebo)
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




📝 Changes
Implements SHPE-1659. Adds
<Box />, a general-purpose container that exposes Easy UI's design tokens as props, so apps stop reaching for one-off SCSS modules and inlinestyleobjects.Why now: an audit of
easypost-web-appfound 93*.module.scssfiles (~2,350 lines), and 55 of them (59%) contain nothing but properties a Box can express — 13 files repeatobject-fit+ fixed dimensions for logos, 10 hand-roll dividers, 5 copy.cardButton { all: unset }verbatim, and 3 independently reinvented Tailwind. The highest-volume single gap: aStackchild can't say how it flexes (flex: 1appears 32×).The API rule: constrain what has a token scale, leave free what does not
Space / color / border / shadow / z-index / opacity → typed to their scales. Sizing and positioning →
number(px) or any CSS string. That one rule decides every prop.What it replaces, by pattern
Each of these is a shape that shows up repeatedly in the audit. Left is roughly what's in the app today; right is the Box.
1. Surfaces — the single most common module
2. Logo and thumbnail sizing — 13 near-identical files
Fluid variant, with a reserved box so nothing shifts while the image loads:
3. Dividers — 10 hand-rolled versions
Ruled rows — the same thing per list item, with the last rule suppressed:
A
Dividercomponent is queued as a follow-up; this is the primitive it will be built on.4. The unstyled card button — copied verbatim in 5 files
as="button"applies the reset automatically. This is also safer thanall: unset, which strips the focus ring — Box's reset keeps focus visible, and Box deliberately has nooutlineprop so it can't be turned off.Same for links and lists:
5.
flex: 1inside a Stack — 32 occurrences, the biggest single gapToday this needs a wrapper class, because
HorizontalStackchildren have no way to say how they flex:The other flex members of the same family:
6. Ad-hoc flex rows — 119
displayusagesVerticalStack/HorizontalStack/HorizontalGridremain the first choice and the docs say so. Box covers what they don't — wrapping,space-between, asymmetric gaps:7. Page containers
8. Sticky toolbars and headers
zIndexis token-constrained (input_icon|nav|drawer|modal|notification), which is the point — it ends the arms race ofz-index: 9999.9. Overlays, badges, and positioned decoration
10. Scroll containers
11. Negative margins — full-bleed inside a padded parent
marginuniquely accepts negated space tokens ("-4") andauto;paddingaccepts neither, because neither is meaningful there.12. Responsive everything
Most props take a breakpoint object, and a value applies from its breakpoint upward:
13. Odds and ends the audit turned up
Cascade and inference rules worth knowing
Design decisions worth a look
className, nostyle— Box is the escape hatch, so it can't have one; an escape hatch on the escape hatch puts arbitrary CSS back in app code. Policy in the docs: a missing capability is a bug in Box — file it.asapplies the right reset automatically (button,a,ul,ol,fieldset,legend), so nobody writesall: unsetagain — which is also safer, sinceall: unsetstrips focus rings.solid; a color alone implies width1.??, not||—paddingTop>paddingY>padding, sopadding="0"is honored.VerticalStack/HorizontalStack/HorizontalGridfirst. Polaris omits flexbox from Box entirely; the audit's 119displayusages say we'd just be pushing people back to SCSS.responsive-propmixin +utilities/css.ts. This is the point: GitHub Primer madeBox+sxuniversal (ADR-005), then removed it (ADR-016) over runtime cost — 1000 components 242ms→96ms, one PR-diffIconButton400ms→165ms. Static CSS Modules + custom properties keeps us off that path by construction.Prior art read from source and written up in
documentation/specs/Box.md: Polaris, Paste, Primer, Styled System, Radix Themes, Braid, Atlassian.Two non-obvious bugs this had to solve
An unset
--ezui-c-box-*resolves tounset, which resets its property to the CSS initial value — not "no declaration." Soflex-grow: var(…)next toflex: var(…)silently undoesflex="1"(grow → 0), andborder-top-width: var(…)clobbersborderWidth="1"withmedium. Fix: the stylesheet declares only longhands —flexis expanded in JS, border widths cascade per side and default to0.Cost
Built library CSS: +3,896 bytes gzipped (+12.2%); 285,022 vs 230,117 raw (+23.9%, unminified — the bulk is
@mediaboilerplate that compresses ~14:1). 222--ezui-c-box-*: initialdeclarations, under the ticket's ~330 estimate because 15 props are deliberately non-responsive (color,objectFit,cursor,zIndex,boxShadow, border widths, …).Heads up / follow-ups (not in this PR)
shape.border_widthhas exactly one alias (1) andopacityexactly one (underlay), so those two props are single-value unions — a tokens gap, not a Box gap. RelatedlyborderRadius="full"resolves to a literal9999px.Queued:
Divideras a named component ·Cardrefactored onto Box · sizing props onModal· layout-owned sticky offset forForgeLayout· expand the border-width/opacity scales.Reviewing:
npm run start:storybook→ Primitives/Box (11 stories + a docs page covering the no-classNamepolicy).✅ Checklist
Easy UI has certain UX standards that must be met. In general, non-trivial changes should meet the following criteria:
Visuals match Design Specs in Figma— Box is an invisible layout primitive with no visual design of its own.mdxdocs pagedocumentation/specs/Box.md, including the 7-system prior artminorStrikethroughany items that are not applicable to this pull request.