Skip to content

Latest commit

 

History

History
256 lines (208 loc) · 11.7 KB

File metadata and controls

256 lines (208 loc) · 11.7 KB
title Design Tokens
description The centralized semantic Tailwind / NativeWind vocabulary shared by every Oxy app.

Design Tokens

@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.

Preset recipes and runtime colour

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 picker

The 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.

Opt in

Native (NativeWind) and Tailwind v3 web

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 web (auth, console, website) and CSS-first NativeWind (Homiio)

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.css

The pre-JS palette (prerendered apps)

theme.css is the alias layer: --color-background: var(--background). It says nothing about what --background isBloomThemeProvider 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.

Consumers that are not browsers

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; // 400

The 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.

Color roles

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

The neutral surface ramp

--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.

Scales

  • 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 with border-border-image.
  • Typography: text-<role> (size/line-height) + font-<role> (family/weight) for caption, bodySmall, bodyTitleSmall, body, subtitle, sectionTitle, headerBold, buttonLarge.
  • Shadow: shadow-s, shadow-m (web box-shadow). On native surfaces apply bloomShadowStyle('s' | 'm') (returns RN elevation/shadow props).

Type scale

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