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.
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.
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.
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.
A signed-in user chooses dark (default), light, or match device.
bgo-theme-mode cookie (a preference, not a secret
— deliberately not httpOnly, so the client can write it without a round trip).app/src/app/layout.tsx reads it and stamps data-bgo-theme and color-scheme
on <html>, so the first byte already carries the right palette. There is
no inline no-flash script and no client-only theming.match device is resolved in CSS by a prefers-color-scheme media query — no
JavaScript is involved in deciding which palette to use.app/src/components/theme-selector.tsx is the control. It writes the cookie,
updates the attribute, and emits a bgo-theme-change event so the header and
the settings page never disagree.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.
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:
app/src/components/shell/header.tsx — public and auth pages, with a
disclosure menu below 950px and the theme picker when signed in.app/src/components/shell/footer.tsx — the public footer.app/src/components/app/header.tsx — the signed-in workspace header, which
also carries the mark, the theme picker, and the workspace nav.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.
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.
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.
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:
oklch(), which is how Tailwind v4 serialises computed colours. A
probe that only understands rgb()/rgba() reads those as transparent, walks
up to an opaque ancestor, and cheerfully reports a red button as white-on-white.unmeasured (currently the
signal orb and the word inside it, which are deliberately fixed dark objects in
both palettes) instead of being scored against whatever ancestor is opaque.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).