medOS ultra

Widget Rail Surface System

macOS-Dock-style WidgetRail + opt-in widgetSurface export pattern for miniapps alongside the patient profile.

6 min read diagramsUpdated 2026-05-15docs/architecture/widget-rail-surface-system.md

macOS-Dock-style configurable sidebar for patient profiles — lets nurses track active orders, lab results, and other departmental signals at a glance without switching tabs.

Concept

The Widget Rail extends the existing miniapp pattern with an opt-in widget surface — a small, glanceable representation of a miniapp that renders in a narrow icon dock alongside the patient profile. Clicking an icon opens a 320px flyout panel; clicking “Open full tab” promotes it to the full miniapp tab (the existing DynamicContentRenderer slot).

┌─────────────────────────────────────────────────┬────┐
│                                                 │ ▦  │ ← Widget Extensions header
│   Patient Profile Content                       │────│
│   (tabs rendered via DynamicContentRenderer)     │ 📋 │ ← OrderActivityDock icon (badge: 5)
│                                                 │ 🧪 │ ← LabResults icon (badge: 2)
│                                                 │    │
│                                                 │    │
│                                                 │    │
└─────────────────────────────────────────────────┴────┘
                                                  52px

When an icon is clicked:

┌──────────────────────────────────┬──────────┬────┐
│                                  │ ↗ ╳      │    │
│   Patient Profile Content        │──────────│ ▦  │
│                                  │ Flyout   │────│
│                                  │ content  │ 📋 │ ← active (highlighted)
│                                  │ 320px    │ 🧪 │
│                                  │          │    │
│                                  │          │    │
└──────────────────────────────────┴──────────┴────┘

Architecture

Three Layers

Layer Location Responsibility
WidgetRail (ui-kit) web/packages/ui-kit/src/components/widget-rail/ Generic dock + flyout chrome. No domain knowledge.
Widget surfaces (miniapps) Each miniapp’s index.tswidgetSurface export Domain-specific compact widget UI.
Host (patient profile) Consumer component that renders `` Provides encounter/patient context, wires onOpenTab.

File Inventory

File Purpose
web/packages/ui-kit/src/components/widget-rail/types.ts WidgetSurface, WidgetSurfaceContext, WidgetRailConfig, WidgetRailProps
web/packages/ui-kit/src/components/widget-rail/WidgetRail.tsx Dock + flyout component (~240 lines)
web/packages/ui-kit/src/components/widget-rail/index.ts Barrel exports
web/packages/miniapps/order-activity-dock/types.ts OrderType, OrderStatus, ActiveOrder, ORDER_TYPE_META, STATUS_META
web/packages/miniapps/order-activity-dock/OrderActivityDock.tsx Active orders widget — grouped by dept type, status chips, progress bar
web/packages/miniapps/order-activity-dock/index.ts Barrel + widgetSurface export
web/packages/miniapps/laboratory-results/LabResultsWidget.tsx Lab results widget — pending/completed panels, specimen stage dots
web/packages/miniapps/laboratory-results/index.ts Updated — now exports widgetSurface
web/sandbox/targets/WidgetRailTarget.tsx Sandbox demo with mock patient profile

WidgetSurface Contract

Any miniapp can opt in by exporting a widgetSurface object from its barrel file. The type:

interface WidgetSurface {
  id: string;                    // unique widget identifier
  labelTh: string;               // Thai display name
  labelEn: string;               // English display name
  Icon: FC;                      // icon component (rendered in dock)
  priority?: number;             // sort order (lower = higher, default 50)
  defaultEnabled?: boolean;      // shown by default? (default true)
  badge?: FC<WidgetSurfaceContext>;  // optional dynamic badge component
  Widget: FC<WidgetSurfaceContext & { onClose; onOpenTab? }>;  // flyout content
}

Important: Icon must be an FC, not ReactNode

The Icon field is a component (FC), not a pre-rendered element (ReactNode). This avoids the sandbox dual-React issue where MUI icons created at module scope via React.createElement() bind to the wrong React copy in Vite’s pre-bundled dependency graph.

// CORRECT — icon renders inside the React tree
const MyIcon: React.FC = () =>
  React.createElement(ScienceIcon, { sx: { fontSize: 20 } });

// WRONG — creates element at module scope, dual-React in sandbox
icon: React.createElement(ScienceIcon, { sx: { fontSize: 20 } })

In .ts barrel files (not .tsx), use React.createElement inside the component body. In .tsx files, JSX is fine.

Lazy Loading

The Widget field should use React.lazy() so flyout content is only loaded when the user clicks the icon:

Widget: React.lazy(() => import('./LabResultsWidget')),

The WidgetRail wraps each flyout’s content area in its own `` boundary with a spinner fallback, so lazy widgets don’t unmount the entire rail.

