Skip to content

Design system

The rules the marketing site (ndakita_group_site) is built on, and why each one is the way it is. Both hostnames share this system: a visitor crossing from ndakita.com to build.ndakita.com should not be able to tell.

Tokens live in src/app/globals.css. Components live in src/components/ui/.

Colour

Token Value Job
--foreground #0f0f0f text, filled buttons
--background #ffffff page
--secondary #f7f7f7 alternating section band, raised cards
--muted #6b6b6b secondary text, placeholders
--border #e8e8e8 decorative only: card edges, dividers, section rules
--border-strong #8a8a8a borders that are a control: inputs, selects, checkbox, secondary button, segmented toggles
--accent #404040 hover partner for --foreground, never used alone
--brand #143154 navy. The ground on dark surfaces, the fill on light ones
--brand-ink #2a5c9c hover partner for --brand. Goes lighter, see below
--mark #C9992A gold. The hero mark and the footer rule, nothing else
--success #047857 money in, positive state
--danger #b91c1c form errors

Both brand values are read off the logo files in public/, not chosen. That is why the site and the mark are the same two colours rather than a near miss.

The border split

--border at 1.23:1 on white is a hairline, which is correct for decoration and fails WCAG 1.4.11 for anything that bounds a control. --border-strong is 3.45:1. If the border is the only thing telling you where a control begins, it must be the strong one.

The brand rule

Navy is a ground before it is an accent. Gold is a mark and nothing else.

Navy carries white type at 13.15:1 and stands against the white page at the same 13.15:1, so it can be a surface, a fill, a boundary and body text without help. Gold is the opposite kind of colour: 5.06:1 on navy and 7.37:1 under the dark ink that sits on it, but only 2.6:1 against white, so it can be a block behind a word and must never be a word, a control edge, or the only thing carrying a meaning.

Two rules follow from single numbers, and both are load-bearing:

  • Navy and --foreground must never touch. 1.46:1 between them. Small black controls are fine because they sit on white; nothing black may sit on a navy ground and nothing navy on a black one. Every full-width dark band on the site is navy, so no two darks ever meet.
  • Gold must never carry a meter or a state. At hue 42 it sits 10 degrees from the amber that means "low stock", at only 1.26x its lightness. It works because the two never co-occur: gold is the hero and the footer, the amber lives inside the product animation.

--brand-ink goes lighter than --brand, which is the one counter-intuitive value here. Navy is already near the bottom of the range, so a deeper navy lands at 1.26:1 against the rest state, the same invisible hover --accent was created to fix. The lighter partner is 1.95:1 apart.

What this replaced, and why it is worth knowing

