| title | Design Tokens |
|---|---|
| description | The centralized semantic Tailwind / NativeWind vocabulary shared by every Oxy app. |
@oxy.so/bloom/design-tokens ships the Oxy Unified Design Language: a
namespaced, semantic set of utility classes (bg-fill, text-text-tertiary,
p-space-8, rounded-radius-20, text-body, font-body, shadow-s) that
resolve to the SAME class names on web (Tailwind) and native (NativeWind).
Color roles are aliases onto Bloom's existing resolved theme tokens
(--background, --surface, --primary, …), so they follow the active
light/dark mode and color preset automatically. The vocabulary is additive —
existing utilities (bg-background, text-muted-foreground, rounded-lg) keep
working.
Bloom ships 64 named recipes, authored once in the type-checked
COLOR_PRESET_REGISTRY. A recipe is not a frozen light/dark token table: it is
an identity seed, a scheme variant and optionally explicit secondary/tertiary
seeds. BloomThemeProvider, getResolvedTokens() and BloomColorScope run
those seeds through the same colour policy for the active mode and scope.
Arbitrary consumer seeds remain supported through buildThemeFromSeed() and
BloomSeedScope; the named catalog is a set of useful starting points, not the
limit of the dynamic engine.
import {
COLOR_PRESET_REGISTRY,
COLOR_PRESET_FAMILY_REGISTRY,
COLOR_PRESET_GROUPS,
} from '@oxy.so/bloom';
const cobalt = COLOR_PRESET_REGISTRY.find((recipe) => recipe.name === 'cobalt');
cobalt?.displayName; // 'Cobalt + Signal Yellow'
cobalt?.family; // 'ocean'
cobalt?.tertiaryHex; // '#ffd000'
COLOR_PRESET_FAMILY_REGISTRY[0].displayName; // stable family copy and order
COLOR_PRESET_GROUPS.ocean.presets; // ordered recipes for a pickerThe 46 pairing: 'curated' recipes pin an intentional action seed. The first 16
pin tertiaryHex; the newest 30 are complete three-seed combinations and pin
both secondaryHex support and tertiaryHex action. Identity remains
--primary, support resolves through --secondary, and the standout action is
--tertiary, which is the role used by FABs and compose actions. The other 18
recipes derive both accent families dynamically. Every recipe exposes
displayName, description, family, pairing and featured metadata, so
consumer pickers do not need a second name/category map. Historical
APP_COLOR_PRESETS, name arrays, hex lookup and access-gate arrays are derived
from this registry and retain their existing public shapes.
For visual review, run Storybook and open Theme / Color System Playground
(src/theme/ColorSystemLab.stories.tsx). It keeps signed-in and public Mention
views, renders both modes from getResolvedTokens(), and filters the complete
registry by family or curated/derived pairing.
Add the Bloom preset to your tailwind.config.js:
const { bloomTailwindPreset } = require('@oxy.so/bloom/tailwind-preset');
module.exports = {
presets: [bloomTailwindPreset],
content: [/* your existing globs */],
theme: { extend: {/* your existing app overrides */} },
};That single line adds every semantic utility. Your BloomThemeProvider already
populates the underlying --x color tokens at runtime.
Tailwind v4 apps configure tokens in CSS. Import Bloom's shipped theme CSS —
never paste the tokens into your own global.css. Bloom ships a static
theme.css (generated from the same source as bloomThemeCss(), so JS and CSS
cannot drift) at a dedicated export:
/* your global.css — after the Tailwind import */
@import "tailwindcss";
@import "@oxy.so/bloom/design-tokens/theme.css";That single @import registers the full Bloom @theme vocabulary (color-role
aliases, spacing, radius, border-width, typography, shadow), so utilities like
rounded-radius-28, p-space-8, and text-body resolve without hand-copying
any --radius-radius-* / --spacing-* block. The aliases are
--color-<role>: var(--canonical) — direct var(--x) references (never
hsl(var(--x)), per Bloom's web CSS-var contract).
Keep only your app-local color seeds / :root overrides in global.css.
Do NOT re-declare Bloom's radius or spacing scales locally — the imported CSS is
the single authority.
If you need the block inline (e.g. an app that assembles its stylesheet at build
time) rather than via @import, bloomThemeBlock() / bloomThemeCss() return
the exact same content programmatically:
import { bloomThemeBlock } from '@oxy.so/bloom/design-tokens';
// write `bloomThemeBlock()` into your generated global.csstheme.css is the alias layer: --color-background: var(--background). It
says nothing about what --background is — BloomThemeProvider writes that at
startup. So between first paint and hydration, a prerendered page shows whatever
palette its own stylesheet declares, and a hand-written one is a second source of
truth that drifts from the preset it claims to mirror.
Generate it instead, at build time:
import { getPresetVars, buildSeedScopeVars } from '@oxy.so/bloom/design-tokens';
// Document root: canonical tokens are all it needs.
getPresetVars('oxy', 'dark'); // { '--background': 'rgb(16 13 16)', … }
// A scoped block — a brand section, a forced-dark region — needs the
// `--color-x` aliases too, because an alias substitutes where it is DECLARED:
// overriding `--background` on a subtree does not move a `--color-background`
// declared at `:root`. That is why hand-written brand themes write every colour
// twice. `buildSeedScopeVars` returns both namespaces from one seed.
buildSeedScopeVars({ seed: '#7c5aed', mode: 'dark' });buildSeedScopeVars also writes the ROLE vocabulary (withScopeAliases)
alongside the colour aliases — omitting it is what makes a scoped block paint
near-black text over a dark photo in forced-dark mode, since the role that
resolves foreground-over-fill never got scoped with everything else.
A preset's own hex + variant reproduces getPresetVars token-for-token, so
the document root and a brand scope go through the same code path. Both
functions are pure — no react, no react-native — so a plain node/bun script can
import them.
A consumer that cannot run a stylesheet — native UI code, a design tool, a codegen step — reads the resolved tokens as data instead. Bloom ships them in W3C Design Tokens (DTCG) format, generated from the same colour engine, so the JSON and the CSS cannot disagree:
import tokens from '@oxy.so/bloom/design-tokens/tokens.json';
tokens.color.oxy.dark.card.$value; // '#3b323c' — the value of --card
tokens.color.teal.light.primary.$value; // '#1d6a75'
tokens.spacing['space-8'].$value; // '8px'
tokens.typography.body.fontWeight.$value; // 400The JSON is an interoperability artifact generated by generate:tokens-json,
not an editable source. Each preset group also carries a so.oxy.bloom
extension with its seed recipe and picker metadata (family, description,
pairing, featured and optional secondary/tertiary seeds). Keeping the authored
registry in TypeScript gives duplicate/shape checks and inferred identifier
types; generating JSON from it gives non-TypeScript tools the same data without
introducing a second catalog.
color.<preset>.<scheme>.<token> covers every preset Bloom ships and both
schemes. Token names are the CSS custom properties with the leading --
removed, so the mapping to getPresetVars output is mechanical. Values are
sRGB hex — #rrggbb, or #rrggbbaa for the translucent -subtle family.
Font families and shadows are deliberately absent: families resolve to
var(--bloom-font-*), which is meaningless off the web, and shadows ship as a
platform-forked style object (bloomShadowStyle). If you need the same data in
memory rather than from the file, bloomDesignTokens() from
@oxy.so/bloom/design-tokens returns it.
| Utility | Role | Canonical token |
|---|---|---|
bg-bg |
page background | --background |
bg-fill |
default surface | --card |
bg-fill-secondary |
muted fill | --muted |
bg-fill-hover |
secondary hover | --accent |
bg-fill-brand |
brand fill | --primary |
bg-fill-brand-hover |
brand hover | --ring |
bg-fill-inverse |
inverse fill | --foreground |
bg-fill-inverse-hover |
inverse hover | --muted-foreground |
bg-fill-placeholder |
disabled surface | --muted |
text-text |
primary text | --foreground |
text-text-secondary |
secondary text | --muted-foreground |
text-text-tertiary |
tertiary text | --muted-foreground |
text-text-inverse |
text on brand | --primary-foreground |
text-text-fixed-light |
always-light text | --primary-foreground |
text-text-placeholder |
placeholder text | --muted-foreground |
border-border |
default border | --border |
border-border-image |
hairline color | --border |
border-border-secondary |
softer border | --input |
border-border-input |
input border | --input |
border-border-input-active |
focused input | --ring |
--background, --surface, --popover, --muted/--accent and --card are a
stack: several components carry meaning only in the step between two of them —
an ActivityHeatmap empty cell, Card variant="filled", a Code block, the
StatBar and CompositionBar tracks, DotGridMeter's off dots, a filled Tabs
track, a selected Item, the solid TabBar, a plain Card on a panel, the
Toast action, and the Avatar/BenefitList/LinkPreviewCard placeholders.
Those tones are owned by SURFACE_RAMP in theme/color-policy.ts rather than
taken from Material 3's container roles. M3's ramp steps by 2 tones, which at
these lightnesses is about 1.2 ΔE00 — below the just-noticeable difference for
two large flat patches. Measured across all 64 presets, 4 tones (~2.4 ΔE00) is
the first spacing at which every pair separates.
--background is deliberately not part of the ramp: the page tone is every
consuming app's page colour, so the rungs below it carry the spacing instead.
--card is not either — it sits on the far side of the page in light (tone 100,
white) and at the top of the ramp in dark, so it is expressed as the engine role
that means exactly that in each mode.
If you move any of these, measure every pair rather than the one you are
fixing: the light tones 92/94/96/98/100 are exactly --muted, --popover,
--surface, --content-area and --card, so any new value for one lands on
another unless you check.
- Spacing (px):
space-2/4/8/12/16/20/24/32+screen-margin(20).p-space-8,gap-space-20,px-screen-margin. - Radius (px):
radius-8/12/20/28/max.rounded-radius-20. - Hairline border:
border-hairline(0.5px), pair withborder-border-image. - Typography:
text-<role>(size/line-height) +font-<role>(family/weight) forcaption, bodySmall, bodyTitleSmall, body, subtitle, sectionTitle, headerBold, buttonLarge. - Shadow:
shadow-s,shadow-m(web box-shadow). On native surfaces applybloomShadowStyle('s' | 'm')(returns RN elevation/shadow props).
| Role | Size / line | Weight | Family |
|---|---|---|---|
caption |
11 / 14 | 400 | sans |
bodySmall |
13 / 18 | 400 | sans |
bodyTitleSmall |
13 / 18 | 600 | sans |
body |
15 / 22 | 400 | sans |
subtitle |
17 / 24 | 500 | sans |
sectionTitle |
20 / 26 | 600 | sans |
headerBold |
28 / 34 | 700 | display |
buttonLarge |
17 / 22 | 600 | sans |