Перейти до основного вмісту

🎨 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