Consumer UI Kit (CUI Kit)

The Consumer UI Kit provides a complete, pre-built solution for building select and pour interfaces. Built on the DeviceAssembly → Nozzle → Availability foundation, CUI Kit handles the complexity of brand/beverage/flavor navigation, selection tracking, timer management, and pour integration.

What CUI Kit Provides

CUI Kit delivers a complete selection workflow out of the box:

  • Brand → Beverage → Flavor navigation – Complete drill-down workflow with state management
  • Selection tracking – Synchronized local and backend selection state
  • Timer lifecycle management – Automatic ambient/attract mode timer coordination
  • Pour integration – Validation hooks and context management
  • EventBus integration – Analytics events for all user interactions
  • Companion model support – Seamless integration with custom companion models
  • Type safety – Full TypeScript support with generic data types

The useCuiKit Hook

The useCuiKit hook is the entry point to the CUI Kit system. It requires a NozzleProvider parent and returns a comprehensive kit object with all selection state and handlers.

Basic Setup

import { useCuiKit } from '@kosdev-code/kos-dispense-sdk';
function SelectAndPourScreen() {
  const kit = useCuiKit({
    initialGroup: 'brands', // or 'beverages'
  });
  // kit now contains navigation, selections, group handlers, pour, and timers
}

Type Parameters

CUI Kit supports generic type parameters for custom data shapes:

interface BrandData {
  logoUrl: string;
  tagline: string;
}
interface BeverageData {
  nutritionUrl: string;
  calories: number;
}
interface FlavorData {
  colorCode: string;
}
const kit = useCuiKit<BrandData, BeverageData, FlavorData>({
  initialGroup: 'brands'
});
// kit.brands.models are typed as AvailabilityModel<BrandData>[]
// kit.beverages.models are typed as AvailabilityModel<BeverageData>[]
// kit.flavors.models are typed as AvailabilityModel<FlavorData>[]

Companion Model Integration

If you’ve created companion models for brands, beverages, or flavors, configure them in the options:

const kit = useCuiKit<BrandData, BeverageData, FlavorData,
  BrandCompanionModel, BeverageCompanionModel, FlavorCompanionModel>({
  initialGroup: 'brands',
  options: {
    companionTypes: {
      brand: 'BrandCompanion',
      beverage: 'BeverageCompanion',
      flavor: 'FlavorCompanion'
    }
  }
});
// Access companions alongside models
kit.brands.companions // BrandCompanionModel[]
kit.beverages.companions // BeverageCompanionModel[]
kit.flavors.companions // FlavorCompanionModel[]

Navigation State (kit.nav)

CUI Kit manages navigation between brand, beverage, and flavor groups.

Navigation Properties

kit.nav.currentGroup  // Current group: 'brands' | 'beverages' | 'flavors'

Navigation Methods

// Navigate back to previous level
kit.nav.back()
// Return to initial group (clears selections)
kit.nav.home()

Navigation Behavior

  • back() – Moves to previous level, clearing selections appropriately
    • From brands → clears brand and beverage selections
    • From beverages or flavors → returns to initialGroup, clears selections
  • home() – Returns to initialGroup, publishes home event
    • Clears selections based on initialGroup
    • Publishes /kos/dispense/{nozzlePath}/home/ topic

Selection State (kit.selections)

Access current selections across all levels:

// Currently selected brand (AvailabilityModel or null)
kit.selections.brands
// Currently selected beverage (AvailabilityModel or null)
kit.selections.beverages
// Currently selected flavors (AvailabilityModel[] or null)
kit.selections.flavors

These selections reflect both local state and backend selection state in the NozzleModel.

Group Objects

CUI Kit provides three group objects (kit.brands, kit.beverages, kit.flavors) with identical structure:

Group Structure

{
  // Array of AvailabilityModels for display
  models: AvailabilityModel<T>[]
  // Companion models (if configured)
  companions: CompanionModel[]
  // Clear selection at this level
  clear: () => void
  // Selection handler
  handlers: {
    onSelected: (model: AvailabilityModel<T>) => void
  }
}

