Skip to main content

06 — The shell and the theme

Read this first: every authenticated page you have opened so far rendered inside the same component — a fixed header, a sidebar that widens under your mouse, and one <Outlet /> in the middle. This chapter reads that shell, then the 124-line theme that colors it, and ends at the one file where the theme quietly stops working — and what you must do about it.

Time: about 25 minutes. Assumes lesson 05.

The shell is furniture around one outlet​

Open frontend/src/ui/layout/app-layout.tsx. Strip the sidebar internals away and the whole component is five fixed-position pieces: a skip link, <AppHeader />, the desktop sidebar, a mobile top bar with its drawer, and this:

{/* Main content */}
<Box
as="main"
id="main-content"
tabIndex={-1}
outline="none"
ml={{ base: 0, md: collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH }}
pt={HEADER_HEIGHT}
transition="margin-left 0.25s ease"
>
<Box maxW="1400px" mx="auto" px={{ base: 4, md: 8 }} py={8}>
<Outlet />
</Box>
</Box>

Pages never own their margins. The header and sidebar are position="fixed", so <main> pushes itself clear of both with pt and ml, and every page body lands in a centered maxW="1400px" column. The magic numbers live in one place, nav-config.ts:

export const SIDEBAR_WIDTH = '260px';
export const SIDEBAR_COLLAPSED_WIDTH = '72px';
export const HEADER_HEIGHT = '56px';

The first thing in the tree is not the header. It is this:

{/* Skip link — first in tab order, revealed on keyboard focus */}
<Box
asChild
position="fixed"
bg="brand.800"
color="white"
/* … */
transform="translateY(-250%)"
_focusVisible={{ transform: 'translateY(0)' }}
transition="transform 0.15s ease"
>
<a href="#main-content">Skip to main content</a>
</Box>

A keyboard user's first Tab press reveals a link that jumps past the entire nav to #main-content — the id and tabIndex={-1} on <main> above exist to receive that jump. Off-screen by transform, on-screen on _focusVisible: invisible to everyone it does not serve.

The sidebar's tricks are written down​

The sidebar has two widths and one rule for choosing between them:

const isExpanded = isHovering || !collapsed;

Collapsed is the persisted choice — it lives in the Zustand sidebarStore from lesson 05, so it survives navigation. Hover is a temporary override: mousing over the 72px rail widens it to 260px without changing the stored preference, and the rail takes zIndex 10 only while hovering so it floats over page content instead of shoving it aside.

That hover-expansion produced two scars, and both are documented where they happened:

// Save the nav scroll position while the list is still at full height —
// once groups collapse to max-height 0, scrollTop clamps to ~0.
const saveScroll = useCallback(() => {
if (navScrollRef.current) setNavScrollTop(navScrollRef.current.scrollTop);
}, [setNavScrollTop]);

// Restore synchronously on any expand (hover or button). Open groups render
// at full height in the same commit, so no clamping and no visible jump.
useLayoutEffect(() => {
if (isExpanded && navScrollRef.current) {
navScrollRef.current.scrollTop = useSidebarStore.getState().navScrollTop;
}
}, [isExpanded]);

When nav groups animate shut, the list gets short and the browser clamps scrollTop toward zero — so the position must be captured before collapse and restored in a useLayoutEffect, not a useEffect, so the restore lands in the same paint as the expansion. The second scar is in NavTooltip:

// Always render the same tree — swapping between a bare fragment and
// Tooltip.Root remounts every nav item when the sidebar toggles.

Both comments justify code that would otherwise look like superstition. That is the house style you met in the DevOps course, and you will keep meeting it.

Two more things you should recognize but not linger on: the entries are permission-filtered before render — that machinery is lesson 03 — and the sliders icon opens NavCustomizeDrawer, which layers the user's reorder/hide/favorites on top of the permission-filtered set, so customization can only rearrange what permissions already allowed.

The header is a row of four things​

app-header.tsx is fifty lines and almost all of it is this row:

<Heading asChild size="md" color={{ base: 'brand.800', _dark: 'brand.100' }} whiteSpace="nowrap">
<Link to={ROUTES.DASHBOARD} aria-label="MotorPH Enterprise home">
MotorPH Enterprise
</Link>
</Heading>

<Box h="24px" borderLeft="1px solid" borderColor={borderColor} />

<Box flex="1" minW={0}>
<AppBreadcrumbs />
</Box>

<HStack gap={1} flexShrink={0}>
<ColorModeToggle />
<ProfileMenu />
</HStack>

Brand link home, divider, breadcrumbs eating the flexible middle (minW={0} so long trails truncate instead of pushing the toggles off-screen), then the color-mode toggle and profile menu. The header carries display={{ base: 'none', md: 'flex' }} — on phones it disappears entirely and the layout renders its own mobile top bar with a hamburger drawer instead.

Notice the brand color: {{ base: 'brand.800', _dark: 'brand.100' }}. That token is about to become the whole story.

The theme is data, compiled once​

