Appearance
UI Components Package
Shared UI components and design tokens in the @jubiloop/ui package, built with shadcn/ui patterns and Tailwind CSS 4.
Overview
The UI package owns reusable components, semantic design tokens, shared base styles, and the PostCSS configuration used by the webapp and marketing site. Applications consume the package as source; it has no separate build step.
Package Structure
| Path | Responsibility |
|---|---|
packages/ui/src/components | Shared React components |
packages/ui/src/hooks | Shared UI hooks |
packages/ui/src/lib | Utilities, including configured class merging |
packages/ui/src/styles | Tokens, mappings, base rules, and shared utilities |
packages/ui/scripts | Package contract and stylesheet smoke tests |
package.json defines the supported public exports. Internal files that do not match those export patterns are not part of the consumer contract.
Package Exports
Components, hooks, and library helpers use wildcard subpath exports:
text
@jubiloop/ui/components/* -> src/components/*.tsx
@jubiloop/ui/hooks/* -> src/hooks/*.ts or src/hooks/*.tsx
@jubiloop/ui/lib/* -> src/lib/*.ts
@jubiloop/ui/styles.css -> src/styles/index.css
@jubiloop/ui/postcss.config -> postcss.config.mjsUsing Components
Consumers import the module they need rather than a package-wide barrel:
tsx
import { Button } from '@jubiloop/ui/components/button'
import { Card } from '@jubiloop/ui/components/card'
import { Input } from '@jubiloop/ui/components/input'
import { Label } from '@jubiloop/ui/components/label'Component modules use named exports. Host-element wrappers accept the corresponding React.ComponentProps, while Radix wrappers use React.ComponentProps<typeof Primitive> so native props, Radix behavior, and refs remain available to consumers.
Components with visual variants define them with cva. Dimensional size axes use sm, md, and lg, with md as the omitted default; purpose-specific sizes such as icon or compact are valid when they describe different geometry. Components that support changing their root element expose the Radix Slot asChild pattern:
tsx
<Button asChild>
<Link to="/dashboard">Open dashboard</Link>
</Button>Root elements use data-slot, and variant-aware controls may expose data-variant and data-size. These attributes support shared styling and consumer slot targeting without adding app-specific props to the package.
Adding New Components
To add new shadcn/ui components:
bash
# From an app directory (webapp or marketing)
cd apps/webapp
pnpm dlx shadcn@canary add [COMPONENT]The app's components.json points shadcn at packages/ui/src/styles/index.css and aliases generated components into packages/ui. The package's wildcard component export makes a new component available at @jubiloop/ui/components/{component-name} without another export entry.
Styling
Shared style ownership is divided by responsibility:
| Path | Responsibility |
|---|---|
src/styles/primitives.css | Private raw color, typography, spacing, shape, shadow, and motion |
src/styles/tokens.css | Public semantic roles and light/dark values |
src/styles/theme.css | Exact Tailwind mappings and the shared dark variant |
src/styles/base.css | Shared element and document rules |
src/styles/utilities.css | Complete type-* role utilities, confettiFall keyframes, and reduced-motion rules |
src/styles/index.css | Style composition and packages/ui/src source scan |
@jubiloop/ui/styles.css | Public stylesheet export |
@jubiloop/ui/postcss.config | Public PostCSS configuration export |
Each application owns a small CSS wrapper:
apps/webapp/src/globals.cssapps/marketing/src/app/globals.css
The wrapper imports @jubiloop/ui/styles.css and defines the Tailwind @source scan for that application's src directory. Don't add application paths to packages/ui/src/styles/index.css.
Tailwind runs through the shared PostCSS configuration. Consumers use the supported @jubiloop/ui/styles.css and @jubiloop/ui/postcss.config entry points rather than adding @tailwindcss/vite or another app-specific Tailwind processor.
Token Layers
primitives.css is the only raw-value layer. Every declaration uses the private --jui-ref-* prefix. These values can contain hex, HSL, lengths, shadow recipes, and motion values. Only primitives.css and tokens.css may reference private names. Components, applications, theme.css, base.css, and utilities.css must not use them, and theme.css must not expose them as Tailwind utilities.
tokens.css defines the public semantic contract. Semantic declarations point to a private primitive or to an intentional semantic alias, such as --border: var(--border-default). Raw values don't belong in this file.
The public tokens have two scopes:
- Theme-sensitive tokens exist in both
:rootand.dark. This includes surfaces, content, brand and status colors, boundaries, focus, navigation, and elevation. - Root-only tokens don't change by color mode. This includes typography, page spacing, content widths, shape and role radii, and motion. Under
prefers-reduced-motion: reduce,tokens.cssshortens deliberate and decorative motion, whileutilities.cssdisables smooth scrolling and shortens transition and animation durations.
Use the named radius and elevation roles; the package does not provide generic --radius or --shadow compatibility tokens.
theme.css maps semantic tokens to Tailwind namespaces. Application and component code should use the resulting semantic classes:
tsx
<section className="bg-background text-foreground">
<article className="rounded-card border border-border bg-card text-card-foreground shadow-raised">
<button className="bg-primary text-primary-foreground hover:bg-primary/90">Continue</button>
</article>
</section>Other mapped roles include bg-surface-low, status colors such as text-success, layout classes such as px-page-gutter and gap-layout-gap, control geometry such as h-control-md and min-h-control-multiline, content widths such as max-w-content and max-w-page, role radii, elevation shadows, and named motion utilities such as duration-feedback and ease-standard. Named duration utilities come from Tailwind's --transition-duration-* namespace while retaining the public --motion-duration-* semantic source tokens. Prefer semantic color utilities when a role fits the design. This contract doesn't claim that every raw Tailwind palette utility is forbidden.
| Role | Semantic utility examples |
|---|---|
| Surfaces and content | bg-background, bg-card, bg-surface-low, text-foreground, text-muted-foreground |
| Statuses | bg-success, text-warning, bg-info-subtle, text-destructive-subtle-foreground |
| Boundaries and focus | border-border, border-input, border-focus-ring, ring-3, ring-focus-ring/30 |
| Layout | px-page-gutter, gap-layout-gap, h-control-md, min-h-control-multiline, max-w-content, max-w-page |
| Shape and elevation | rounded-control, rounded-card, rounded-panel, rounded-pill, shadow-raised, shadow-overlay |
| Motion | duration-feedback, duration-transition, ease-standard, translate-y-lift-subtle, scale-subtle |
utilities.css defines the eight semantic type-* utilities described below and keeps the confettiFall keyframes inside @layer utilities. Other token-backed utilities come from the mappings in theme.css.
Class Merging
Shared components pass their defaults and consumer className values through cn() from src/lib/utils.ts. Its Tailwind Merge configuration mirrors custom theme namespaces that are not recognized by default, including semantic text sizes, spacing, containers, radii, shadows, easing, durations, translation, and scale. This lets a consumer override h-control-md with h-10 or rounded-card with rounded-lg without retaining two conflicting declarations.
The compound type-* roles use asymmetric conflicts. A later type-body replaces earlier family, size, weight, leading, and tracking classes. A later text-sm or font-bold remains as an intentional local exception to one part of the role. Color classes remain independent.
Typography
Typography is part of the shared token contract. primitives.css defines private raw values for three families and each role's size, line-height, weight, and letter spacing. tokens.css exposes the public display, heading-lg, heading-md, heading-sm, body, label, caption, and code roles.
| Role | Family | Size behavior | Utility |
|---|---|---|---|
display | Newsreader Variable | clamp(3rem, calc(2.5rem + 2vw), 3.75rem) | type-display |
heading-lg | Newsreader Variable | clamp(2rem, calc(1.5rem + 1.5vw), 3rem) | type-heading-lg |
heading-md | Newsreader Variable | clamp(1.5rem, calc(1.25rem + 1vw), 2.25rem) | type-heading-md |
heading-sm | Newsreader Variable | clamp(1.25rem, calc(1.1rem + 0.5vw), 1.5rem) | type-heading-sm |
body | Manrope Variable | 1rem | type-body |
label | Manrope Variable | 0.875rem | type-label |
caption | Manrope Variable | 0.75rem | type-caption |
code | System monospace | 0.875rem | type-code |
Manrope Variable is the default font-sans family for body text, UI, labels, and captions. Newsreader Variable is available only through explicit display or heading roles. Code stays on the system monospace stack; Fontsource doesn't supply it.
theme.css maps font-sans, font-display, font-heading, and font-code, plus text-display, text-heading-lg, text-heading-md, text-heading-sm, text-body, text-label, text-caption, and text-code with their line heights. utilities.css combines each public role's family, size, line-height, weight, and tracking into a single class:
tsx
<header>
<h1 className="type-display text-foreground">Plan something memorable</h1>
<p className="type-body text-muted-foreground">Keep every detail in one place.</p>
</header>
<label className="type-label" htmlFor="event-date">
Event date
</label>
<code className="type-code">pnpm run dev</code>Use a type-* utility when the complete semantic role should control all five properties. Use font-display or font-heading only when applying the Newsreader family to an existing page-level hierarchy whose size, line-height, weight, and tracking should remain unchanged.
Don't stack superseded text-size, line-height, weight, family, or tracking utilities on a type-* class. For example, type-heading-lg text-xl leading-tight font-bold replaces parts of the semantic role and obscures which contract applies. Add one of those utilities only for an intentional local exception.
Display and heading sizes use clamp() so they scale with the viewport and remain bounded. Pages using these roles must reflow without horizontal text overflow at 320px. The browser typography spec also checks a 640px viewport as a proxy for a 1280px layout at 200% zoom; it doesn't set real browser zoom. Wrapping is expected. Don't force single-line headings or fixed-width containers that break that reflow.
Newsreader includes a variable italic face. Use the normal Tailwind italic utility with type-display, type-heading-lg, type-heading-md, or type-heading-sm where italics fit the editorial treatment:
tsx
<h2 className="type-heading-lg italic">A gathering worth remembering</h2>Font Delivery
The workspace catalog pins @fontsource-variable/manrope and @fontsource-variable/newsreader. Both production app manifests depend on those packages through catalog:. Each production entrypoint imports the required Fontsource styles directly:
ts
import '@fontsource-variable/manrope/wght.css'
import '@fontsource-variable/newsreader/wght-italic.css'
import '@fontsource-variable/newsreader/wght.css'apps/webapp/src/main.tsx and both marketing route-group layouts own these imports while @jubiloop/ui owns the shared families, role tokens, Tailwind mappings, and utilities. Fontsource bundles local, unicode-ranged WOFF2 assets and uses font-display: swap. Production pages don't make Google Fonts runtime calls or load font files from remote URLs. There are no manual preloads or custom PostCSS font transformations. Font licenses are recorded once in THIRD_PARTY_NOTICES.md.
The typography Playwright project checks both production apps for available self-hosted font faces, font requests limited to the configured local app origins, and horizontal reflow when WOFF2 requests are blocked. It doesn't assert a specific computed fallback family. Run it from playwright-framework/ with pnpm exec playwright test --project=typography.
Light And Dark Modes
:root supplies the light values. An application activates dark mode by adding .dark to an ancestor, normally the document root. Applying that class changes the theme-sensitive semantic tokens, and the shared dark variant in theme.css targets its descendants. The webapp configures next-themes in src/main.tsx to control this class. Marketing explicitly applies the light class and color scheme, so it uses only the light values even though it imports both token scopes.
The following 19 text token pairs are designed to meet at least 4.5:1 in light and dark mode:
text
foreground/background card-foreground/card
popover-foreground/popover primary-foreground/primary
secondary-foreground/secondary tertiary-foreground/tertiary
muted-foreground/muted accent-foreground/accent
success-foreground/success success-subtle-foreground/success-subtle
warning-foreground/warning warning-subtle-foreground/warning-subtle
info-foreground/info info-subtle-foreground/info-subtle
destructive-foreground/destructive destructive-subtle-foreground/destructive-subtle
sidebar-foreground/sidebar-background sidebar-primary-foreground/sidebar-primary
sidebar-accent-foreground/sidebar-accentThe following four non-text token pairs are designed to meet at least 3:1 in both modes: input/background, border-strong/background, focus-ring/background, and sidebar-ring/sidebar-background.
Verify contrast in rendered browser states. Opacity, gradients, images, overlays, state changes, arbitrary utility combinations, and a token used on a different surface can change effective contrast even when the base token pair meets its target.
Changing Tokens
Token changes can affect every package consumer in both themes. Search all consumers and test the rendered states before changing an existing role.
- Add or change the raw value in
primitives.cssusing a--jui-ref-*name. - Reference that primitive from a role in
tokens.css. Add both light and dark declarations for a theme-sensitive role; add root-only values only to:root. - If consumers need a Tailwind utility, add the semantic mapping to
theme.css. Never map a private primitive directly. - If the mapping adds a custom Tailwind value, update
src/lib/utils.tswhen Tailwind Merge does not recognize that namespace value by default, and add coverage toscripts/class-merge.test.mjs. - Use the supported semantic classes at the consumer and keep the public contract focused on current roles.
- Run the UI tests and both consuming app builds, then verify affected rendered states.
The style-token contract owns typography primitives, semantic roles, Tailwind mappings, and type-* utilities. base.css applies the body role values and the system monospace family to code elements. Component variants remain with their component implementation rather than in token or utility stylesheets.
Validation
Run from the repository root:
bash
pnpm --filter @jubiloop/ui lint
pnpm --filter @jubiloop/ui test
pnpm --filter @jubiloop/ui check-typestest compiles the public stylesheet through the real Tailwind/PostCSS pipeline and confirms that representative color, border, radius, shadow, motion, and typography utilities are generated. It also verifies that cn() resolves custom semantic utilities against standard Tailwind overrides. These are contract and smoke tests, not complete design-system or accessibility tests.
Run pnpm run build:webapp and pnpm run build:marketing after shared style changes to verify both consumer pipelines. Use browser, visual, and accessibility coverage for rendered behavior. The UI package does not maintain a separate custom token or source-policy validator.
Component Ownership
Add a component here when it is reusable, presentational, and domain-agnostic. Radix wrappers, inputs, buttons, cards, overlays, navigation primitives, logo and social icon wrappers, and layout primitives fit this package.
Keep API calls, route behavior, authentication, event and organization logic, app state, and route-specific compositions in the consuming application. A shared component can expose state and event props, but it must not choose an application's state owner or API client.