Skip to content

UI Design System ​

This document defines the design system rules and conventions for the Hegemony UI. All UI changes must follow these rules to maintain consistency and prevent drift.

Purpose ​

This design system serves as the contract for all UI development:

  • Prevents ad-hoc styling that creates technical debt
  • Ensures consistency across all pages
  • Makes the codebase maintainable and predictable
  • Provides clear patterns for new features

Core Principles ​

1. Component-Based Architecture ​

  • Pages must compose shared primitives (components)
  • No page should implement its own versions of common UI patterns
  • Reuse over recreation

2. Tailwind v4 + shadcn/ui Only ​

  • All styling must use Tailwind v4 utilities
  • Use shadcn/ui components for interactive elements
  • No bespoke CSS inside pages (see "No Bespoke CSS Rule" below)

Licensing note. shadcn/ui is not an npm dependency — its components are copied into apps/ui/src/components/ui/ by the CLI and edited in place, so no package-manager licence check can see them. The scaffolded files are derived from shadcn/ui (MIT, © 2023 shadcn) and carry a dual header:

text
// SPDX-FileCopyrightText: 2023 shadcn <https://ui.shadcn.com>
// SPDX-FileCopyrightText: 2025-2026 Jakub Trávník <[email protected]>
//
// SPDX-License-Identifier: MIT AND AGPL-3.0-or-later

Keep that header when adding a component with npx shadcn add, and use the plain AGPL-3.0-or-later header only for components written here from scratch (error-banner, multi-select, permission-denied, searchable-select, chip-input, boolean-segmented-control). Retaining the notice is what MIT asks for in exchange; dropping it is the one way to turn a free component into a licence violation.

3. Semantic Tokens ​

  • All colors must use semantic design tokens:
    • bg-background, bg-card, bg-muted
    • text-foreground, text-muted-foreground
    • border-border, border-input
    • accent, accent-foreground
    • Never hardcode colors like #646cff or rgba(100, 108, 255, 0.2)

4. Accessibility First ​

  • Use Radix/shadcn components for built-in accessibility
  • Prefer role-based selectors in tests (getByRole, getByLabel)
  • Add data-testid only when necessary

Layout System ​

App Shell Structure ​

The application uses a consistent shell layout:

text
┌─────────────────────────────────────────────────┐
│ Sidebar Nav                  │  Page Content    │
│ - Flows                      │  ┌─────────────┐ │
│ - Runs                       │  │ PageHeader  │ │
│ - Inventory                  │  └─────────────┘ │
│   - Sites                    │                  │
│   - Devices                  │                  │
│ - Credentials                │  Main Content    │
│                              │                  │
└─────────────────────────────────────────────────┘

Rules:

  • Left sidebar for primary navigation
  • Inventory is a parent navigation trigger, not a route; it reveals Sites, Devices and Objects when clicked and opens automatically while a child route is active.
  • Inventory's entries are fixed. The menu does not grow one entry per object type: a plugin registers every type it can serve while a provider reads only the types its configuration names, so that shape made the menu a list of mostly empty pages whose length changed as syncs ran. Which type you are looking at is chosen on the Objects page itself, from a picker in its heading.
  • The Objects picker is data-driven where the menu is not: it offers the types that have synced rows, following GET /inventory/object-types/counts rather than the registry. While those counts are still loading, or if that request fails, every registered type is offered instead — a picker that is briefly too long is better than one that hides a type someone synced. The type named in the URL is always offered, however few rows it has, so a bookmark opens on itself.
  • A page whose subject is chosen on the page belongs in the heading, not beside it: PageHeader takes a node so the control sits in the h1 ("Objects — Prefixes"), and /inventory/<type> stays the address of that view.
  • Content area with consistent padding
  • PageHeader component at top of each page
  • Dark-first theme with subtle borders

Page Structure ​

Every page must follow this structure:

tsx
<div className="page-container">
  <PageHeader title="Page Title">{/* Optional action buttons */}</PageHeader>

  <div className="page-content">{/* Main content */}</div>
</div>

Rules:

  • Use PageHeader component for title + actions
  • Consistent padding via utility classes
  • No custom page-level CSS files

Spacing Scale ​

Use Tailwind's spacing scale consistently:

