Skip to content

SynVirt Frontend Principles

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/FRONTEND_PRINCIPLES.md. Edit at the source, not here.

SynVirt’s operator is an IT admin who runs a small-to-medium shop, not a Linux expert. The dashboard is their daily driver. This document defines the experience rules that every pixel of web-ux must honor. Breaking a rule requires an explicit waiver in the PR description.

  • Reads English (technical English is OK; CLI jargon is not).
  • Knows Windows and occasionally macOS. Does not know Linux internals.
  • Manages 5–50 VMs across 1–4 hosts.
  • Has 2–10 minutes to investigate when something alerts at 2 AM.
  • Uses the product on desktop (primary) and mobile (occasional, 2 AM).
  • Values predictability over power. Does not enjoy surprises.

Never expose raw technical terms in primary UI. Translate:

Raw term UI term
zpool Storage pool
dataset Storage folder
zvol Virtual disk
bridge / vSwitch Network
VLAN ID Network tag
uplink Physical link
libvirt domain Virtual machine
qemu-guest-agent Guest tools
cloud-init First-boot setup
NUMA node CPU group
ashift Block size (advanced)
IOMMU / SR-IOV Hardware isolation (advanced)

Raw terms are allowed in “Advanced” collapsed sections and in tooltips.

On dashboard load, within 5 seconds the operator must know:

  1. Are all VMs healthy? (single status line)
  2. Is storage OK? (single status line)
  3. Is network OK? (single status line)
  4. Are there any alerts requiring action?
  5. When was the last successful backup? (once backups ship)

If the answer to any of these takes more than one glance, the design failed.

Every screen has exactly one primary action. It is the biggest, most colorful button, in the top-right or center-top. Secondary actions are smaller. Tertiary actions live in overflow menus (three-dot icon).

Pages with more than 7 top-level things visible at once need a rethink.

Loading latency tiers:

  • < 100 ms: no indicator
  • 100–500 ms: skeleton placeholder
  • 500 ms – 3 s: spinner with context text (“Loading your VMs…”)
  • 3 s: progress bar, cancelable button

These tiers apply to the FIRST load of a view only. Once a view has data, refreshes follow the stale-while-revalidate discipline (§“Foundation primitives”): the data stays on screen, a subtle indicator marks the refresh, and a failed refresh keeps the stale rows with an error banner — never a spinner over content, never a blank.

Saving:

  • Optimistic UI where safe (toggling a light setting).
  • Pessimistic with spinner where not (starting a VM, changing network).
  • Success confirmation is a toast that auto-dismisses at 4s with a “View details” link for audit.
  • Failures show a banner that stays until the user dismisses or retries.

Errors:

  • Never display a raw error from libvirt, virsh, or systemctl.
  • Map every known error to a human sentence + suggested action.
  • Unknown errors: “Something unexpected happened. [Show details] [Report]”. The [Show details] reveals the raw message for the power user.

Error translation lives in the dashboard’s API error map (crates/web-ux-v2/src/api/errors.ts — the ApiError / ERROR_MAP surface); legacy web-ux used assets/error-messages.json. Example:

{
"virsh_not_enough_memory": {
"user_message": "Not enough free RAM on this host to start the VM.",
"suggested_action": "Shut down another VM or add more RAM.",
"severity": "error"
}
}

Destructive = stopping a VM, deleting a VM, wiping a pool, factory reset.

Rules:

  • Confirmation modal with the VM/resource name typed by the user (“Type the VM name to confirm deletion: _______”).
  • Deleted resources go to Trash for 7 days by default. Trash is visible in Settings. Hard delete requires additional confirmation.
  • Undo button on the success toast for 10 seconds after the action.
  • Never show two destructive buttons side by side. Always separate with whitespace or other actions between them.

Never show “No data” or an empty table. Always:

  • A short explanation of what this view is for.
  • A primary CTA to populate the view.
  • Optional secondary CTA to learn more.