Brand Group (kit.brands)

// Display all visible brands
kit.brands.models.map(brand => (
  <BrandCard
    key={brand.id}
    brand={brand}
    onClick={() => kit.brands.handlers.onSelected(brand)}
  />
))
// Access brand companions
kit.brands.companions // BrandCompanionModel[]
// Clear brand selection (also clears beverage)
kit.brands.clear()

Behavior: Selecting a brand updates kit.selections.brands, publishes selection event, and navigates to flavor group.

Beverage Group (kit.beverages)

// Display beverages (filtered by brand if selected)
kit.beverages.models.map(beverage => (
  <BeverageCard
    key={beverage.id}
    beverage={beverage}
    onClick={() => kit.beverages.handlers.onSelected(beverage)}
  />
))
// Access beverage companions
kit.beverages.companions // BeverageCompanionModel[]
// Clear beverage selection
kit.beverages.clear()

Behavior:

  • If a brand is selected, shows beverages for that brand
  • If no brand exists (beverage-only brandset), shows all beverages
  • Selecting updates kit.selections.beverages, publishes event, navigates to flavors

Flavor Group (kit.flavors)

// Display all flavors (multi-select)
kit.flavors.models.map(flavor => (
  <FlavorButton
    key={flavor.id}
    flavor={flavor}
    selected={kit.selections.flavors?.includes(flavor)}
    onClick={() => kit.flavors.handlers.onSelected(flavor)}
  />
))
// Access flavor companions
kit.flavors.companions // FlavorCompanionModel[]
// Clear all flavor selections
kit.flavors.clear()

Behavior: Flavors support multi-select. Calling onSelected toggles the flavor in the selection array and updates backend state.

Pour Integration (kit.pour)

CUI Kit integrates with useNozzlePour to provide complete pour functionality.

Pour Status

kit.pour.status.canPour        // boolean - ready to pour
kit.pour.status.isPouring      // boolean - currently pouring
kit.pour.status.currentState   // PourState enum
kit.pour.status.pourProgress   // 0-100 progress from future

Pour Operations

// Execute pour (uses selected pourable from NozzleModel)
kit.pour.handlers() // spread onto button element
// Force cancel active pour
kit.pour.cancel()

Pour Validation Hook

Add custom validation before pours execute:

kit.updateOnBeforePour(async (context) => {
  // Check payment, user authentication, etc.
  const isValid = await validatePayment();
  if (!isValid) {
    showPaymentError();
    return false; // Reject pour
  }
  return true; // Allow pour
});

Behavior: If validation returns false, pour is cancelled and /kos/dispense/{nozzlePath}/pre/pour/rejected/ event is published.

Pour Context

Add custom data to pour operations for analytics, payment tracking, or external integrations:

// Add context data
kit.pour.updatePourContext('paymentId', '12345');
kit.pour.updatePourContext('userId', 'user-abc');
// Remove context data
kit.pour.removeFromPourContext('paymentId');

This context is passed through the entire pour lifecycle and available in validation callbacks.

Pour Usage Pattern

<button
  {...kit.pour.handlers()}
  disabled={!kit.pour.status.canPour}
>
  {kit.pour.status.isPouring
    ? `Pouring ${kit.pour.status.pourProgress}%`
    : 'Pour Beverage'
  }
</button>

Timer Management (kit.timers)

CUI Kit provides lifecycle management for ambient/attract mode timers.

Timer Registration

import { KosTimer } from '@kosdev-code/kos-ui-sdk';
// Create timer
const attractTimer = new KosTimer({
  duration: 30000, // 30 seconds
  onComplete: () => navigateToAttract()
});
// Register with CUI Kit
kit.timers.add(attractTimer);

Timer Operations

// Pause all registered timers
kit.timers.pause()
// Restart all registered timers
kit.timers.restart()

