Skip to main content

🎨 UI Layer

UI primitives, semantic colors, responsive patterns, and the dashboard tile system.

UI Kit Inventory (shared/ui/)​

Reusable primitives. Always check here before creating a new component.

ComponentPurpose
AnimatedOutletReact Router outlet with route transitions
ButtonPrimary / secondary / ghost variants
CardSurface container with shadow + border
CollapsibleSectionExpandable section with chevron
DataViewGeneric data list/grid wrapper
DrawerRight slide on desktop, bottom sheet on mobile
InputText input with label and error states
LanguageSelectori18next locale dropdown
LogoSvaroh logo
MiniSelectCompact select for inline use
ModalNear-fullscreen on mobile, max-w-lg on desktop
PaginationPage navigation
RouteTabsTab nav backed by router
SelectStandard select
SliderRange slider
StatusBadgeOnline/offline/error status pill
StickyHeaderSticky positioned section header
SvarohSpinnerBranded loading spinner
SwitchOn/off toggle
ThemeToggleLight / dark mode switch
TruncatedTextText with ellipsis + tooltip on overflow
ViewModeToggleList / grid view selector

Source β†—

Semantic Colors​

Defined in colors.css β†— as CSS custom properties via Tailwind 4's @theme {}. Light/dark variants toggle on .dark class on root.

Semantic Tokens​

--color-bg-base /* page background */
--color-bg-surface /* card/panel background */
--color-text-primary /* main text */
--color-text-secondary /* muted text */
--color-border-base /* borders */

In Tailwind: bg-bg-base, text-text-primary, border-border-base.

Named Colors​

text-honolulu-blue, bg-ghost-white, text-spanish-gray, etc. β€” used as accent colors.

Hard rule​

No hardcoded #hex or rgb() β€” only semantic tokens or named colors. Apply via Tailwind classes. No inline style={{}} for color.

cn() Helper​

import { cn } from '@/shared/ui/cn';

<div className={cn('px-4 py-2', isActive && 'bg-bg-surface text-text-primary')} />

cn() = twMerge(clsx(inputs)) β€” handles conditional classes and merges conflicting Tailwind utilities.

Responsive Patterns​

Breakpoint: md (768px). Mobile-first β€” base styles are mobile, md: for desktop.

  • Desktop (md+): sidebar (hidden md:block) β€” Dashboard, Home, Devices, collapsible "Advanced"
  • Mobile (< md): bottom tab bar (md:hidden) β€” Dashboard, Devices, Home, More
  • /more route β€” secondary nav (Automations, User Management, Settings, Station)
  • Desktop: breadcrumbs left, avatar + WS status right
  • Mobile: logo or back arrow left (via getParentRoute()), avatar + WS status right

Layout Conventions​

ElementMobileDesktop (md+)
Page paddingp-4 pb-20 (clears tab bar)p-6
Card paddingp-4p-6
Headingstext-xltext-2xl
Buttons (with text)icon-only + aria-labelicon + text
Touch targetsmin-h-[44px] (Apple HIG)default
Modalmax-w-nonemax-w-lg
Drawerbottom sheetright slide

Pattern for buttons:

<Button>
<PencilIcon />
<span className="hidden md:inline">{t('common.edit')}</span>
</Button>

Safe Areas​

viewport-fit=cover in <meta name="viewport">, .safe-bottom class for env(safe-area-inset-bottom).

Dashboard Tile System​

The dashboard is the entry surface β€” a grid of tiles for the most-used devices and zones.

Hierarchy​

Station β†’ Zone β†’ Tile + flat "Favorites" overlay. Without favorites, the dashboard breaks past ~20 devices. Stations are singletons; zones group rooms; favorites are is_favorite per dashboard_layout_item (per-user, not per-device β€” different users can favorite different devices).

Tile Sizes​

Sizes are an enum tied to capability, not free resize:

SizeGridUsed for
compact1Γ—1Binary toggle (switch, lock, motion sensor)
wide2Γ—1Has value or slider (dimmer, thermostat, climate sensor)
large2Γ—2Preview content (camera, energy chart, weather)

