Layout & Spacing
Urengi’s layout has one container width, seven breakpoints and a band rhythm that repeats on every page. Once you can see the rhythm, adding a section to an existing page is mostly a matter of deciding which band it belongs to.
The container
One class, defined in @layer components in global.css:
.site-container { @apply mx-auto max-w-[1100px] px-4; }
1100px of content, 16px of gutter, centred. Sections apply it themselves rather than receiving it from the page, because a section that spans the full viewport — a tinted band, the footer card — needs the container inside it, not around it.
The pattern you will see repeatedly is a full-bleed background element wrapping a .site-container child:
<section class="bg-band py-16 md:py-24">
<div class="site-container">…</div>
</section>
That is the whole trick to the band rhythm. The colour goes edge to edge; the content stays at 1100px.
Breakpoints
@theme {
--breakpoint-xs: 400px;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
}
Six named, plus the implicit base. Only xs is an addition; the rest mirror Tailwind’s defaults and are declared explicitly so they are visible in the file rather than assumed.
The one you will use most is lg, because that is where the site chrome changes shape: above it the navbar draws its pill, below it the pill’s contents move into a Sheet.
Grid stacking points are measured per section rather than applied by rule, and several of them are deliberately not md. A three-column figure row that stacks at md is fine on paper and cramped at 768px; the sections that hit that reach for lg or a two-step sm → lg instead.
The band rhythm
Pages alternate between the page ground and a tinted band. That tint is the --band token, and it is what the problem stats, the results strip, the milestones, the setup steps and the risk heatmap all sit on.
Reach for bg-band rather than bg-primary-100 dark:bg-primary-950. The eight sections that used to spell it by hand are the reason the token exists, and the dark value is not what you would guess — Colors & Theming has the details.
Vertical rhythm is py-16 md:py-24 for a standard band, with heroes running larger. There is no spacing scale of custom tokens: Tailwind’s own is the scale, and consistency comes from copying the neighbouring section rather than from a variable.
Two shared surface classes
.form__input { /* border, ground, hover, focus ring, placeholder — one definition */ }
.primary-focus { @apply focus-visible:outline-primary-500 focus:outline-hidden
focus-visible:rounded-xs focus-visible:outline-2; }
.form__input is the shared field look for the newsletter, contact and enquiry forms; the ui/input primitive has its own _field recipe for primitive-level use. .primary-focus is the keyboard focus ring, applied to interactive elements that are not primitives.
The site chrome
BaseLayout owns both the header and the footer, so every route gets them for free and a new page is <BaseLayout title description> plus sections. Both read navData, so adding a link is a config edit and never a component edit.
A page that must render bare would take a chrome={false} prop — deliberately not added until something needs it.
The navbar
The wordmark sits on the left. On the right, a single dark pill holds the nav links, the theme toggle and the amber CTA.
That pill is a fixed-brand surface: stone-700 in both themes, because it is the same object as the footer card. This is the one place in the theme where you will see palette aliases used instead of semantic tokens on purpose, and the reason is worth internalising before you edit it. bg-secondary flips to stone-300 in dark mode, which turns the pill into a light slab under stone-300 links — invisible text. Aliases do not flip; semantic tokens do. So the pill’s surface and every colour state inside it are restated on the aliases, spelled with the same modifiers the primitives use so tailwind-merge can actually replace them.
The wordmark and the mobile hamburger sit outside the pill, on the page ground, so those do flip with the theme. The theme toggle is rendered twice for the same reason: the desktop copy lives inside the pill and restates its colours, the mobile copy sits on the page and keeps the primitive’s defaults.
Entries come from navData.header, a discriminated union — link renders a NavLink, dropdown a Dropdown, mega a MegaMenu with titled columns of rich links. Below lg the pill’s contents move into a right-hand Sheet, with mega menus flattened to one titled section per column, so every route stays one tap away and there is no nested disclosure to fight on a phone.
The footer
A rounded dark card holding a band, a four-column directory beside the brand block, and a legal bottom bar — then an oversized wordmark watermark on the page ground beneath it.
Same fixed-brand rule as the pill: the card is stone-700 in both themes, its contents use aliases, and its focus ring is bumped to primary-300 because primary-500 is legible on the page cream but muddy on stone.
The card’s radii are diagonal — a small pair on one diagonal and a large sweep on the other — and they scale down below md, where a 200px arc would eat a phone-width card’s content. That sweep is also why the copyright line keeps its left padding: at its vertical centre the card edge has already come in about 36px.
The five footer bands
The top slot of the footer card is a discriminated union, chosen by the route:
export type FooterBand =
| { kind: "none" }
| { kind: "cta" }
| { kind: "newsletter" }
| { kind: "reference"; story?: string }
| { kind: "sales" };
| Band | Drawn on |
|---|---|
cta |
the default — home, product, about |
newsletter |
the blog area |
reference |
customers index and story pages |
sales |
pricing |
none |
sign-in and sign-up |
A page passes it to BaseLayout:
<BaseLayout title={…} description={…} footerBand={{ kind: "newsletter" }}>
Three design decisions are packed into that type, and each is the kind of thing that gets undone by a well-meaning refactor:
It is a prop, not a route check inside the footer. A pathname.startsWith("/blog") in Footer.astro would be the chrome quietly deciding content policy from a URL, invisible from the page it affects.
It is a discriminated union, not a string plus optional companions. The first version shipped as band?: "cta" | "newsletter" | "reference" alongside bandStory?: string, which type-checks band="cta" with a story attached — a representable state that means nothing. The union also ended the value being renamed at every hop; it used to travel as footerBandStory, then bandStory, then story.
none is a real member, not “pass cta and hide it”. An unrendered CTA still leaves links in the HTML for a crawler to follow off a page that is deliberately not pointing there. It is what the two auth frames draw, and it is right twice over on /signup/: the footer CTA is “Start free trial”, and that is the page you are on.
Adding a sixth band without giving it a branch in Footer.astro is a type error, via an assertNever exhaustiveness check — which is the whole reason the union was chosen.
Adding a section to a page
- Decide the band: page ground, or
bg-band. - Create
Sections/<Page>/<Name>.astro. Give it the full-bleed wrapper plus a.site-containerchild. - Build it from
ui/primitives andCards/. Tokens only. - Import it in the route and drop it in the composition order.
- If a second page wants it later, move it to
Sections/Global/and update both call sites. That is the whole admission test for that folder, and it is what keeps it from becoming a junk drawer.