Example (empty VM list): “No virtual machines yet. Create your first VM to start hosting services. [Create Ubuntu VM] [Custom VM] [Learn more →]”

  • Validate on blur, not on keystroke.
  • Error message is specific: “Hostname already taken on node-01” rather than “Invalid”.
  • Required fields marked with *. Optional fields in a collapsed “More” section when there are more than 5 total.
  • Smart defaults fill every field. The operator who clicks through with no changes gets a working VM.
  • Persistent top nav: logo (home), VMs, Storage, Network, Settings, user menu (right edge).
  • Breadcrumbs on detail pages: VMs › my-vm-01 › Disks.
  • Back button in every detail screen (top-left), never relies on browser back only.
  • Keyboard: / focuses search, ? opens shortcuts modal, g v goes to VMs, g s goes to Storage (future — not MVP).
  • Minimum supported viewport: 375×667 (iPhone SE).
  • Dashboard home, VM list, VM detail, alerts, logout must be usable.
  • Creating/editing resources on mobile is not required; a message “Create new VMs from a larger screen” is acceptable.
  • All interactive elements reachable by Tab.
  • Visible focus ring (Tailwind focus:ring-2).
  • Contrast AA minimum (Tailwind’s default colors mostly comply).
  • ARIA labels on icon-only buttons.
  • Semantic HTML: <button>, <nav>, <main>, not a soup of <div>.
  • Reduced motion honored (prefers-reduced-motion).
  • Theme is operator-selectable: light / dark / system (tri-state toggle via useTheme/ThemeToggle). Default follows the OS (prefers-color-scheme).
  • Brand primary is #0566C1 (token --color-accent). Cyan #06b6d4 is a secondary accent for highlights, charts, and network visualizations — NEVER as the brand.
  • Palette defined by CSS vars:
    • --bg-base #0b0f17
    • --bg-elevated #131824
    • --text-primary #e6edf7
    • --text-secondary #8b95a8
    • --color-accent #0566C1 (actions, links — SYNVirt brand)
    • --color-cyan #06b6d4 (highlights, charts, status pills)
    • --ok #10b981
    • --warn #f59e0b
    • --danger #ef4444
    • --muted #5a6478
  • Fonts: Hanken Grotesk Variable (display/body) + JetBrains Mono (monospace, IDs/paths/technical values). Self-hosted in the crate (@fontsource-variable), no external CDN.
  • Icons: Lucide, inline SVG, no icon fonts.
  • Corner radius: 12px cards, 8px inputs, 6px buttons, 4px badges.
  • Shadows: subtle, 0 1px 2px for flat, 0 12px 32px for modals.
  • Animation: 150–250 ms, easing cubic-bezier(.16,1,.3,1).
  • Never use purple-pink gradients. Accent gradient (when used) is the brand blue #0566C1 on v2, or cyan → blue on the legacy installer/console.

All UI strings live in the dashboard’s i18n catalog (crates/web-ux-v2/src/i18n/en_us.json; legacy web-ux uses assets/strings/en_us.json) with semantic keys:

{
"nav.vms": "Virtual Machines",
"vm.list.empty.title": "No virtual machines yet.",
"vm.list.empty.cta_primary": "Create Ubuntu VM",
"vm.action.start": "Start",
"vm.action.stop": "Shut down",
"error.vm.not_found": "This VM no longer exists. It may have been deleted."
}

Components call t('nav.vms'). When es_MX lands later, a sibling file gets created and a language switcher toggles between them. Dashboard does not hardcode a single English string outside this file.

The loading / empty / status vocabulary is a fixed set of shared components — new surfaces compose these, they do not reinvent them. Every view is audited against the triad: what does it show while loading (skeleton), when there is nothing (empty state), and when the backend fails (mapped error + stale data)?

Skeletons — ui/Skeleton.vue + ui/SkeletonTable.vue. A skeleton matches the FINAL layout: same size, same density, same column tracks, so the swap to data causes zero layout shift. Gate it on the first load only (initialLoading). The shimmer stops under prefers-reduced-motion (and the in-app data-reduced-motion override) while the placeholder stays visible. Skeleton elements are aria-hidden; the swapping container carries role="status" + a human label. No spinner storms: one view renders at most one loading treatment at a time.

Stale-while-revalidate — composables/useSwr.ts (swrState). Stores track each fetched collection through swrState().track(fn): initialLoading (first load, nothing cached — the ONLY skeleton gate), refreshing (background refresh with data on screen), lastUpdated, error. Views render cached data immediately and refresh in the background; the refresh signal is ui/DataFreshnessIndicator.vue (:refreshing + age ramp) next to the view title. Full-page spinners on refetch are a defect. A refresh failure keeps the stale rows and raises a banner; it never blanks the view.

Empty states — ui/EmptyState.vue. Icon + one-line title + optional description + a primary CTA in the actions slot (per §“Empty states” — an empty view without a CTA is a defect). Use size="sm" inside drawers and panel rows.

Status chips — ui/HealthBadge.vue. THE status pill. Six semantic tones, one visual language app-wide:

Tone Meaning Example labels
healthy working as intended Reachable, Online, App-consistent
warning degraded, honest caveat Crash-consistent, Stale
critical broken, needs action Unreachable, Failed
unknown not yet probed Unknown
info a neutral fact Immutable · verified, Quiesced
pending waiting by design (dot breathes) Data plane pending, Queued

Module code maps its domain states onto these tones + a label; it never invents a new pill. Free-form categorical tags (not statuses) stay on ui/Tag.vue.

Motion. Transitions run 150–250 ms on the --duration-* tokens with the --easing-* curves (decel for entrances, emphasis for state changes) — no literal durations in component CSS unless the effect is a slow ambient loop, and every loop ships a prefers-reduced-motion (plus [data-reduced-motion]) kill switch. Motion conveys state (loading, refresh, arrival), never decoration.

  • Do not show JSON payloads to the user.
  • Do not show UUIDs. Always resolve to a name.
  • Do not show Linux paths (/var/lib/libvirt/images/...). Show “Storage pool: rpool-fast”.
  • Do not use modal dialogs for information (“Did you know?”). Modals are for actions only.
  • Do not show the browser’s native confirmation (confirm()). Always use a styled modal.
  • Do not show spinner-only “Loading…” without context.
  • Do not show success messages that require dismissal. Auto-dismiss at 4s.
  • Do not show timestamps as ISO 8601. Use relative (“3 minutes ago”) with an absolute tooltip on hover.
  • Do not use emojis as UI chrome (allowed in empty states as decoration).
  • Do not auto-refresh destructive views. The list of VMs auto-refreshes; the delete modal does not.