Use Case: Pause timers during pour operations, restart when pour completes. CUI Kit tracks timers but doesn’t automatically pause/restart – your application controls timing.

EventBus Integration

CUI Kit publishes events for all user interactions, enabling analytics, logging, and external system integration.

Using useDispenseLifecycle Hook (Recommended for React)

For React components, use the useDispenseLifecycle hook to listen to CUI Kit events:

import { useDispenseLifecycle } from '@kosdev-code/kos-dispense-sdk';
function AnalyticsTracker() {
  useDispenseLifecycle({
    onBrandSelected: (brand) => {
      analytics.track('brand_selected', { brandId: brand.id });
    },
    onBeverageSelected: (beverage) => {
      analytics.track('beverage_selected', { beverageId: beverage.id });
    },
    onFlavorSelected: (flavor) => {
      analytics.track('flavor_selected', { flavorId: flavor.id });
    },
    onPour: () => {
      analytics.track('pour_started');
    },
    onPourCancel: () => {
      analytics.track('pour_cancelled');
    },
    onReturnToHome: () => {
      analytics.track('returned_home');
    }
  });
  return null; // Analytics component doesn't render UI
}

Available Lifecycle Callbacks

The useDispenseLifecycle hook provides callbacks for all CUI Kit events:

interface UseDispenseLifecycleProps {
  // Selection events
  onBrandSelected?: (brand: AvailabilityModel) => void
  onBrandCleared?: () => void
  onBeverageSelected?: (beverage: AvailabilityModel) => void
  onBeverageCleared?: () => void
  onBeverageListUpdated?: (beverages: AvailabilityModel[]) => void
  onFlavorSelected?: (flavor: AvailabilityModel) => void
  onFlavorCleared?: () => void
  // Pour events
  onPour?: () => void
  onPourCancel?: () => void
  onBeforePourRejected?: () => void
  // Navigation events
  onReturnToHome?: () => void
  onInit?: () => void
  // Timer events
  onAmbientTimer?: () => void
  onAttractTimer?: () => void
  onResumeAttractTimer?: () => void
  onIdleTimeout?: () => void
  onAreYouThereTimeout?: () => void
}

Published Topics

CUI Kit publishes to EventBus topics using the pattern /kos/dispense/{nozzlePath}/[event]/:

// Selection events
/kos/dispense/{nozzlePath}/brand/selected/
/kos/dispense/{nozzlePath}/brand/cleared/
/kos/dispense/{nozzlePath}/beverage/selected/
/kos/dispense/{nozzlePath}/beverage/cleared/
/kos/dispense/{nozzlePath}/beverage/list/updated/
/kos/dispense/{nozzlePath}/flavor/selected/
/kos/dispense/{nozzlePath}/flavor/cleared/
// Pour events
/kos/dispense/{nozzlePath}/pour/
/kos/dispense/{nozzlePath}/pour/cancel/
/kos/dispense/{nozzlePath}/pre/pour/rejected/
// Navigation events
/kos/dispense/{nozzlePath}/home/
/kos/dispense/{nozzlePath}/init/
// Timer events
/kos/dispense/{nozzlePath}/ambient/start/
/kos/dispense/{nozzlePath}/attract/start/
/kos/dispense/{nozzlePath}/attract/resume/
/kos/dispense/{nozzlePath}/idle/timeout/
/kos/dispense/{nozzlePath}/are-you-there/timeout/

Beverage List Updates

The onBeverageListUpdated callback fires whenever the visible beverage list changes, providing the updated array of beverages for UI animations, analytics, or external system synchronization.

import { useDispenseLifecycle } from '@kosdev-code/kos-dispense-sdk';
function BeverageAnalytics() {
  useDispenseLifecycle({
    onBeverageListUpdated: (beverages) => {
      // Fires when beverage list changes due to filtering or brand selection
      analytics.track('beverage_list_updated', {
        count: beverages.length,
        beverageIds: beverages.map(b => b.id)
      });
    }
  });
  return null;
}

