Component Patterns

Architectural patterns used throughout @so.boss/gloss-ui. Understanding these patterns helps you consume components correctly and leverage TypeScript for compile-time safety.

1. Discriminated Unions

Several Gloss components use a discriminated union on a variant prop to narrow the set of allowed props at compile time. MetricCard is the primary example: it defines 9 variants, each with its own required and optional props, all sharing a common base interface.

TypeScript narrows the type automatically based on the variant value. If you pass props that belong to a different variant, the compiler rejects it before you ever run the code.

// The discriminated union type
type MetricCardProps =
  | StandardVariant      // variant: 'standard'
  | SparklineVariant     // variant: 'sparkline'
  | BarsVariant          // variant: 'bars'
  | DonutVariant         // variant: 'donut'
  | HighlightVariant     // variant: 'highlight'
  | ArrowVariant         // variant: 'arrow'
  | ObjectArrowVariant   // variant: 'object-arrow'
  | VersusVariant        // variant: 'versus'
  | SegmentedVariant;    // variant: 'segmented'

// Each variant extends a shared base
interface SparklineVariant extends BaseMetricCardProps {
  variant: 'sparkline';
  sparklineData: number[];  // Required only for this variant
  accentColor?: string;
}

// Usage: TypeScript knows sparklineData is required here
<MetricCard variant="sparkline" label="Orders" sparklineData={[42, 38, 51, 47, 55]} />

// Usage: TypeScript knows value is the primary prop here
<MetricCard variant="standard" label="Revenue" value={128400} />

// Compile error: sparklineData is not valid on 'standard' variant
// <MetricCard variant="standard" label="Revenue" sparklineData={[1,2,3]} />

Key benefit

Invalid prop combinations are caught at compile time. You cannot accidentally pass sparkline data to a donut card or bar data to a standard card. The compiler acts as a guardrail, eliminating an entire class of runtime bugs.

2. Controlled Components

All Gloss components are fully controlled. They do not maintain internal state for values, selections, or visibility. The consuming application owns all state and passes it down via props. When user interaction occurs, the component fires a callback — it never updates its own state.

This means there is no useState for values inside library components. Animation state, hover effects, and layout measurement hooks are the only internal state allowed — these are implementation details that do not affect the data contract.

import { TabbedDataView } from '@so.boss/gloss-ui';

function MyDashboard() {
  // YOU own the state
  const [activeTab, setActiveTab] = useState('orders');
  const [tabs, setTabs] = useState<TabConfig[]>(initialTabs);

  return (
    <TabbedDataView
      tabs={tabs}
      activeTabId={activeTab}
      onTabChange={setActiveTab}       // Callback: component tells you what happened
      onTabClose={(id) => setTabs(t => t.filter(tab => tab.id !== id))}
      renderTabContent={(tab) => <DataTable tab={tab} />}
    />
  );
}

Key benefit

Predictable data flow. Your app is the single source of truth for all state. Components become pure renderers that are easy to test, debug, and reason about. You can persist state anywhere — localStorage, server, URL params — without the library caring how.

3. Render Props

Layout and container components use render props to delegate content rendering to the consuming application. The library handles layout, interaction, and behavior — you provide the actual content.

This pattern appears in MetricCardGrid (renderCard, renderEmptySlot) and TabbedDataView (renderTabContent). The component calls your render function with the relevant data, and you return the JSX to display.

import { MetricCardGrid, MetricCard } from '@so.boss/gloss-ui';

function Dashboard() {
  return (
    <MetricCardGrid
      cards={cardData}
      cardOrder={order}
      layout={gridLayout}
      onReorder={setOrder}
      onPageChange={setPage}
      // You decide how each card renders
      renderCard={(card) => (
        <MetricCard {...card.props} />
      )}
      // You decide what empty slots look like
      renderEmptySlot={(index) => (
        <div className="empty-slot">
          <button onClick={() => addCard(index)}>+ Add Card</button>
        </div>
      )}
    />
  );
}

Key benefit

The library never needs to know about your specific card variants, data shapes, or rendering requirements. You get layout, pagination, and drag-and-drop behavior for free while retaining full control over what appears in each slot.

4. Slot Props

Slot props accept a ReactNode that the component renders in a designated position. Unlike render props (which are functions), slot props are pre-rendered elements. This pattern is used for dependency inversion — the component declares a visual slot, and you inject any component you want.

The canonical example is ChartControlBar's datePickerSlot. The control bar needs a date picker in its top-right corner, but it does not import or know about HybridDatePicker. Instead, you pass it in.

import { ChartControlBar } from '@so.boss/gloss-ui';
import { HybridDatePicker } from '@so.boss/gloss-ui';

function ChartControls() {
  return (
    <ChartControlBar
      vizState={vizState}
      onTypeChange={setType}
      onQuantChange={setQuant}
      // Inject any date picker into the slot
      datePickerSlot={
        <HybridDatePicker
          value={dateRange}
          onChange={setDateRange}
          presets={presets}
        />
      }
    />
  );
}

// Or inject a completely different component:
<ChartControlBar
  {...props}
  datePickerSlot={<SimpleDateInput value={date} onChange={setDate} />}