Use CaseSpacingTailwind Class
Component gap4pxgap-1
Small gap8pxgap-2
Standard gap12pxgap-3
Large gap16pxgap-4
Section spacing24pxgap-6
Page padding32pxp-8

Rules:

  • Use consistent spacing throughout
  • Prefer gap for flex/grid layouts
  • Use space-y sparingly, prefer explicit gaps

Typography Hierarchy ​

ElementSizeWeightUsage
Page Title (h1)2rem600PageHeader title
Section Heading (h2)1.5rem600Major sections
Subsection (h3)1.25rem600Card headers
Body0.875rem400Default text
Small0.75rem400Meta info, labels

Rules:

  • Use semantic HTML elements (<h1>, <h2>, etc.)
  • Style via Tailwind classes (text-2xl, font-semibold)
  • Maintain visual hierarchy through size and weight

Table-First Lists ​

All list views must use the DataTable primitive:

DataTable Rules ​

  1. Use DataTable for all lists

    • Devices, Runs, Flows, Sites, Credentials
    • Consistent column structure
    • Built-in sorting, filtering (when needed)
  2. Column Structure

    tsx
    {
      header: "Name",
      accessor: "name",
      cell: (row) => <span className="font-medium">{row.name}</span>
    }
  3. Status Columns

    • Use StatusBadge component
    • Consistent color coding
    • Semantic status names
  4. Actions Column

    • Right-aligned
    • Use RowActions component (dropdown or icon group)
    • Max 3 visible actions, rest in dropdown

Table Styling ​

tsx
<Table>
  <TableHeader>
    <TableRow>
      <TableHead>Column Name</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>Cell Content</TableCell>
    </TableRow>
  </TableBody>
</Table>

Rules:

  • Use shadcn Table components
  • No custom table CSS
  • Hover states built-in
  • Responsive by default

CRUD List Soft-Delete Convention ​

For entities that support soft delete, list pages must use the same interaction model:

  • Tabs

    • Use TableTabs with Active and Deleted tabs.
    • Show per-tab counts.
  • Deleted actions

    • Use DeletedTabActions with two actions:
      • Restore (moves row back to Active).
      • Permanently delete (irreversible).
  • Confirm dialogs

    • Keep separate confirm states for soft delete and permanent delete.
    • Include entity name in dialog description whenever available.
    • Soft-delete messaging must explicitly mention recovery via Deleted tab.
  • Loading and errors

    • Track operation state per item (deletingId, restoringId).
    • Surface failures in a visible inline error banner (no console-only errors).
  • Row interactions

    • Active-tab rows may navigate to detail on row click.
    • Deleted-tab row actions must prevent accidental row navigation where needed.

This convention applies to pages like Devices, Sites, Credentials, Variables, Notifications, and Schedules when soft-delete is enabled.

Row Grouping Convention ​

Long client-side lists may group their rows under collapsible headings by passing grouping to DataTable:

tsx
<DataTable
  columns={columns}
  data={flows}
  keyAccessor="id"
  grouping={{
    // key identifies the group, label/hint render the heading, rank orders
    // groups (catch-all groups last).
    groupOf: (flow) => ({ key: 'local', label: 'Local', hint: 'not in git', rank: 2 }),
  }}
/>

Rules:

  • Grouping replaces paging. A grouped table renders the whole filtered, sorted list in one pass and shows a count summary with Expand all / Collapse all instead of page controls — a page break inside a collapsed group has no sensible meaning.
  • Grouping runs after sorting, so the column sort still orders rows inside each group.
  • Client-side tables only: with remoteOperations the data holds a single page, so its groups would be misleading.
  • The group a row belongs to must be derivable from data the list already loaded. Do not fetch per row to build a heading.
  • Offer grouping through a Group by selector next to the list's search box, and keep the mode in the query string (see the Flows page and flowListUrlState), so a grouped view can be linked and bookmarked.

List View Convention ​