The site ran on hi-vis yellow (#facc15) until 2026-08-01, and the switch is recorded because the failure mode is instructive. Yellow is 12.5:1 on black and 1.5:1 on white; navy is 13.15:1 on white and 1.46:1 on black. Both are one-ground colours, just opposite grounds — and the site's grounds are mostly white.

So yellow needed a prop everywhere it went: a dark keyline under the button, because a 1.5:1 fill cannot be what identifies a control under 1.4.11; an inverted meter track, because yellow on a light grey measured 1.4:1; a "decorative only" caveat against the section rules and the nav underline. Navy needs none of them, and they were all deleted with it.

Four other candidates were measured and rejected: steel blue and indigo work but are the colour every ERP competitor already uses; safety orange works but sits 11 degrees from the low-stock amber; murram clay fails the dark hero at 2.93:1 and reads as an error at 16 degrees from --danger. The branches are still in the repo (theme/*) if any of that needs revisiting.

Type

Archivo carries h1 and h2; Inter carries body, UI and every number. Both are self-hosted through next/font, which is what lets the privacy policy say no request leaves for Google.

The rule is display face at 30px and up. A few h2 elements are 14px section labels; those opt back out with font-sans at the call site.

Step Size Weight Tracking Leading For
Display text-5xl sm:text-6xl md:text-7xl 700 -0.02em 1.05 the one hero h1 on /
H1 text-4xl md:text-5xl 700 -0.015em 1.1 page titles
H2 text-3xl md:text-4xl 700 -0.01em 1.15 section headings, tool-page h1
H3 text-2xl md:text-3xl 600 normal snug legal sections, utility pages
H4 text-lg 600 normal snug roomy card titles
H5 text-base 600 normal normal dense card titles

Archivo needs less negative tracking than Inter, because its sidebearings are tighter. tracking-tight (-0.025em) collides above ~96px. Each step carries its own value instead, and less of it the larger it gets. The only tracking-tight left is the wordmark, which is Inter.

Money and any figure a reader compares down a column uses tabular-nums.

Radius

Three values. rounded-md (6px) for dense controls, rounded-lg (8px) for anything with a body, rounded-full for pills. rounded-xl survives on exactly one element, the product animation's window chrome, which genuinely draws a larger corner.

Rhythm

Role Padding
Page head pt-32 pb-16
Section py-24
Band (dark CTA, footer) py-16

pt-32 clears the fixed header and matches scroll-padding-top: 6rem.

Container is max-w-7xl mx-auto px-6; prose columns (legal, pricing) are the documented exception. The heading block is max-w-3xl mb-16 and reads heading, then lede. It used to open with an uppercase eyebrow label above the heading; those were dropped across all eighteen call sites and the component with them, so a section now leads with what it is about. Sections alternate --background and --secondary strictly: two adjacent sections sharing a background fuse into one slab, and a navy hairline now separates them as well.

Components

  • Button / ButtonLink — variants primary, secondary, inverse, inverseGhost, ghost; sizes sm, md, lg; fullWidth. sm uses min-h rather than padding so it reaches the 44px target inside a table row without growing the row. fullWidth exists because call sites passing w-full plus their own padding silently overrode the size.
  • Card / CardGrid — the one card treatment: a hairline grid, gap-px over a border-coloured ground. Chosen because it makes unequal card heights impossible rather than merely tolerable, costs one border-width of noise instead of one per card, and degrades at a single column into horizontal rules. Bordered rounded boxes remain only for panels, which are single containers rather than repeated items.

Brand assets

The mark is two figures, one navy and one gold, sharing an overlap. Ndakita Build's variant adds rising bars cut into that overlap, and those bars are the only thing distinguishing the two brands' marks. Four files in public/, and they are not interchangeable — each is drawn for a specific ground:

File Colours Belongs on
ndakita-navbar.svg navy + gold, white overlap the white company header
build-navbar.svg the same, plus navy bars in the overlap the white Build header
ndakita-footer.svg white + gold, navy overlap the navy footer, and the company share card
build-footer.svg the same, plus white bars in the overlap the Build share card

Putting either navbar file on the navy footer draws navy figures on navy at 1:1. Putting a footer file on the white header does the same with white. The bars invert with everything else: navy cut into a white overlap on the header, white cut into a navy one on the card. Recolouring the figures but not the bars is the failure that is easy to miss, because it costs only the two strokes that make the mark Build's.

public/ndakita-mark.png is the 1152px transparent raster master. Nothing renders it; it exists for anywhere a vector will not do.

The header shows the mark beside the wordmark, not instead of it. The mark reads as a symbol rather than a name, and on a header nobody has seen before the name is the part that has to survive. Both sit inside one link with the image aria-hidden, so the brand is announced once.

Favicons are per host, via the route segment. src/app/icon.svg is the company mark and src/app/build/icon.svg the Build one; Next resolves the icon convention per segment, so /build/* — which is every page on build.ndakita.com after the middleware rewrite — emits the Build icon and ndakita.com keeps the company one. Confirm a change by grepping rel="icon" out of .next/server/app/build.html and .next/server/app/index.html after a build. The href is /build/icon.svg, which carries an extension and so is excluded from the middleware matcher: it is served literally on the Build host rather than 308'd back to /icon.svg.

Both carry their own navy ground, unlike the transparent master. A transparent favicon takes whatever the browser or OS puts behind it, and on a dark tab strip the navy figure sits at 1.46:1 against near-black with half the logo gone. They use the footer treatment for the same reason: navy figures on a navy ground are nothing.

Share cards

Two opengraph-image.tsx routes, one per host: the company card at the app root, Ndakita Build's under /build. Both are the hero treatment rather than a logo panel — navy ground, the gold mark on the same word — so a link shared into a WhatsApp thread looks like the page it opens.

Three things about them are worth knowing before editing:

  • The logo is inlined as a base64 data URI. Satori has no loader that takes a path, so each route reads its own footer variant and embeds it: the company card public/ndakita-footer.svg, the Build card public/build-footer.svg. Neither may read a navbar file — those are drawn for a white ground and lose a whole figure on navy. The Build card carried the company mark until build-footer.svg existed, which meant the two cards were identical apart from their words.

  • A page that exports an openGraph object drops the inherited file-convention image. Next replaces the resolved object wholesale instead of merging, so the root opengraph-image only ever reached the home page, the one page that sets no openGraph of its own. Every other page unfurled as a bare link for weeks without any warning. socialMeta() in lib/metadata.ts now names the image explicitly, deriving it from the page's own absolute URL so each host gets its own card without the call site having to know which. Any new page must go through socialMeta().

  • Satori is the layout engine inside next/og, not a font choice. It renders the card to a PNG on the server, where there is no browser and no CSS pipeline, so next/font's self-hosted faces are unreachable and it falls back to a built-in one-weight Noto Sans. That is why the headlines rendered regular however the weight was declared. assets/ now carries the latin subsets of Archivo Bold and Inter Regular, 48 KB for both, read with readFile(join(process.cwd(), …)) and passed to ImageResponse. Any new card must load them the same way or it silently drops back to Noto.

Where each colour lands

The brand rule in §Colour is the constraint; this is where the two colours actually go.

Surface Treatment Measured
Hero, footer, /build CTA band The ground itself white h1 13.15, white/70 lede 7.24
Share cards The ground, white type 13.15
Primary button Navy fill, white ink, no keyline, hover lifts to --brand-ink 13.15 label, 13.15 boundary
Between top-level sections 1px rule, main > section + section 13.15
Card hover 3px left edge, transparent at rest decorative
Current nav item Underline (desktop) or left edge (mobile) decorative
Meters Navy fill on a light track 11.64
Assessment progress bar Navy on a --secondary track 12.27

Gold, --mark

Surface Treatment Measured
The word "Kenyan" in the hero Block behind the word, dark ink on it 5.06 on navy, 7.37 under the ink
Share cards The same block, same treatment 5.06 / 7.37
The mark itself One of the two figures, in every logo file

Gold marks one word and one figure, and nothing else. It is not available for buttons, states, meters or borders. It briefly carried the footer's top rule, straight and then following the curve; the curve reads as an edge on its own and the line only competed with it.

--mark therefore resolves to a single call site in CSS. That looks like overkill for a token until you try folding it into --brand: they are opposite kinds of colour — one a ground white sits on, one a fill dark sits on — and the logo needs both.

The alpha levels a navy ground forces

Navy is lighter than the #0f0f0f it replaced, so white-alpha text that cleared 4.5:1 on black does not automatically clear it here. white/50 lands at 4.48 and fails; the hero kicker moved to white/60 at 5.77. white/70 body and white/60 legal both pass. Check any new alpha against navy rather than assuming.

Accessibility floor

Every interactive element has a focus-visible ring; there is a skip link and an id="main-content" on every <main>; <main> never wraps the header or footer. Anything that moves for more than five seconds has a pause control (WCAG 2.2.2), which prefers-reduced-motion does not satisfy because it is a different success criterion. Text links never get lighter on hover: on a near-black foreground that reduces contrast, so hovers change background or underline instead.