When this callback fires:

  • After brand selection (beverages filtered by selected brand)
  • When clearing brand selection (all beverages shown)
  • Any time the visible beverage array changes

Common use cases:

1. Transition Animations

Trigger CSS transitions when the beverage list updates:

function BeverageGrid() {
  const [isTransitioning, setIsTransitioning] = useState(false);
  useDispenseLifecycle({
    onBeverageListUpdated: () => {
      setIsTransitioning(true);
      setTimeout(() => setIsTransitioning(false), 300);
    }
  });
  return (
    <div className={`beverage-grid ${isTransitioning ? 'fade-transition' : ''}`}>
      {/* Grid items */}
    </div>
  );
}

2. Accessibility Announcements

Notify screen reader users when the list changes:

function AccessibleBeverageList() {
  const [announcement, setAnnouncement] = useState('');
  useDispenseLifecycle({
    onBeverageListUpdated: (beverages) => {
      const count = beverages.length;
      setAnnouncement(`${count} beverage${count !== 1 ? 's' : ''} available`);
    }
  });
  return (
    <div>
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {announcement}
      </div>
      {/* Beverage list UI */}
    </div>
  );
}

3. Auto-Selecting Default Beverage

A common pattern when brand filtering occurs is to automatically select a default beverage:

function BeverageSelectionScreen() {
  const kit = useCuiKit({ nozzlePath });
  useDispenseLifecycle({
    onBeverageListUpdated: (beverages) => {
      // Auto-select when brand filtering leaves one beverage
      if (beverages.length === 1) {
        kit.beverages.handlers.onSelected(beverages[0]);
      }
      // Or apply your application's default selection logic
      else {
        const defaultBev = beverages.find(b => b.data.isRecommended);
        if (defaultBev) {
          kit.beverages.handlers.onSelected(defaultBev);
        }
      }
    }
  });
  return <BeverageGrid />;
}

This pattern allows applications to implement custom default selection logic based on business requirements while keeping the policy in application code rather than the CUI Kit.

4. External Display Synchronization

Update secondary displays or external systems:

useDispenseLifecycle({
  onBeverageListUpdated: (beverages) => {
    // Sync with external LED display
    externalDisplay.setBeverageCount(beverages.length);
    // Update companion mobile app
    mobileApp.notifyBeverageListChanged(beverages.map(b => ({
      id: b.id,
      name: b.data.name,
      available: b.available
    })));
  }
});

This callback provides fine-grained notification of list changes, complementing the selection-focused callbacks (onBeverageSelected, onBeverageCleared) with list-level updates.

Listening in Models (Alternative)

When working with models, use @kosTopicHandler decorators:

import { kosTopicHandler } from '@kosdev-code/kos-ui-sdk';
class AnalyticsModel {
  @kosTopicHandler({ topic: '/kos/dispense/+/beverage/selected/' })
  handleBeverageSelected(message) {
    this.analytics.track('beverage_selected', message);
  }
}

Complete Example: Select and Pour UI

Here’s a complete select and pour interface using CUI Kit:

