Design System · v1

Bold, blocky, neo-brutalist.

A living reference for the visual language of the app — typography, color, spacing, and components. A neo-brutalist / Memphis skin: thick black keylines, hard blur-less offset shadows, flat bright fills, and hard square corners. Buttons physically press on click. An ink-black primary anchors the system while punchy Memphis color carries status.

Montserrat
Type family
#18181B
Brand accent
Zinc
Neutral scale
0 · square
Base radius
2px / hard
Border & shadow
Quick start
  • Tokens — palette and type live in app/assets/stylesheets/shared/_design_tokens.scss; start with Color and Typography.
  • Actions — one near-black accent; patterns in Buttons and Forms.
  • List pages — use Search with render_search_input_row, the Filter toolbar, and the Data table density.
  • Deeper — comboboxes, chips, and tabs are linked in the table of contents.
01 · Color

Palette

Ink black anchors structure — borders, nav, tabs, and focus. Buttons use the marketing trio: lime primary, pink outline hover, white neutral. Semantic colors still carry badges, alerts, and charts inside thick black keylines. Edit app/assets/stylesheets/shared/_design_tokens.scss and it cascades site-wide.

Ink black
$brand-purple $primary #18181B
Pure black
$brand-purple-active #000000

Button palette

Lime
#E2FF3B
$brand-lime • btn-primary
Pink
#FF86C1
$brand-pink • outline hover
White
#FFFFFF
$surface • outline rest

Accent ramp

Subtle
#F4F4F5
$brand-purple-subtle
Base
#18181B
$brand-purple
Hover
#27272A
$brand-purple-hover
Active
#000000
$brand-purple-active
Emphasis
#18181B
$brand-purple-emphasis

Neutral ramp — zinc

50
#FAFAFA
$ink-50
100
#F4F4F5
$ink-100
200
#E4E4E7
$ink-200 • border
300
#D4D4D8
$ink-300
400
#A1A1AA
$ink-400
500
#71717A
$ink-500
600
#52525B
$ink-600 • secondary
700
#3F3F46
$ink-700 • body
800
#27272A
$ink-800
900
#18181B
$ink-900

Semantic

Success
#22C55E
$success • green-500
Danger
#EF4444
$danger • red-500
Warning
#E2FF3B
$warning • $brand-lime
Info
#06B6D4
$info • cyan-500
02 · Type

Typography

Montserrat everywhere. A modular type scale: page titles at 800, section headings at 700, body at 400 with 1.6 line-height. Secondary and caption copy floors at $ink-600 for legibility. Monospace for code / kbd / pre.

Display — 3.5rem
display-4 · 600 · -0.02em

Heading 1 — 2.875rem

h1 · 800 · 1.1

Heading 2 — 2rem

h2 · 700 · 1.12

Heading 3 — 1.625rem

h3 · 700 · 1.15

Heading 4 — 1.375rem

h4 · 700 · 1.2
Heading 5 — 1.125rem
h5 · 600 · tab titles
Heading 6 — 1rem
h6 · 600 · compact labels

Body — 1rem. The quick brown fox jumps over the lazy dog.

body · 400 · 1.6

Lead — 1.0625rem. Intro copy under a page title.

type-lead · 400 · 1.55

Caption — 0.875rem. Helper text, tab descriptions, form hints.

type-caption · $ink-600
Eyebrow — 0.8125rem uppercase
type-eyebrow · 700 · KPI / filter kickers

Anchor example · Strong · Emphasized · inline-code · Cmd + K

inline elements
def hello
  puts "Monospace: SFMono-Regular, Menlo, Monaco"
end
pre · monospace
03 · Static assets

Logos, icons, and files

Stable, first-party URLs under /branding are served by BrandingController and listed in Branding::PublicAssets::SOURCE_BY_SLUG — the same set the MCP get_hienergy_design_system_guide tool returns for emails and partners. Pipeline images use Sprockets digests; the public web root has a few legacy and PWA files.