/>

Key benefit

Components stay decoupled from each other. ChartControlBar works with any date picker — or no date picker at all. This makes components independently testable and allows you to swap implementations without modifying library code.

5. Co-located Types, Fixtures, and Hooks

Every component family follows a consistent directory structure. Types, fixtures, and hooks live alongside the component they belong to — not in shared directories. This communicates ownership and prevents inappropriate reuse of implementation details.

MetricCard/
  types.ts                 -- Type contracts (interfaces, unions)
  MetricCard.tsx           -- Main component
  CardShell.tsx            -- Sub-component (shared wrapper)
  TrendIndicator.tsx       -- Sub-component
  GutterActions.tsx        -- Sub-component
  useSlideOutReveal.ts     -- Co-located hook (used only by GutterActions)
  utils/
    formatValue.ts         -- Pure utility function
    formatTrend.ts         -- Pure utility function
  visualizations/
    SparklineBg.tsx        -- SVG visualization
    BarsBg.tsx             -- SVG visualization
    DonutChart.tsx         -- SVG visualization
  fixtures/
    allVariants.ts         -- Sample data for all 9 variants
    states.ts              -- Loading, error, stale state samples
  index.ts                 -- Family barrel (re-exports components + types)

Key benefit

Everything related to a component family is in one place. Types are exported from the family barrel alongside components. Fixtures provide demo data without external dependencies. Hooks that are implementation details stay co-located rather than polluting a shared hooks directory.

6. BEM SCSS with gloss- Prefix

All CSS classes follow the BEM naming convention with a gloss- prefix. This ensures component styles never collide with application styles, even without CSS Modules or CSS-in-JS.

SCSS files use design tokens from @so.boss/gloss-styles via @use "tokens" as *. Variable references like $color-gray-900 and $space-4 keep values consistent across the library.

// Component SCSS follows BEM with gloss- prefix
.gloss-metric-card {
  position: relative;
  overflow: hidden;

  // Element
  &__header {
    display: flex;
    align-items: center;
    gap: $space-2;
  }

  // Modifier (variant)
  &--sparkline {
    .gloss-metric-card__bg {
      opacity: 0.6;
    }
  }

  // Modifier (state)
  &--stale {
    .gloss-metric-card__value {
      filter: blur(4px);
      transition: filter 300ms ease;
    }
  }
}

Key benefit

No CSS Modules, no styled-components, no runtime CSS-in-JS overhead. The gloss- prefix guarantees isolation. BEM makes the relationship between blocks, elements, and modifiers explicit and predictable.

7. CSS Variable Fallbacks

Components reference design tokens via var(--token-name, fallback-value). The fallback ensures components render correctly even if the consumer has not loaded the token CSS file. This enables gradual adoption and standalone usage.

The dashboard bridge file (_dashboard-bridge.scss) maps CSS custom properties to SCSS token values. Consumers can override variables at the :root level or use the bridge for standard theming.

// Inside a component's SCSS
.gloss-card-shell {
  background: var(--color-surface-primary, #ffffff);
  color: var(--color-text-primary, #374151);
  box-shadow: var(--shadow-card, 0 1px 3px rgba(0, 0, 0, 0.08));
  border-radius: var(--radius-lg, 12px);
}

// Consumer can override at :root level
:root {
  --color-surface-primary: #1a1a2e;
  --color-text-primary: #e0e0e0;
  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3);
}

// Or scope overrides to a container
.dark-theme {
  --color-surface-primary: #1a1a2e;
  --color-text-primary: #e0e0e0;
}

Key benefit

Components work out of the box without any CSS setup. The fallback values provide a sensible default theme. When you are ready to customize, override CSS variables at the scope you need — globally, per-section, or per-component instance.

8. Container/Presenter Split

Gloss components are the presenters. They handle rendering, layout, and visual interaction (hover, animation, scroll). They do not fetch data, read from context, access localStorage, or manage application state.

Your application provides the containers — components that connect data sources (API calls, context, stores) to the presenter's props. This separation keeps the library portable and testable.

// Mental model:
// Container (your app)  -->  props  -->  Presenter (@so.boss/gloss-ui)

// Your container: owns state, fetches data, handles side effects
function MetricCardContainer({ cardId }: { cardId: string }) {
  const { data, isLoading } = useMetricsQuery(cardId);
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <CardShell
      colSpan={isExpanded ? 2 : 1}
      onExpand={() => setIsExpanded(true)}
      onShrink={() => setIsExpanded(false)}
    >
      <MetricCard
        variant="sparkline"
        label={data?.label ?? ''}
        value={data?.value}
        sparklineData={data?.trend ?? []}
        isLoading={isLoading}
      />
    </CardShell>
  );
}

// What @so.boss/gloss-ui NEVER does:
// - useContext(AppContext)      -- no context consumption
// - localStorage.getItem(...)  -- no storage access
// - fetch('/api/...')           -- no data fetching
// - useRouter()                -- no routing

Key benefit

Library components are environment-agnostic. They work in any React app, any test runner, any Storybook setup — no providers, no mocking, no configuration. Your containers are the only place where app-specific dependencies appear.