import {
  DeviceAssemblyProvider,
  NozzleProvider,
  useCuiKit
} from '@kosdev-code/kos-dispense-sdk';
// Provider hierarchy setup
function App() {
  return (
    <DeviceAssemblyProvider>
      <NozzleProvider nozzlePath="assembly:core:nozzle:nozzle">
        <SelectAndPour />
      </NozzleProvider>
    </DeviceAssemblyProvider>
  );
}
// Select and pour interface component
function SelectAndPour() {
  const kit = useCuiKit<BrandData, BeverageData, FlavorData>({
    initialGroup: 'brands'
  });
  // Render based on current navigation group
  return (
    <div>
      <NavigationHeader
        currentGroup={kit.nav.currentGroup}
        onBack={kit.nav.back}
        onHome={kit.nav.home}
      />
      {kit.nav.currentGroup === 'brands' && (
        <BrandGrid brands={kit.brands.models} onSelect={kit.brands.handlers.onSelected} />
      )}
      {kit.nav.currentGroup === 'beverages' && (
        <BeverageGrid beverages={kit.beverages.models} onSelect={kit.beverages.handlers.onSelected} />
      )}
      {kit.nav.currentGroup === 'flavors' && (
        <FlavorSelection
          flavors={kit.flavors.models}
          selected={kit.selections.flavors ?? []}
          onToggle={kit.flavors.handlers.onSelected}
        />
      )}
      <SelectionSummary selections={kit.selections} />
      <button
        {...kit.pour.handlers()}
        disabled={!kit.pour.status?.canPour}
      >
        {kit.pour.status?.isPouring
          ? `Pouring ${kit.pour.status.pourProgress}%`
          : 'Pour Beverage'
        }
      </button>
    </div>
  );
}
// Display components
function BrandGrid({ brands, onSelect }) {
  return (
    <div className="brand-grid">
      {brands.map(brand => (
        <div key={brand.id} onClick={() => onSelect(brand)}>
          <img src={brand.data.logoUrl} alt={brand.data.name} />
          <span>{brand.data.name}</span>
        </div>
      ))}
    </div>
  );
}
function BeverageGrid({ beverages, onSelect }) {
  return (
    <div className="beverage-grid">
      {beverages.map(beverage => (
        <div
          key={beverage.id}
          onClick={() => onSelect(beverage)}
          className={beverage.available ? '' : 'unavailable'}
        >
          <span>{beverage.data.name}</span>
          {!beverage.available && <span>Unavailable</span>}
        </div>
      ))}
    </div>
  );
}
function FlavorSelection({ flavors, selected, onToggle }) {
  return (
    <div className="flavor-selection">
      {flavors.map(flavor => (
        <button
          key={flavor.id}
          onClick={() => onToggle(flavor)}
          className={selected.includes(flavor) ? 'selected' : ''}
        >
          {flavor.data.name}
        </button>
      ))}
    </div>
  );
}
function SelectionSummary({ selections }) {
  return (
    <div className="summary">
      {selections.brands && <div>Brand: {selections.brands.data.name}</div>}
      {selections.beverages && <div>Beverage: {selections.beverages.data.name}</div>}
      {selections.flavors?.length > 0 && (
        <div>Flavors: {selections.flavors.map(f => f.data.name).join(', ')}</div>
      )}
    </div>
  );
}

Advanced Pattern: Timer Integration

Integrate ambient mode timers with CUI Kit lifecycle:

function SelectAndPourWithTimers() {
  const kit = useCuiKit({ initialGroup: 'brands' });
  const navigate = useNavigate();
  useEffect(() => {
    // Create ambient timer
    const ambientTimer = new KosTimer({
      duration: 30000,
      onComplete: () => {
        kit.nav.home();
        navigate('/attract');
      }
    });
    // Register with CUI Kit
    kit.timers.add(ambientTimer);
    // Pause timer during pours
    if (kit.pour.status?.isPouring) {
      kit.timers.pause();
    } else {
      kit.timers.restart();
    }
    return () => {
      ambientTimer.destroy();
    };
  }, [kit.pour.status?.isPouring]);
  // Rest of component...
}

Benefits Over Manual Implementation

Building selection workflows manually requires:

  1. State management – Tracking brands, beverages, flavors, and navigation state
  2. Backend synchronization – Calling nozzle.setSelectedBrand() and nozzle.setSelectedPourable() at the right times
  3. Navigation logic – Managing transitions between groups
  4. Event publishing – Publishing analytics events for tracking
  5. Pour integration – Connecting selection state to pour operations
  6. Timer coordination – Managing timer lifecycles across interactions

CUI Kit handles all of this automatically, letting you focus on UI rendering and user experience.

Next Steps


API Validation: All APIs verified against use-cui-kit.ts:1-475, use-brands.ts:1-67, use-beverages.ts:1-76, use-flavors.ts:1-79, use-nozzle-pour.ts:1-142, use-dispense-lifecycle.ts:1-148