Stable /branding/* (email-safe, no digest)

Absolute URL on any environment: <%= request.base_url %> + path (or public_brand_asset_path(...) in views, Branding::PublicAssets.url_for in mailers/JSON-LD helpers).

Monochrome (dark on light)

Monochrome (dark on light)

Navigation, sign-in, org JSON-LD logo, light surfaces, and email HTML headers. Primary stable wordmark path.

/branding/hienergy-logo-black.svg

slug: hienergy-logo-black.svghienergy_logo_black.svg

Monochrome wordmark (PNG)

Monochrome wordmark (PNG)

Google Sheets IMAGE() banners and other raster-only surfaces that need the black wordmark. Same mark as the SVG; workbook/export use.

/branding/hienergy-logo-black.png

slug: hienergy-logo-black.pnghienergy_workbook_logo.png

Reversed wordmark (PNG)

Reversed wordmark (PNG)

Google Sheets IMAGE() banners on dark headers and other raster-only dark surfaces.

/branding/hienergy-logo-white.png

slug: hienergy-logo-white.pnghienergy_logo_white.png

Social share card (1200×630)

Social share card (1200×630)

Open Graph / Twitter image for link previews on LinkedIn, Slack, and X. Raster PNG so unfurlers that reject SVG still render a preview.

/branding/hienergy-social-card.png

slug: hienergy-social-card.pnghienergy_social_card.png

Favicon

Favicon

Browser tab icon. Layouts use `favicon_link_tag` with the stable path via `public_brand_asset_path("favicon.ico")`.

/branding/favicon.ico

slug: favicon.icohi.ico

Other images in app/assets/images (Sprockets)

Use image_tag "…" or asset_path in the app. URLs include a digest in production — do not use in email HTML.

dex_avatar.png

dex_avatar.png

Dex / MCP chat face (offcanvas, workspace, message bubbles)

helper: image_path("dex_avatar.png")

world.svg

world.svg

World map and geographic chart primitives when bundled through the asset pipeline

helper: image_path("world.svg")

Web root in public/

Served as-is; no Sprockets. Favicon handling should align with the branding section above for product UI.

Path Notes
/favicon.ico Fallback favicon; prefer `/branding/favicon.ico` for consistency with the design system
/icon.svg Generic icon asset in public (used where a root-relative icon is required)
/icon.png Bitmap icon in public
/apple-touch-icon.png Home-screen icon
/apple-touch-icon-precomposed.png Legacy iOS precomposed touch icon
04 · Action

Buttons

Every button shares the neo-brutalist treatment: a 2px black keyline, a hard offset shadow, a flat fill (no gradients). Shadows behave physically: hover lifts the button off the page (shadow offset grows), click presses it below the surface — it travels the full shadow distance and the shadow inverts to a hard inset, so the button reads as sunk into the page. .btn-primary is neon lime with black text; .btn-outline-primary is white at rest, pink on hover, lime when active. Secondary stays white; danger stays coral-red. It's all driven by Bootstrap's --bs-btn-* variables in application.bootstrap.scss, so any .btn-* inherits the skin.

Canonical vocabulary

These are the only variants allowed in production views. Solid success / warning / info / light / dark / secondary and their outlines are banned — semantic color belongs on badges, alerts, and charts, not buttons.

Sizes
States
Button group · dropdown
New-record button

Every "create a new record" CTA in the app — admin index pages, dashboards, tab toolbars, modal triggers — is an icon-only primary button with the bi-plus-lg glyph and a title + aria-label like "New agency". One glyph, one shape, everywhere.

← default, small — always btn-primary, via new_record_button
05 · Input

Forms

Focused inputs get a near-black focus ring matching the accent.

Helper text goes here.
06 · Signal

Badges

Solid
Primary Secondary Success Danger Warning Info Light Dark
Pill · subtle
Pill Pill Pill Subtle pill
07 · Feedback

Alerts & flash messages

Inline .alert callouts use the neo-brutalist keyline fill. Session flashes always go through shared/flash_messages (icon + title + body + dismiss). Keys: notice/success, alert/error/danger, warning, info.

08 · Surface

Cards & tables

Default card

#fff surface, 2px $ink-900 keyline, square corners, hard offset $box-shadow.

Primary action
With header

.card-header.bg-white keeps the header on-brand.

Borderless + shadow

.border-0.shadow-sm for emphasis without an outline.

AdvertiserNetworkStatusEPC
Acme RunningImpactApproved$1.24
Globex FitnessPartnerizeApplied$0.88
Initech OutdoorPepperjamRejected$0.12
Umbrella GearCJNot applied
09 · Shape

Radii, elevation, focus

Corners — squared off

sm
0
base
0
lg
0
pill
50rem

Elevation — hard offset shadows

$box-shadow-sm Buttons, alerts, inputs.
$box-shadow Cards, dropdowns.
$box-shadow-lg Modals, popovers.

Focus ring

$focus-ring-color is near-black at 25% so keyboard focus carries the accent without drowning the UI.
10 · States

Empty & loading

Use shared/empty_state and shared/loading_state anywhere a list, panel, or turbo frame can be empty or pending. One voice, one visual rhythm.

Empty state — card

No deals yet

Deals you create or import will appear here. Start by adding your first program.

Empty state — bare (inside a card)

No matches

Try a broader term or clear some filters.

Loading state — inline

Loading…

Loading state — block (panels, turbo frames)

Loading performance data…
11 · Scaffold

Page header & breadcrumbs

Every top-level page starts with render "shared/page_header". It ties breadcrumbs, avatar, title, subtitle, meta badges, and action buttons into one rhythm. Pass an actions_extra slot for dropdowns or custom chips.

12 · Metric

KPI tiles

The hairline KPI tile is the default metric pattern: quiet zinc dot, uppercase kicker, dark count, muted meta. Used on /changes, the publisher show page, and the advertiser show page (via .kpi-card).

Applied
1,204
in the last 24 hours
Approved
318
in the last 24 hours
Rejected
47
in the last 24 hours
Stopped
9
in the last 24 hours
13 · Chip

Chips

Status chips use the semantic fills (Approved / Applied / Rejected / Stopped). Neutral meta chips stay hairline.

Meta chip — .advertiser-chip

Status hue matches design-system badges: Approved → Success, Applied → Warning, Rejected → Danger, Stopped → Info, Not applied → Secondary. Prefer status_badge / advertiser_status_badge and transaction_status_badge — never hand-roll the classes.

Advertiser statuses
Approved Applied Rejected Stopped Not Applied Unknown
Transaction statuses
Pending Approved Paid Corrected
Meta
12 deals acme.test

Advertiser status badges

Solid badge text-bg-* fills used by status_badge.

Approved Applied Rejected Stopped Not Applied

Filter summary — .filter-summary + .filter-chip

Filters Network: Impact Status: Applied Publisher: Daily Mail 1,204 results

Network pill — .network-pill

Impact Partnerize Rakuten Avantlink

Advertiser list identity — shared/_advertiser_list_identity

Pass show_logo: false (default is true) when a row already shows a large logo nearby — for example the Advertiser column on All Deals — so the name and meta lines are not repeated twice with art.

With logo (default)

1MORE logo
1MORE
Impact Reshop

Text only (show_logo: false)

1MORE
Impact Reshop
14 · Data

Data table

Ink-black header, pale-blue zebra rows, airy cell padding, bold lead column, and an optional totals row. Cell notes use .network-pill (ink) or .network-pill--lime. Prefer table data-table inside a bordered surface (shared/surface_panel or .ds-card).

Network Window Transactions Corrections Rate
CJ
Jan 1 – Jul 15 Feb–Apr sync gap
412,884 4,912 1.19%
Awin
Jan 1 – Jul 15 monitoring since Jun
288,410 1,104 0.38%
Impact
Jan 1 – Jul 15
196,022 640 0.33%
Partnerize
Jan 1 – Jul 15
94,118 210 0.22%
Program total 2026 YTD 991,434 6,866 0.69%
16 · Filter

Filter toolbar

Single-line search + selects + actions via filter-toolbar--inline (scrolls horizontally on narrow viewports). List pages on /changes, public deal lists, and similar screens.

Filter combobox (toolbar)

Single-value filter rows (admin index pages, deals#index, etc.) use admin/advertisers/single_filter_select: a field-shaped trigger (filter-combobox / filter-combobox__toggle) and a filter-dropdown-menu panel with search. Styles live in app/assets/stylesheets/components/_filter_combobox.scss. For a combobox not inside a filter bar, see Searchable combobox (Stimulus).

Filters
Country

Public sample deals (card + frame)

Sample Deals (and similar lists): white rounded-3 card, filter-stacking-context so comboboxes stack above the table, filter-toolbar--compact / --nested, single_filter_select in a row, min-w-0 on the search group, and clear_filters_link (with turbo_frame when the form targets a frame). Language toggles can use filter-toolbar__lang-scroll for horizontal scroll on small screens.

Filters

Field
Field
Field
Filters Network: Impact Status: Applied 42 results
17 · Segmented

Segmented control

A quiet pill-shaped toggle for mutually exclusive views (e.g. "All / People / List emails" on the contacts tab). Use when the options are short and switching is expected to be frequent; prefer tabs for larger sections.

18 · List

Contact list

Dense list rhythm for people rows: avatar + name, role · company subtitle, email/phone meta line, and an optional rating capsule. Rows hairline-divide and tint on hover. Used on the advertiser "Contacts" tab.

19 · Tabs

Tabs

Every tabbed surface in the app uses one universal system: render_tabs for the nav + content shell, and tab_header at the top of every pane body. Navigation renders as neo-brutalist pills — rounded capsules with a black active fill and pink hover — not connected underline tabs. Surfaces use 2px ink borders and hard offset shadows; index tiles and panels should not stack border-0, shadow-sm, or hover-shadow on top of the global card chrome.

Pills
Default · show pages · dashboards · configuration

Overview

Short helper line under the title. Keep it to a single sentence.

Tab bodies should start with tab_header and then render their actual content — tables, charts, forms, whatever is needed.

Statistics

Performance metrics over time.

The nav can show a trailing badge: count, and each button supports a tooltip: for longer descriptions.

History

Audit log of changes.

Conditionally rendered tabs should use hidden: !condition on t.pane — never wrap the call in an if block.

Usage
Copy-paste starter · render_tabs + tab_header
<%= render_tabs(id: "advertiserTabs",
                data: { controller: "tab-url" }) do |t| %>
  <% t.pane "overview",
           title: "Overview",
           icon: "bi-info-circle",
           active: true,
           url_hash: "overview" do %>
    <%= tab_header title: "Overview",
                   icon: "bi-info-circle",
                   description: "Details and attributes." %>
    <%# ...pane body... %>
  <% end %>

  <% t.pane "stats",
           title: "Statistics",
           icon: "bi-bar-chart",
           hidden: !@has_stats,
           url_hash: "stats" do %>
    <%= tab_header title: "Statistics",
                   icon: "bi-bar-chart",
                   description: "Performance metrics over time." do %>
      <%= render "shared/export_to_sheets_button", resource: "advertisers" %>
    <% end %>
    <%# ...pane body... %>
  <% end %>
<% end %>
20 · Data visualization

D3 charts

Production chart controllers import their palettes from app/javascript/lib/chart_design_tokens.js as the single source of truth. The reference swatches below mirror those tokens. Series colors come from CATEGORICAL_10: flat, saturated fills anchored by brand ink, in the same register as the lime/pink Memphis accents. Grids stay whisper-quiet zinc; tooltips get the hard 2px ink keyline. All demos below render from static sample data.

Categorical ramp

CATEGORICAL_10 in series order. Neighboring hues always contrast, so stacked and grouped charts stay legible; the trailing zinc is the long-tail bucket.

1 · Ink
2 · Cyan
3 · Pink
4 · Lime
5 · Violet
6 · Mint
7 · Coral
8 · Blue
9 · Orange
10 · Zinc

Sales & commission lines

transaction-line-chartLINE_DUO_CHART_STYLES. Sales in brand ink, commissions in mint. Used on the home Overview tab.

Daily bars

transaction-totals-chart (sales) — DEFAULT_D3_CHART_STYLES, ink fill.

transaction-totals-chart (commission) — mint fill via barFillForType.

Scatter plots

transactions-charts — one point per transaction; bubble size is commission.

commission-chart — commission rate over time.

Commission rate box plot

commission-rates — daily rate distribution (min/quartile/median) over the last 30 days.

KPI comparison line

kpi-line-chart — current period in ink, previous period dashed zinc. Powers the Performance Summary KPI cards.

Status donut

pie-chartPIE_CHART_COLORS: mint / cyan / coral / lime map to approved / applied / rejected / stopped, matching the badge semantics.

Approved 0%
Applied 0%
Rejected 0%
Stopped 0%

Transaction status — stacked daily & donut

transaction-status-daily-chart (daily variant) — TRANSACTION_STATUS_CHART_COLORS: zinc pending, mint approved, ink paid, coral corrected.

Donut variant of the same controller (variant: "donut").

Status changes histogram

status-changes-yearly-stacked-histogram — stacked daily bars of advertiser status changes, colored by PIE_CHART_COLORS.

Stacked area — by publisher

admin-publisher-daily-transaction-chartCATEGORICAL_10 series with a sales / transactions metric toggle.

Stacked area — by network

publisher-network-transaction-chart — the publisher-facing sibling, stacked by network.

Yearly stacked histogram

publisher-network-yearly-stacked-histogram — sales / commissions toggle; total swaps via [data-period-total]. Demo total: $3,924

Grouped bars

network-transaction-chart — network × month grouped bars, CATEGORICAL_10.

Bubble pack

bubble-chart — category share; bubble area encodes count.

Treemaps

treemap — commission by advertiser, tinted by the mint/coral diverging ramp (CHART.treemap.diverge) for period-over-period change.

publisher-network-transaction-treemap — network share with a sales / transactions toggle and ink choropleth ramp.

Contact verification stacked bars

contacts-source-status-chartCONTACT_STATUS_CHART_COLORS: mint valid, lime catch-all, coral abuse, ink-600 unknown.

World map

world-map — ink highlight on zinc landmass (CHART.map). Fetches TopoJSON from the world-atlas CDN, so it needs network access to paint.

Fetch-backed charts

Three chart controllers load their data over HTTP instead of inline values, so they are documented here without a live demo:

  • clicks-bar-chart — daily clicks bars; fetches JSON from a url value with period buttons (see the admin advertiser Clicks tab).
  • transaction-line-chart-combined — sales + commission duo lines behind a skeleton loader with a weeks selector.
  • api-call-usage-chart — API usage bars, DEFAULT_D3_CHART_STYLES.