A list page may offer a second, card-based rendering of the same data (the Flows page's Library). When it does:

  • Whichever view answers the page's everyday question is the default. On the Flows page that is the library, because the list's daily job is "find the flow I want and start it"; the table remains the dense, sortable, bulk-friendly view behind one click. Switching views must never be the only way to reach an action.
  • Both views read the same search, filter and grouping state, from the query string. Do not give the card view its own filter controls: two ways to filter one list is one too many.
  • Cards carry what a row cannot - a full description, tags, live signals - and promote the page's primary verb (Run, Open) to a button, with the rest in the same CrudActions menu the table row uses.
  • The chosen view is part of the list's shareable state, so it belongs in the query string with the search and grouping.
  • Cards have no column headers to click, so a card view that needs an order carries its own Sort by selector next to the Group by one, and that choice joins the rest in the query string. Do not offer it in the table, where the columns already sort.
  • Decide where a card affordance's state lives by asking who it is for. Pinning on the Flows page is the whole organization's ordering of its own list and has to survive an import, so it is a field on the flow and a request to the server, not local storage. Reach for local storage only for something genuinely personal to one browser, and keep the read tolerant of a missing or unparsable value.

Bulk Selection Convention ​

For table pages that support multi-record mutations:

  • Use DataTable selection props for standard tables (selectedRowIds, onSelectedRowIdsChange, isRowSelectable) instead of custom checkbox wiring.
  • Use BulkActionToolbar directly above the affected table and only render it when at least one row is selected.
  • Keep destructive bulk actions behind ConfirmDialog and describe whether the action is restorable or permanent.
  • Preserve partial-success feedback in the page error area and keep failed row IDs selected so users can retry or inspect them.
  • Exclude provider-backed/read-only records from selection with disabled checkboxes and continue to expose row-level read-only affordances.

Inventory list pages that mix local and provider-backed records should expose compact source filter buttons (All Sources, local, provider ID) above the active table, matching the Devices and Sites pages.


Form Composition ​

Forms must use shadcn form primitives:

Form Structure ​

tsx
<form className="space-y-4">
  <div className="space-y-2">
    <Label htmlFor="field">Field Label</Label>
    <Input id="field" type="text" placeholder="Enter value..." />
  </div>

  <div className="flex gap-2">
    <Button type="submit">Save</Button>
    <Button type="button" variant="secondary">
      Cancel
    </Button>
  </div>
</form>

Form Mode Detection Convention ​

Form mode (create, edit, duplicate) must be derived from router state (e.g., useLocation) rather than direct window.location access. This keeps behavior consistent with SPA routing and test environments.

Rules:

  • Use shadcn Label, Input, Select, Textarea
  • Associate labels with inputs via htmlFor/id
  • Group form actions at bottom
  • Use space-y for vertical spacing

Form Fields ​

Field TypeComponentNotes
Text input<Input type="text" />Standard text
Number<Input type="number" />Numeric values
Select<Select>Dropdown selection
Boolean<BooleanSegmentedControl />True/False or Enabled/Disabled
Checkbox<Checkbox>Simple on/off acknowledgements
Textarea<Textarea>Multi-line text

Shared Primitives (Components) ​

All pages must use these shared components:

Required Primitives ​

Use these primitives for all UI development:

  1. PageHeader (@/layout/PageHeader)

    • Title (left)
    • Actions slot (right)
    • Consistent styling
    tsx
    import { PageHeader } from '@/layout';
    
    <PageHeader title="Devices">
      <Button>Add Device</Button>
    </PageHeader>;
  2. DataTable (@/components/DataTable)

    • List views
    • Generic columns with accessors
    • Optional row click handler
    tsx
    import { DataTable, type Column } from '@/components';
    
    const columns: Column<Device>[] = [
      { header: 'Name', accessor: 'name' },
      {
        header: 'Status',
        accessor: (row) => <StatusBadge status={row.status}>{row.status}</StatusBadge>,
      },
    ];
    
    <DataTable columns={columns} data={devices} keyAccessor="id" />;
  3. RowActions (@/components/RowActions)

    • Edit/Delete/View actions
    • Dropdown menu for actions
    • Destructive variant for delete
    tsx
    import { RowActions } from '@/components';
    
    <RowActions
      actions={[
        { label: 'Edit', onClick: () => handleEdit(item) },
        { label: 'Delete', onClick: () => handleDelete(item), variant: 'destructive' },
      ]}
    />;
  4. EmptyState (@/components/EmptyState)

    • When no data exists
    • Icon + message + optional action
    • Centered layout
    tsx
    import { EmptyState } from '@/components';
    
    <EmptyState
      title="No devices found"
      description="Create a device to get started."
      action={<Button>Add Device</Button>}
    />;
  5. StatusBadge (@/components/StatusBadge)

    • Status indicators
    • Color-coded (running, success, failed, etc.)
    • Optional dot indicator
    tsx
    import { StatusBadge } from '@/components';
    
    <StatusBadge status="running" showDot>
      Running
    </StatusBadge>;
  6. ConfirmDialog (@/components/ConfirmDialog)

    • Destructive actions (delete, cancel)
    • Clear action + cancel
    • Accessible
    tsx
    import { ConfirmDialog } from '@/components';
    
    <ConfirmDialog
      open={showDelete}
      onOpenChange={setShowDelete}
      title="Delete Device"
      description="Are you sure? This cannot be undone."
      variant="destructive"
      onConfirm={handleDelete}
    />;
  7. BulkActionToolbar (@/components/BulkActionToolbar)

  • Appears above a table when rows are selected

  • Shows selected count, clear-selection control, and one or more bulk actions

  • Pair destructive actions with ConfirmDialog

    tsx
    import { BulkActionToolbar } from '@/components';
    
    <BulkActionToolbar
      selectedCount={selectedIds.size}
      itemLabel="device"
      onClearSelection={() => setSelectedIds(new Set())}
      actions={[{ label: 'Delete selected', variant: 'destructive', onClick: openConfirm }]}
    />;
  1. PermissionDenied (@/components/ui/permission-denied)

    • Standardized access-denied banner for gated pages
    • Optional message prop for section-specific wording
    • Use instead of duplicating inline destructive alert markup in hasRole() guards
    tsx
    import { PermissionDenied } from '@/components/ui/permission-denied';
    
    if (!hasRole('admin')) {
      return <PermissionDenied message="You do not have permission to manage API tokens." />;
    }

    Prefer the capability-driven guards where a page maps to an API route: RequirePermission (@/auth) for whole pages and PermissionButton for controls. The Settings hub is the model: it is open to every role, and each tile names the GET route its page loads, so a caller sees only the pages they may open in the active organization and is never led to a 403. A locked PermissionButton words its tooltip from the route's policy and scope (both come from /auth/capabilities): an org-scoped route asks for the role "in this organization", a platform-scoped one (/settings/*, /orgs, …) for the platform-wide role, because organization roles never grant those.

  2. ErrorBanner (@/components/ui/error-banner)

  • Standardized inline error banner for page and panel failures

  • Accepts a string error prop and renders an accessible destructive alert

  • Use instead of duplicating AlertCircle + destructive border markup in pages

    tsx
    import { ErrorBanner } from '@/components/ui/error-banner';
    
    <ErrorBanner error="Failed to load configuration." />;

Detail Page Action Error Convention ​

Detail pages with destructive actions should follow this pattern:

  • Wrap destructive mutations in try/catch
  • Store local action error state (actionError)
  • Render an inline destructive alert/banner near the page header

This avoids silent failures and keeps action feedback consistent across detail views.

  1. TagBadge (@/components/TagBadge)

    • Key=value tag display
    • Subtle background differentiation
    • Used in tables for tags columns
    tsx
    import { TagBadge } from '@/components';
    
    <TagBadge tagKey="env" value="production" />;
  2. TagList (@/components/TagBadge)

    • Display multiple tags with truncation
    • Shows "+N" overflow indicator
    • Wraps TagBadge components
    tsx
    import { TagList } from '@/components';
    
    <TagList tags={{ env: 'prod', role: 'core' }} maxVisible={3} />;
  3. MultiSelect (@/components/ui/multi-select)

    • Multi-value selection with search
    • Uses Command palette (cmdk)
    • Badge display for selected items
    tsx
    import { MultiSelect } from '@/components/ui/multi-select';
    
    <MultiSelect
      options={[{ value: 'dev-1', label: 'Device 1' }]}
      selected={selectedDevices}
      onChange={setSelectedDevices}
      placeholder="Select devices..."
    />;
  4. BooleanSegmentedControl (@/components/ui/boolean-segmented-control)

    • True/False or Enabled/Disabled selections
    • RadioGroup-based segmented control
    • Preferred for explicit binary choices
    tsx
    import { BooleanSegmentedControl } from '@/components/ui/boolean-segmented-control';
    
    <BooleanSegmentedControl
      value={enabled}
      onValueChange={setEnabled}
      trueLabel="Enabled"
      falseLabel="Disabled"
      ariaLabel="Status"
    />;
  5. Checkbox (@/components/ui/checkbox)

    • shadcn/Radix checkbox
    • Accessible with label association
    • Use for simple on/off acknowledgements
    tsx
    import { Checkbox } from '@/components/ui/checkbox';
    import { Label } from '@/components/ui/label';
    
    <div className="flex items-center space-x-2">
      <Checkbox id="enabled" checked={enabled} onCheckedChange={setEnabled} />
      <Label htmlFor="enabled">Enable feature</Label>
    </div>;

When to Create a New Primitive ​

Before creating a new component, check:

  1. Does this pattern exist in 2+ pages? → Create primitive
  2. Is this a one-off? → Consider if it's really needed
  3. Can existing primitive be extended? → Prefer composition

Process:

  1. Create component in src/components/
  2. Export from src/components/index.ts
  3. Document usage in this file
  4. Refactor existing pages to use it

No Bespoke CSS Rule ​

CRITICAL: Pages must NOT have their own CSS files.

❌ FORBIDDEN: ​

  • Creating MyPage.css
  • Adding <style> tags
  • Inline styles (except for dynamic values)
  • Custom CSS classes in pages

✅ ALLOWED: ​

  • Tailwind utility classes
  • shadcn component classes
  • CSS variables for theming
  • Dynamic inline styles (e.g., style={{ width: dynamicValue }})

Rationale ​

  • Prevents style drift
  • Ensures consistency
  • Makes refactoring safe
  • Reduces maintenance burden

Sanctioned exceptions ​

Two legacy stylesheets predate this rule and remain as documented exceptions. Both map theme tokens to local CSS variables and contain rendering-heavy styles that Tailwind utilities cannot express well:

  • apps/ui/src/pages/RunDetail.css — step card, event timeline, and artifact rendering for the run detail view.
  • apps/ui/src/components/MonitorPanel.css — chart-specific overrides for the connectivity monitor visualizations.

Do not use these as precedent. New work in these areas should migrate styles to Tailwind/shadcn where practical and must not grow the files. Stylelint covers both files, and any new .css file under src/pages/ or src/components/ will be rejected in review.


Import/Export Modal ​

The Import/Export modal is a global feature:

Requirements:

  • Uses shadcn Dialog component
  • Tabs for Import/Export sections
  • Monospace editor for YAML
  • Result summary after operation
  • Accessible keyboard navigation

Rules:

  • Preserve existing API behavior
  • No backend changes
  • YAML schema unchanged
  • E2E tests must pass

Theme and Colors ​

Dark-First Theme ​

Hegemony uses a dark theme with oklch() color space:

  • Dark background (#131313)
  • Slightly lighter panels/headers (#1b1b1b)
  • Subtle borders (oklch(1 0 0 / 10%) - white at 10% opacity)
  • Strong accent color (#ff543a - orange-red, used sparingly)

CSS Variables ​

Use these semantic tokens (defined in src/index.css using oklch() color space):

css
/* Dark theme (primary) */
.dark {
  --background: #131313;
  --foreground: oklch(0.985 0 0);
  --card: #131313;
  --card-header: #1b1b1b;
  --card-foreground: oklch(0.985 0 0);
  --popover: oklch(0.205 0 0);
  --popover-foreground: oklch(0.985 0 0);
  --muted: oklch(0.269 0 0);
  --muted-foreground: oklch(0.708 0 0);
  --border: oklch(1 0 0 / 10%);
  --input: oklch(1 0 0 / 15%);
  --destructive: #ff543a;
  --strong-accent: #ff543a;
}

Rules:

  • Never hardcode colors
  • Use semantic tokens only
  • Dark theme is primary (.dark class on root)

The following navigation labels are STABLE and must not change without updating E2E tests:

  • Home
  • Flows
  • Runs
  • Triggers (schedules and webhooks, as tabs on one page)
  • Artifacts (submenu trigger; reveals Terraform States, Container Images and, for platform admins, Binary Artifacts on click)
  • Inventory (submenu trigger; reveals Sites, Devices and Objects on click; the menu does not grow an entry per object type)
  • Settings (every role; the hub filters its tiles by the caller's capabilities, see the PermissionDenied note above)

A submenu trigger is a button with aria-expanded and aria-controls, never a link; its items are compact links nested in a role="group". A submenu opens on its own when one of its pages is the current one, one is open at a time, and it stays as the user left it otherwise (navSubmenus in layout/AppShell.tsx declares them). The nav's height decides whether the sidebar is shown at all, so a submenu must stay short enough for a deep link into one of its pages to keep the sidebar on a 720px-tall display.

Schedules and webhooks are two ways to start the same flow, so they share one Triggers entry rather than two sidebar rows; the page tabs between them and the old /schedules and /webhooks URLs redirect there. Detail and edit pages keep their own routes.

Home is the landing route: the index route redirects / to /home, and the brand wordmark links there. Its sidebar entry needs no end: true flag — the path is /home, not /, so the default prefix match only ever fires on Home itself.

Home renders links that repeat sidebar labels (its Resources card links to Sites, Devices, and so on). Navigation-shape assertions in E2E tests must therefore be scoped to the navigation landmark rather than the whole page.

If you change navigation:

  1. Update E2E tests in same PR
  2. Update this document
  3. Notify team of navigation change

Help Affordance ​

The app shell mounts a Help trigger (a HelpCircle button labeled "Help") in its own sidebar row above the user-info footer, and icon-only in the mobile header. It opens a right-side sheet (components/help/HelpDrawer.tsx) showing route-aware documentation bundled into the build from docs/docs-map.yaml (the generated src/generated/help-manifest.ts module, lazy-loaded on first open). Keep the trigger reachable in every shell mode; new routes get help content by mapping docs in the docs-map, not by editing the drawer.

Organization Switcher ​

Users in more than one organization get a switcher (components/OrgSwitcher.tsx) in its own sidebar row under the brand. It must never read as part of the nav, which it sits next to:

  • The trigger is a bordered selector (org badge, an "Organization" caption, the active org's name) rather than a ghost row like the nav links. It is h-10 in both sidebar modes so collapsing does not shift the nav below it.
  • Each organization is shown by a coloured initials badge, hashed from its id so the colour survives a rename, never by a nav-style icon.
  • In the desktop sidebar the menu opens beside the sidebar (menuSide="right", offset past the sidebar edge), not down over the nav links. The mobile drawer keeps it below the trigger.
  • The menu header stays pinned while a long list scrolls, capped to the room Radix reports, and the active organization is highlighted with a check rather than dimmed as disabled.

Testing Requirements ​

E2E Testing Rules ​

  1. Smoke Tests (@smoke)

    • Quick checks for deployment issues
    • Verify navigation works
    • Verify pages load
    • Run before full suite
  2. Page Tests

    • Full CRUD operations
    • Form validation
    • Error handling
    • Mock API responses
  3. Locator Strategy

    • Prefer getByRole, getByLabel, getByText
    • Add data-testid only when role-based fails
    • Never use CSS selectors

When to Update E2E ​

  • Navigation structure changes
  • Form fields added/removed
  • Page layouts significantly change
  • New pages added

Command to verify:

bash
task ui:e2e:smoke

Quality Gates ​

All UI changes must pass:

Required Commands ​

  1. Format + Lint + Typecheck:

    bash
    task ui:all
  2. Build:

    bash
    npm --prefix apps/ui run build
  3. E2E Smoke Tests:

    bash
    task ui:e2e:smoke
  4. Full Verification (for layout/component/routing changes):

    bash
    task ui:verify

CI Expectations ​

  • All checks must pass
  • Playwright E2E runs on PRs
  • HTML report uploaded on failure
  • No console errors in tests

Copilot Instructions Integration ​

This design system is enforced via .github/copilot-instructions.md:

  1. Require shadcn + Tailwind usage

    • No custom CSS files
    • Use semantic tokens
  2. Forbid new page CSS

    • Pages compose primitives
    • No PageName.css files
  3. Require E2E updates

    • Update tests when nav/routes change
    • Verify smoke tests pass

Summary ​

This design system ensures:

  • Consistency: All pages look and feel cohesive
  • Maintainability: Shared components reduce duplication
  • Predictability: Clear patterns for new features
  • Quality: Enforced via Copilot instructions and CI

Golden Rule: If you need a new visual pattern, create a primitive, document it, and reuse it. Never style ad hoc inside a page.

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].