frontend/src/ui/theme.ts builds Chakra v3's styling system in a single expression:

const config = defineConfig({
theme: {
tokens: {
colors: {
brand: {
50: { value: '#E9EEF8' },
/* … */
500: { value: '#3F67B0' },
/* … */
800: { value: '#1A3470' },
900: { value: '#122550' },
},
/* … accent reds/yellows/greens */
},
fonts: {
heading: { value: 'Inter, system-ui, -apple-system, sans-serif' },
body: { value: 'Inter, system-ui, -apple-system, sans-serif' },
display: { value: "'Outfit Variable', Inter, system-ui, -apple-system, sans-serif" },
},
/* … radii, shadows */
},
/* … textStyles, semanticTokens, recipes */
},
});

export const system = createSystem(defaultConfig, config);

createSystem(defaultConfig, config) merges these overrides into Chakra's defaults; the exported system is what the provider in lesson 01 hands to every component. Nothing in the app writes #1A3470 — components write brand.800 and the system resolves it. Recipes push the same tokens into whole component families at once:

const buttonRecipe = defineRecipe({
base: {
fontWeight: '600',
borderRadius: 'lg',
},
variants: {
variant: {
solid: {
bg: 'brand.800',
color: 'white',
_hover: { bg: 'brand.700' },
_active: { bg: 'brand.900' },
},
/* … subtle, outline, ghost */
},
},
});

Every <Button> in every module gets navy-800 with a 700 hover and a 900 press, because the recipe says so once. Dark mode needs no second palette: next-themes (wired in lesson 01) flips a class on the root element, and two mechanisms react to it. Declaratively, _dark conditionals and semantic tokens:

semanticTokens: {
colors: {
focusRing: { value: '{colors.brand.800}' },
border: {
DEFAULT: { value: { _light: '{colors.gray.100}', _dark: '{colors.gray.700}' } },
},
/* … */
},
},

Imperatively, useTheme() — the layout reads const isDark = theme === 'dark' and hand-picks gray.700 or gray.200 for its borders. You will meet both forms in this codebase; the semantic token is the one to prefer when you write new code, because it keeps the light/dark pairing in one file instead of scattering ternaries.

Predict: you change the brand scale in theme.ts from navy to green — every step, 50 through 900. Which of these follow: the solid buttons, the sidebar's active link, the header brand text, the data grids' header bar? Write your answer down before reading on.

The grid cannot read your theme​

Buttons, sidebar, header: yes — they reference brand.* tokens and the system re-resolves them. The grids: no. Here is frontend/src/ui/ag-grid/theme.ts, complete:

import { themeQuartz } from 'ag-grid-community';

export const motorphGridTheme = themeQuartz.withParams({
accentColor: '#1A3470',
headerBackgroundColor: '#E9EEF8',
headerTextColor: '#122550',
headerFontWeight: 600,
borderColor: '#E2E8F0',
rowHoverColor: '#F4F7FD',
selectedRowBackgroundColor: '#E9EEF8',
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
fontSize: 14,
rowHeight: 44,
headerHeight: 44,
});

Read the values against the palette you just saw. accentColor is brand.800. headerBackgroundColor and selectedRowBackgroundColor are brand.50. headerTextColor is brand.900. The fontFamily string is fonts.body, character for character. This file is a hand-copied mirror of theme.ts.

It exists because AG Grid ships its own theming engine — themeQuartz.withParams generates the grid's CSS from literal values at module load. The string 'brand.800' means nothing to it; token references resolve only inside Chakra's system, and the two systems share no vocabulary. So the brand had to be re-typed as hex.

The rule: these two files move together. Change the palette in theme.ts and the app re-skins itself while every grid keeps wearing the old navy — the mirror must be re-derived by hand in the same commit. Note also what the mirror lacks: every value is a light-mode color, and nothing under ui/ag-grid/ mentions dark mode. That gap is real, and it is exactly the kind of edge you will map in lesson 22.

You will see who consumes motorphGridTheme — ServerDataGrid and a handful of direct-grid pages — when you decode the grid stack in lesson 08.

Where this shows up in MotorPH​

Recap​

  • Pages render inside furniture they do not own — the shell fixes header and sidebar in place and gives every page the same 1400px column, so no page ever re-implements chrome.
  • Color is spelled brand.N, never hex, inside the app — the scale lives once in theme.ts, recipes fan it out to whole component families, and one edit re-skins everything Chakra renders.
  • Dark mode has a declarative form and an imperative form — _dark/semantic tokens versus useTheme() ternaries; prefer the declarative one so the pairing stays in the theme file.
  • ui/ag-grid/theme.ts is a hand-copied mirror, and the two files move together — AG Grid's theming engine cannot resolve Chakra tokens, so a palette change that skips the mirror leaves every data grid wearing the old brand.
  • The odd code carries its own justification — scroll-clamp saves, same-tree tooltips, shadow choices are all explained by comments at the site; read them before you "simplify" anything.

Next: 07 — The grid problem, and the column contract.