The user picks "collapse / expand" from sizes valid for that device type. Free grid (e.g. react-grid-layout) gives flexibility but home users get lost in it.

Layout Persistence​

Layout is stored on the backend, not localStorage β€” otherwise sync between phone/tablet/web breaks.

CREATE TABLE dashboard_layouts (
user_id uuid PRIMARY KEY,
items jsonb, -- [{deviceId, size, position, isFavorite}]
updated_at timestamptz
);

Read/written atomically as one JSON β€” no per-device normalization needed.

Tile Interactions (3 gesture levels)​

  1. Tap on main area β†’ primary action (toggle light, run scene, open camera live view). Never opens settings.
  2. Tap on inner control (brightness slider, Β±temperature) β†’ secondary action without opening details.
  3. Long-press / tap on β‹― β†’ detail Drawer (all params, history, settings, delete).

Edit Mode​

Edit mode is a separate page state, not always-visible drag handles. "Edit" button in header β†’ tiles get drag handle in corner + slight wobble β†’ "Done" saves layout in one PUT request.

Drag is implemented with @dnd-kit/core + @dnd-kit/sortable:

  • Activation: distance ~8px OR delay 250ms β€” avoids tap conflicts
  • While dragging: scale: 1.05 + shadow-lg + z-50
  • Other tiles rearrange with FLIP animation showing drop zones
  • Auto-scroll near edges (built into dnd-kit)
  • Drop outside valid zone β†’ spring-back to origin

Edit mode is a Redux state in dashboardSlice, not local useState β€” F5 in edit mode shouldn't lose context.

Reading State at a Glance​

  • Background color = on/off (honolulu-blue accent for active, neutral bg-surface for off)
  • Corner indicator: offline (gray X), updating (spinner), error (red dot)
  • Big numeric value (22Β°C, 65%, 1.2 kW) β€” main tile content
  • No "Status: ON" text β€” admin pattern, not home UI

Optimistic Updates​

Mandatory. Tap β†’ reducer mutates state immediately, command goes out via thunk. WS event device_state_changed confirms. If no confirmation in 3s OR error β†’ roll back state + toast.error.

Tile Registry​

Capability β†’ renderer mapping (similar idea to backend's device-type-registry, but for UI):

// components/dashboard/tileRegistry.ts
type TileRenderer = {
sizes: TileSize[]; // allowed sizes
defaultSize: TileSize;
Component: ComponentType<TileProps>;
};

const tileRegistry: Record<DeviceCapability, TileRenderer> = {
switch: { sizes: ['compact', 'wide'], defaultSize: 'compact', Component: SwitchTile },
dimmer: { sizes: ['wide'], defaultSize: 'wide', Component: DimmerTile },
climate: { sizes: ['wide', 'large'], defaultSize: 'wide', Component: ClimateTile },
// ...
};

A new device type β†’ one entry β†’ tile appears automatically.

One Device = One Tile (default)​

A climate sensor with 4 capabilities (temp + humidity + pressure + battery) does not create 4 tiles β€” that turns 30 devices into 100+ tiles. Resolver picks one renderer per device by capability priority:

const resolveTileRenderer = (device: Device): TileRenderer => {
const caps = new Set(device.capabilities);

// Richer capability overrides simpler ones
if (caps.has('dimmer')) return tileRegistry.dimmer; // dimmer subsumes switch
if (caps.has('thermostat')) return tileRegistry.thermostat;
if (caps.has('temperatureMeasurement') && caps.has('humidityMeasurement')) {
return tileRegistry.climateCombo; // single tile, two values
}
if (caps.has('switch')) return tileRegistry.switch;
return tileRegistry.generic;
};

Per-capability sub-tiles are opt-in via favorites β€” user picks "pin humidity as separate tile" in the Drawer.

i18n​

useTranslation() for components, i18n.t('key') for thunks (outside React):

const { t } = useTranslation();
return <p>{t('zones.delete_confirm', { name })}</p>;
// in actions.ts
import i18n from '@/i18n';
toast.success(i18n.t('devices.updated'));

Locale files: ua (fallback) + en. Both must always have the same keys.

Reference​