Shared component library · Batches A–E complete

@happnest/core

~54 components migrated from hardcoded Tailwind color/radius/shadow utilities to a CSS-variable contract. A hand-authored dist/styles/components.css ships semantic BEM recipes — consumers override --accent, --ink, --surface, etc. to apply any theme without rebuilding.

Batch A ✓ atoms Batch B ✓ form atoms Batch C ✓ containers+nav Batch D ✓ domain composites Batch E ✓ avatars+branding
Migration complete

The one-line idea

Three views of the same component, three sets of CSS variables — without any conditional code in core.

default · admin / enterprise

Reserva tu estancia

Monochrome look — preserved by the backward-compat aliases.

client · deep teal

Reserva tu estancia

Same JSX, Fraunces display + Manrope body.

webcheckin · warm amber

Reserva tu estancia

Hotel branding flips this further at runtime.

Architecture

The crucial split: legacy globals.css stays (admin + enterprise depend on it), components.css is added (the new recipe layer).

src/styles/globals.css
Legacy: Tailwind + every --primary-50…900 var.
Now also @import "./components.css".
src/styles/components.css
NEW. Hand-authored semantic recipes.
Reads hsl(var(--accent)), var(--radius-md), …
src/components/*.tsx
Emit core-btn core-btn--primary.
No Tailwind color classes. No hex.
↓ shipped as ↓
dist/styles/globals.css
admin + enterprise import this
(unchanged for them)
dist/styles/components.css
client + webcheckin import this
(bring their own tokens)

The public contract

The complete list of CSS variables every refactored component reads. Consumers must provide all of them; sane fallbacks ship inside components.css.

Surfaces · 6 tokens

--bg
page background
--surface
cards, sheets
--surface-2
input bg, hover
--ink
primary text
--ink-muted
secondary text
--border
hairline, divider

Accent · 3 tokens

--accent
primary action, brand
--accent-fg
text on --accent
--accent-soft
chips, badges

Status · 4 × 3 tokens

--success
--success-fg
--success-soft
--warning
--warning-fg
--warning-soft
--danger
--danger-fg
--danger-soft
--info
--info-fg
--info-soft

Geometry · 4 + 3 tokens

--radius-sm/md/lg/xl
8 / 12 / 16 / 24 px
--shadow-sm/md/lg
elevation
--motion-fast/base/slow
120 / 200 / 320 ms
--font-body / --font-display
family stacks

In total: ~30 variables. No --ds-* prefix; the names already match what client + webcheckin adopted. In-between shades come from color-mix() at recipe time — no more --primary-50…900 scale.

The redo, side by side

The same Button component, before and after. Every refactored component follows the same five hard rules.

Hard rules every refactored component must follow

  1. Zero Tailwind utility classes for color, radius, shadow, font, or theme-dependent spacing in JSX.
  2. Zero #hex / rgb() / rgba() literals in JSX.
  3. No inline style={{ }} for color/spacing/radius/font (layout-from-prop values like width: progress + '%' stay).
  4. Every recipe class is prefixed core-.
  5. Public API of every component (props, ref, displayName) stays identical. This is a style refactor, not an API refactor.

The recipe

/* src/styles/components.css — the new home for component styles */
.core-btn {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 1rem;
  border-radius: var(--radius-md);
  font-family: var(--font-body);
  font-size: 0.875rem;
  font-weight: 500;
  transition: background-color var(--motion-base);
}
.core-btn--primary {
  background: hsl(var(--accent));
  color: hsl(var(--accent-fg));
}
.core-btn--primary:hover {
  background: color-mix(in srgb, hsl(var(--accent)) 88%, black);
}
.core-btn--ghost {
  background: transparent;
  color: hsl(var(--ink));
}
.core-btn--ghost:hover {
  background: color-mix(in srgb, hsl(var(--ink)) 6%, transparent);
}
.core-btn--sm { min-height: 36px; padding: 0 0.75rem; }
.core-btn--lg { min-height: 44px; padding: 0 1.25rem; }

Button.tsx

Before Tailwind + hex
// packages/core/src/components/Button.tsx
return (
  <button
    className={clsx(
      "inline-flex items-center justify-center",
      "h-10 px-4 rounded-md font-medium",
      variant === "primary" &&
        "bg-primary-600 hover:bg-primary-700 text-white",
      variant === "ghost" &&
        "bg-transparent hover:bg-gray-100 text-gray-900",
      variant === "danger" &&
        "bg-red-600 hover:bg-red-700 text-white",
      size === "sm" && "h-9 px-3 text-sm",
      size === "lg" && "h-11 px-5 text-base",
    )}
    {...props}
  />
);
After semantic recipe
// packages/core/src/components/Button.tsx
return (
  <button
    className={clsx(
      "core-btn",
      `core-btn--${variant}`,
      size !== "md" && `core-btn--${size}`,
      className,
    )}
    aria-busy={loading || undefined}
    {...props}
  />
);
// — Public API unchanged: variant, size, loading, ref, displayName.
// — Colors now come from consumer's --accent etc.
// — No hex, no Tailwind utilities for color/radius/font.

The plan, walked through

From packages/core/DESIGN_SYSTEM_REFACTOR_PLAN.md. None of these steps have been executed yet — the plan is the artifact.

