A React component library and the sites built on top of it, kept in one workspace. The library is styled-components on top of a shared token set, written in TypeScript, and consumed by apps through Vite path aliases rather than a build step. Nothing is published to npm; the packages are private and imported straight from source.
packages/
tokens/ colour, space, radius and type scales
theme/ styled-components ThemeProvider and global styles
primitives/ Box, Flex, Grid, Stack, Container, Button, Input, Heading, Text
sections/ composed page sections (empty so far)
utils/ small helpers
apps/
playground/ visual harness for the primitives
bnm-software/ brutalist marketing site for BNM Software
siblings-reunited/ scaffolded, not yet using the library
types/
styled-components/ augments DefaultTheme with the shape of the real theme
pnpm install
pnpm devpnpm dev runs the playground. To work on a specific app, run Vite from inside it:
cd apps/bnm-software
pnpm devOther root scripts: typecheck runs tsc across every package and app with noEmit, lint runs ESLint over the whole tree, and build builds each workspace in turn.
Every app resolves @bnm/* through aliases in its own vite.config.ts, pointing at packages/*/src. TypeScript resolves the same names through paths in tsconfig.base.json. Adding a new package means updating both.
@bnm/tokens exports four objects. color holds a dark base palette under color.bg plus a separate color.colors group used by form controls. space is a numeric scale from 1 to 16 in rem. radius covers sm through xl. font carries the family stacks, weights, line heights and a fluid size scale from 1 to 7, each one a clamp() that grows with the viewport, so headings resize without media queries.
There's also packages/tokens/styles/tokens.css, an equivalent set of CSS custom properties for anything reaching for var(--space-4) instead of the theme object. The two aren't generated from a single source, so a change in one won't show up in the other.
@bnm/theme bundles the token objects into a single theme, wraps styled-components' provider, and injects global styles that set the body background, text colour and base font.
import { ThemeProvider } from '@bnm/theme/ThemeProvider';
createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<App />
</ThemeProvider>
);The theme type flows through to every styled component via types/styled-components/index.d.ts, which declares DefaultTheme as typeof theme. That's what makes theme.space[4] and theme.color.bg.accent autocomplete inside template literals.
To restyle an app without touching the library, spread the base theme and override what you need, then wrap that section of the tree in styled-components' own provider. The BNM Software site does exactly this to swap the dark default palette for a light brutalist one.
const brutalTheme: DefaultTheme = {
...baseTheme,
color: {
...baseTheme.color,
bg: { ...baseTheme.color.bg, default: '#f5f2e9', text: '#0b0b0b' },
},
};Everything builds on Box, a polymorphic styled div that takes an as prop to change the rendered element, shorthand p, m, gap and display props, and two escape hatches: styleOverrides for inline styles and sx for a styled-components block or a function of the theme.
<Box as="article" p="1rem" sx={(theme) => `border-radius: ${theme.radius.md};`}>
...
</Box>Flex exposes direction, align, justify, wrap, inline, grow, shrink and basis, mapping them internally onto transient $-prefixed props so they never leak into the DOM as attributes. VStack and HStack are thin wrappers that preset the direction.
Grid covers the grid template properties with the same clean-prop-to-transient-prop pattern. GridItem takes col, row and area strings and merges them into the element's inline style.
Container centres content with a default max width of min(1100px, 92vw) and symmetric horizontal padding, both overridable. SectionRoot renders a <section> with vertical padding and a tone of default, surface or accent, pulling the background from the theme.
Heading and Text are styled boxes driven by transient props: $level picks a font size from the type scale, $size, $muted, $align and $weight do the same for body copy.
Button takes variant (accent, surface, ghost), size (sm, md, lg) and fullWidth. It forwards refs, defaults type to button when it renders as one, and switches to aria-disabled when rendered as a link. Focus rings use the accent colour with an offset. Disabled state drops opacity and cancels the hover filter.
Input wraps a label, field and error message in a column. Pass label, error and fullWidth; the id is generated with useId when you don't supply one, and the error state recolours the border and focus ring.
<SectionRoot tone="surface" paddingY="4rem">
<Container>
<VStack gap="1rem">
<Heading as="h1" $level={5}>Section title</Heading>
<Text $size={2} $muted>Supporting copy.</Text>
<Button variant="accent" size="md" onClick={handleClick}>Get in touch</Button>
<Input label="Email address" type="email" fullWidth />
</VStack>
</Container>
</SectionRoot>playground renders every primitive across each section tone and every button and input state on one page. It's the fastest way to see a change to the library.
bnm-software is the marketing site, built entirely from the primitives with a locally overridden brutalist theme.
siblings-reunited is still the stock Vite React template. It has no aliases wired up and doesn't import from the library yet.
- The workspace is set up twice over. Root
package.jsondeclares npmworkspacesand its scripts callnpm run -w, but there's also apnpm-workspace.yaml, apackageManagerfield pinning pnpm, and both lockfiles committed.bnm-softwaredeclares its@bnm/*dependencies with theworkspace:*protocol, which npm won't install. Pick one package manager and delete the other's config and lockfile. - Because apps alias
@bnm/*straight to source, theworkspace:*dependencies aren't really doing any work at the moment.playgrounddoesn't declarestyled-componentsor any@bnmpackage at all and relies on hoisting from the root. packages/tokens/srchas emptycolors.ts,spacing.ts,typography.ts,radius.ts,motion.ts,breakpoints.tsandzindex.tsfiles. Every token currently lives inindex.ts.packages/sections/src/index.tsandpackages/utils/src/index.tsare both placeholder files.utilshas aclamphelper that the index doesn't export.tokens.cssand the token objects define different palettes and different type scales. Anything usingvar(--color-accent)won't match a component readingtheme.color.bg.accent.Inputreads fromtheme.color.colors, a light-mode group that sits apart from thetheme.color.bgpalette everything else uses. On the dark default theme the inputs come out white.Heading,TextandButtonmix conventions. The first two take transient$-prefixed props as their public API, whileButtontakes clean names and converts internally.- Only
packages/primitiveshas its owntsconfig.jsonwithoutDirandrootDirset.tsupis installed at the root but nothing is wired up to build the packages for distribution. - There's no root
.gitignoreand no licence file.