WidgetRail Configuration

The host component passes a config prop:

interface WidgetRailConfig {
  position?: 'right' | 'left';     // which edge (default: right)
  collapsedWidth?: number;          // icon dock width (default: 52)
  expandedWidth?: number;           // flyout panel width (default: 320)
  enabledWidgets?: string[];        // whitelist by id (default: all with defaultEnabled)
  autoCollapse?: boolean;           // reserved for future use
}

Data Flow

OrderActivityDock

Currently uses sample data in sandbox mode. In production, it will:

  1. Subscribe to department_queues via Supabase realtime, filtering by encounter_id for the current patient.
  2. Group rows by dept_type, map status to OrderStatus.
  3. Show active count badge on the dock icon.
department_queues (Supabase realtime)
  → filter by encounter_id
  → group by dept_type
  → OrderActivityDock renders grouped list

LabResultsWidget

Uses SAMPLE_PANELS from the laboratory-results miniapp. In production:

  1. Query encounter_journey_cache.clinical_context.specimen_pipeline for specimen stage data.
  2. Query lab panels from the diagnostic service API.
  3. Show count of unverified/critical panels as badge.

Adding a New Widget

  1. Create the widget component in your miniapp package:

    web/packages/miniapps/my-module/MyModuleWidget.tsx
    

    Must accept WidgetSurfaceContext & { onClose; onOpenTab? } props.

  2. Export widgetSurface from your miniapp’s index.ts:

    import React from 'react';
    import MyIcon from '@mui/icons-material/MyIcon';
    import type { WidgetSurface } from '@ui-kit/components/widget-rail';
    
    const Icon: React.FC = () =>
      React.createElement(MyIcon, { sx: { fontSize: 20 } });
    
    export const widgetSurface: WidgetSurface = {
      id: 'my-module',
      labelTh: 'ชื่อภาษาไทย',
      labelEn: 'English Name',
      Icon,
      priority: 30,
      Widget: React.lazy(() => import('./MyModuleWidget')),
    };
    
  3. Register in the host — the consumer component (patient profile) collects all widgetSurface exports and passes them to ``

  4. Test in sandbox — add your widget surface to web/sandbox/targets/WidgetRailTarget.tsx:

    import { widgetSurface as myModuleSurface } from '@miniapps/my-module';
    const WIDGETS = [orderDockSurface, labResultsSurface, myModuleSurface];
    

Existing Widgets

Widget id Priority Badge Source
Active Orders order-activity-dock 10 Active order count (orange) @miniapps/order-activity-dock
Lab Results laboratory-results 20 Critical/pending count (red) @miniapps/laboratory-results

Candidate Future Widgets

Module Widget idea
Pharmacy Pending prescriptions + dispensing status
Imaging Study status (ordered → scheduled → acquired → reported)
Blood Bank Crossmatch status + reservation timer
Nursing Pending nursing tasks / assessments due
Vital Signs Latest vitals + CDS alert count
Acknowledgements Unread ack requests for this patient

Integration with Patient Profile

The WidgetRail is designed to sit alongside the patient profile’s tab content area. The host layout uses a flex row:

<Box sx={{ display: 'flex', height: '100%' }}>
  {/* Main tab content */}
  <Box sx={{ flex: 1, overflow: 'auto' }}>
    <DynamicContentRenderer ... />
  </Box>

  {/* Widget rail on the right edge */}
  <Suspense fallback={<Box sx={{ width: 52 }} />}>
    <WidgetRail
      encounterId={encounter.id}
      patientId={patient.id}
      locale={locale}
      widgets={registeredWidgets}
      config={{ position: 'right' }}
      onOpenTab={(moduleId) => openTab(moduleId)}
    />
  </Suspense>
</Box>

The onOpenTab callback lets the widget request promotion to a full miniapp tab — the host maps the widget id to the corresponding DynamicCoreApp module and opens it.

Design Decisions

  1. Opt-in, not opt-out — miniapps must explicitly export widgetSurface. No auto-discovery or convention-based registration.

  2. Icon is FC, not ReactNode — prevents the Vite sandbox dual-React useContext crash that occurs when MUI icons are instantiated at module-import time.

  3. Lazy Widget loading — flyout content is React.lazy with per-widget Suspense boundary inside the rail, so loading one widget doesn’t unmount/flash the entire dock.

  4. No direct data writes — widgets are read-only surfaces. Actions route through the full miniapp tab or backend APIs.

  5. Bilingual throughoutlabelTh + labelEn on every surface, consistent with the project’s i18n pattern.

  6. Configurable per deploymentenabledWidgets in WidgetRailConfig lets each hospital/region control which widgets appear.

Ask Anything