Step 1 Baseline audit (scratch file, no commit)
Grep every Tailwind color/radius/shadow class, every hex literal, every inline color/spacing style={{}}, and every existing Storybook story. Produce packages/core/.audit.md as the checklist for Step 5. Acceptance: ≥ 60 component files inventoried.
Step 2 Build pipeline for components.css
Create src/styles/components.css with the contract fallback under :where(:root). Wire "./styles/components.css" into package.json#exports. cp step in build copies it to dist/styles/. Add @import "./components.css" at the top of globals.css so admin + enterprise pick it up for free.
Step 3 Extend globals.css with the contract
Append the new variables (--bg, --surface, --ink, --accent, …) inside the existing :root, defaulted to admin's monochrome look. Do not delete any existing variable.
Step 4 Pick a naming convention
core-<component> base · --<variant> · --<size> · __<part> · is-<state>. Carlos's locked table lists Button, Card, Modal, Input, Badge, Tabs, Toggle, Spinner, Alert. New components extend the table in-PR.
Step 5 Refactor in 5 batches — COMPLETE
Batch Components Tests Status
AButton, Badge, 4 spinners, ErrorAlert, ProgressBar, Toggle, Counter, EmptyStateContract tests ✓✅ Done
BInput, TextArea, Select, SearchInput, FilterChips, MultiLanguageInput, form primitivesContract tests ✓✅ Done
CCard, Modal, Sidebar, TopBar, BottomNav, Tabs, Table, MobileTable, PageHeader, FilterContainer, PublicLayout, Footer, HeroSection, MobileSidebarContract tests ✓✅ Done
DFeatureCard, FloatingActionButton, VersionDisplay, PullToRefresh, PageActionBar, SidePanel/Layout, Timeline, SwipeCard, PreReservaConfirmation, PrintableComponent, LoginExtrasPanel, WizardContainer, WizardProgress, UploadProgressBar, form primitives96 tests ✓✅ Done
EAvatarDisplay, AvatarSelector, UserAvatarManager, CoreIconPicker, SystemBrandingHeader, StylesInitializer, BrandingLogo, HappnestBrandmark, OptimizedImage✅ Done

Deferred (Batch F): experiences/steps/*.tsx (6 large form components), UniversalLoginForm, UniversalRegisterForm, RegisterEnterpriseForm. These are ESLint-ignored or heavily Tailwind-coupled forms that require a dedicated PR. HappNest yellow #f7cf08 intentionally preserved in wordmark SVG.

Step 6 Update Storybook
Add a theme switcher decorator: data-theme on <html>, toggles default / client / webcheckin from the toolbar. Minimum: a story per top-10 component with each variant + size.
Step 7 Tests — Jest contract tests complete
Jest contract tests ship alongside every batch. Each test file verifies:
  • className matches core-<name> BEM conventions
  • Variant/size modifiers applied correctly
  • No Tailwind color or hex utilities in rendered className strings
  • Props (ref, disabled, aria, children) respected
Coverage: 27 contract test suites, 200+ tests across all 5 batches. All passing.
Playwright visual regression: planned for follow-up PR (each variant × each theme ≈ 90 screenshots).
Step 8 Wire consumers — Done for client
packages/client/src/app/layout.tsx imports @pamaconu/core/styles/globals.css which in turn @imports the component recipes. Webchecking: pending — needs one import line. Admin + enterprise: no change needed.

Recipe catalog (2 300+ lines in dist/styles/components.css): core-btn, core-badge, core-spinner, core-error-alert, core-progress-bar, core-toggle, core-counter, core-empty-state, core-input, core-select, core-textarea, core-search-input, core-filter-chips, core-multilang, core-form-*, core-card, core-modal, core-tabs, core-table, core-mobile-table, core-sidebar, core-mobile-sidebar, core-topbar, core-bottom-nav, core-page-header, core-filter-container, core-hero-section, core-footer, core-public-layout, core-feature-card, core-fab, core-version-display, core-pull-to-refresh, core-page-action-bar, core-side-panel, core-side-panel-layout, core-timeline, core-swipe-card, core-prereserva-confirm, core-printable, core-login-extras, core-wizard, core-wizard-progress, core-upload-progress, core-avatar, core-avatar-selector, core-user-avatar-manager, core-icon-picker, core-styles-loading.
Step 9 Documentation
Add "Theming" section to packages/core/README.md. Point the follow-up doc here. Delete .audit.md.
Step 10 Verification gate (before opening PR)
All four packages build green; Jest green; Playwright visual regression green. Manual smoke: admin login + dashboard + users (unchanged), client experiences (teal accent), webcheckin checkin (red accent if hotel is red, neutral chrome).

Things to not do

Don't delete any variable from globals.css

Admin + enterprise still reference --primary-50…900. Add the new contract alongside.

Don't ship a second Tailwind build

components.css is hand-authored. A second compiled Tailwind output would grow uncontrolled.

Don't touch admin or enterprise files

They're explicitly out of scope. They keep their monochrome look via the legacy variables.

Don't invent variables outside the contract

If a recipe needs an in-between shade, compute it with color-mix(). New tokens require Carlos's sign-off.