Productive IT logo PRODUCTIVE IT · BrandGrowth OS docs

Brand, theme, and shell

How BrandGrowth OS presents itself, and how to change that safely. Everything here is implementation, not intention: if this document and the code disagree, the code is right and this file is a bug.

Palette

The brand is Productive IT's: lime on ink, with blue and purple as cool secondaries. The lime is the only accent that carries action (links, CTAs, indices, focus rings). Nothing here is a rounded-corner SaaS blue.

Token Dark (default) Light Used for
--bgo-canvas #0e1220 #f4f5f7 page background
--bgo-panel #151a2b #ffffff cards, menus, inputs
--bgo-panel-2 #1c2237 #eceef3 hover fills, chips
--bgo-ink #f4f5f7 #0e1220 body text
--bgo-ink-strong #ffffff #0b0e18 headings
--bgo-copy #b9c1d4 #3f4759 paragraphs
--bgo-muted #8d97b5 #5a6380 labels, metadata
--bgo-line / --bgo-line-2 white @ 15% / 28% ink @ 12% / 24% borders
--bgo-accent #84b340 #84b340 accent, CTAs, focus
--bgo-accent-soft #c6dc8f #c6dc8f CTA hover
--bgo-blue #465493 #465493 tertiary accents
--bgo-purple #7c89c4 #7c89c4 tertiary accents
--bgo-brand-text #84b340 #4c7619 the accent as text (links, active nav)

Tokens are declared once in app/src/app/globals.css and re-exported to Tailwind through @theme inline, so components can use bg-panel, text-ink, border-line, bg-brand, text-muted, bg-panel2 instead of fixed neutrals. Use the tokens. A hard-coded bg-white or text-zinc-900 is a bug that only shows up when someone picks the other theme — and so is a raw palette utility, because Tailwind's palettes are tuned for a white page. The lime that carries action on ink is 2.4:1 on paper, which is why --bgo-accent (surfaces, fills) and --bgo-brand-text (letters) are separate tokens.

Status colours

Four meanings — danger, warning, success, info — each with a tinted surface, its border, the ink that sits on that surface, and (for danger) a solid button. They are not shared between palettes: a chip tuned for paper (pale amber, dark brown text) is a glaring white block on ink.

Family Dark surface / line / ink Light surface / line / ink
danger red @ 14% / @ 42% / #ff9c94 #fdeceb / #f2c1bc / #a3190f
warn amber @ 13% / @ 38% / #f5c451 #fdf5e3 / #efd7a3 / #8a5300
success emerald @ 13% / @ 40% / #6ee7a8 #eaf7ef / #b8ddc7 / #1c6b40
info sky @ 13% / @ 38% / #8ecbf5 #eaf4fd / #b9d9f2 / #16528c

Usage reads left to right as bg-<family>-bg (the tinted surface), border-<family>-line, text-<family>-ink. Destructive controls use the solid pair instead: bg-danger text-on-danger hover:bg-danger-hover. A disabled destructive button deliberately drops out to bg-panel2 text-muted rather than a washed-out red, so "this action is unavailable" is legible in both palettes.

Every one of these pairs is measured, and the numbers are pinned: see app/e2e/theme-contrast.spec.ts.

Typography

Both are self-hosted under app/public/fonts (latin + latin-ext subsets) and declared with @font-face in globals.css. There is no Google Fonts request at runtime and the production build needs no network access. To change a family or weight, re-download the subset from Google Fonts with a browser user agent, drop the .woff2 files in app/public/fonts, and update the @font-face blocks — keeping font-display: swap.

Never declare a family with only src: local('…'). That was the original bug: with no webfont files the browser quietly fell back to the system sans while the display rules kept their very tight tracking and line-height: 0.84, which renders words on top of one another. A fallback is fine as the last entry in a font stack; it is not a substitute for shipping the font.

Display type is set tight but never tighter than the glyphs: headings keep line-height >= 1 and tracking at -0.02em/-0.03em. Anything below 1 makes descenders collide with the caps on the next line and reads as overlapping words. This is pinned for every route by the collapsed-line-height guard in app/e2e/responsive.spec.ts.

Theme modes

A signed-in user chooses dark (default), light, or match device.

Signed-out visitors always see the dark brand shell; they are not offered a picker. That is asserted in app/e2e/brand-theme.spec.ts.

Shell

Two components carry the brand lockup (the Productive IT mark plus the BrandGrowth OS wordmark) so it is present on every page, not only the marketing routes:

The mark lives at app/public/brand/productiveit-logo.svg. New public routes should render through the (marketing) or (auth) layouts rather than building their own header; a page that hand-rolls a header is how the brand drifted apart before.

Hosting under productiveit.in/products/brandgrowth-os/

All brand classes are namespaced bgo-* and every rule is scoped to .bgo-root or a bgo- class, so nothing leaks into host-page markup and we never style bare element selectors that the host site also styles (header, nav, footer, h1). Because this is served as its own route, host CSS is not inherited at all.

Framing is denied by design. The app sends X-Frame-Options: DENY and frame-ancestors 'none' (see app/next.config.ts), so link to it as its own route — productiveit.in/products/brandgrowth-os/ — rather than embedding it in an iframe on a parent page. An embed would need those two headers relaxed to allow the parent origin, which is a deliberate security decision, not a styling one: do not remove them without deciding who is allowed to frame the app.

Phones and tablets

The label scale above is authored for a desktop monitor: 8-10px uppercase mono reads as texture on a laptop and is unreadable on a phone. Below 700px the same labels step up to 12px, and every control in the shell chrome gets a 44px hit area (the iOS/Android minimum). Page copy keeps its authored sizes. Nothing in the workspace may push content past the viewport, and .bgo-root uses overflow-x: clip, which means an over-wide row is cut off rather than scrollable — so the header row wraps instead.

The workspace nav is eleven links (~915px at the compact label size). It is only a horizontal strip when it genuinely fits, above 960px; below that the same links move behind a disclosure in app/src/components/app/header.tsx, because a scrolling strip strands most of them off-screen where nobody finds them.

These are pinned by app/e2e/responsive.spec.ts at 360/390/430/834/1280px: no sideways scroll, nothing rendered past the edge (decorative orbs and the ribbon excepted), labels >= 12px on phones, shell controls >= 44px tall, the header dropping its secondary link before it crowds the brand, and the nav being a disclosure on a phone/tablet but a visible strip on a desktop.

When adding a screen, check it at 390px. The specs cover the routes they list; a new route is not covered until it is added there.

Contrast is measured, not asserted

Deleting the legacy bridge (see below) only means something if the replacement colours are actually legible, so app/e2e/theme-contrast.spec.ts walks all 23 public and workspace routes in both palettes and fails if any text falls below WCAG AA.

Two details make it trustworthy rather than decorative:

Known debt

The globals.css "legacy utility bridge" that remapped raw Tailwind neutrals to brand tokens under the dark palette is gone, along with the last raw text-zinc-900/bg-white/text-red-600-style utility in app/src. If you add a screen that renders wrong in one palette, the cause is a raw utility or a hard-coded hex — not a missing bridge rule; there is no bridge to add it to.

The exceptions are deliberate and must stay: oauth-buttons.tsx hard-codes the Google and LinkedIn brand hexes (third-party marks are not ours to re-colour), and the hero orb plus its three signal labels keep a fixed dark treatment in both palettes (they carry white display type on their own gradient, so they are a brand object rather than a themed surface).