Skip to content

Design system

Overview

The design system has two main layers:

  • Vue.js components in app/frontend/src/design_system/
  • Rails helpers and custom-element wrappers in app/easy_design/

Rails renders easy8-ds-* custom elements and the frontend entrypoint registers the matching Vue components.

Related paths:

  • app/frontend/src/design_system/: source of DS Vue components
  • app/frontend/src/shared_modules/: higher-level shared components built on top of the DS
  • app/easy_design/design_system/: Ruby wrappers and helpers for server-rendered usage
  • easy_storybook/: Storybook configuration

Discover components

Check Storybook before writing UI code.

  • Built Storybook in Rails: /easy_storybook
  • Local Storybook: yarn storybook
  • Local Storybook with design system styles only: yarn storybook-ds (starts faster because it skips the large Easy stylesheet)

Local Storybook runs on http://localhost:6006.

To rebuild the static Storybook served by Rails, run:

yarn build-storybook

This builds Storybook into public/easy_storybook/. To inspect current changes without rebuilding the static bundle, use local Storybook on port 6006.

Each DS component has its own lowerCamelCase story file in app/frontend/src/design_system/**/<component>.stories.ts, for example dsRadioGroup.stories.ts.

Use the design system in Vue.js

Import components from the barrel export:

<template>
  <DSButton variant="primary" size="md" prefix-icon="plus">
    Create project
  </DSButton>

  <DSTextField
    v-model="name"
    name="project_name"
    label="Project name"
    placeholder="Enter project name"
  />
</template>

<script setup lang="ts">
import { ref } from "vue";
import { DSButton, DSTextField } from "@/src/design_system";

const name = ref("");
</script>

Guidelines:

  • Prefer @/src/design_system exports for DS components
  • Check the matching *.stories.ts file before guessing prop names
  • Prefer shared modules from app/frontend/src/shared_modules/ for higher-level use cases
  • For entity autocomplete, prefer the shared Autocomplete module instead of assembling DSSelect manually

Use the design system in Rails

For server-rendered UI, prefer DS helpers such as ds_button, ds_alert, ds_select, and ds_datepicker.

These helpers render DS custom elements such as easy8-ds-button. The registration happens in app/frontend/entrypoints/easy_ds_custom_elements.ts.

Names map across layers: DSButton in Vue.js, ds_button in Rails, <easy8-ds-button> as the custom element.

Example:

<%= ds_button(
  variant: :primary,
  size: :md,
  prefix_icon: "plus"
) do %>
  Create project
<% end %>

<%= ds_alert(
  variant: :warning,
  header: "Unsaved changes",
  body: "Your changes are not saved yet.",
  closeable: true
) %>

Guidelines:

  • Prefer ds_* helpers over raw HTML when a DS component already exists
  • Prefer ds_* helpers over writing raw easy8-ds-* tags directly
  • Check the component story for the Rails equivalent in railsCodeTemplate

Mount Vue apps inside Ruby-rendered DS elements

ds_page, ds_page_content and ds_page_columns accept **html_options (class:, id:, data:) on the custom-element host, so a Rails view can render the DS page shell itself and leave mount points ("islands") for Vue apps inside it.

<% root_id = "my-feature-app-#{SecureRandom.uuid}" %>
<div id="<%= root_id %>" class="my-feature-app" data-my-feature-root>
  <%= ds_page(class: "my-feature__page") do %>
    <div data-my-feature-island="header"></div>
    <%= ds_page_content do %>
      <%= ds_page_columns(
            value: [1, 1],
            resize_handle_aria_label: l(:"my_feature.resize_columns"),
            column_contents: [
              content_tag(:div, "", data: { my_feature_island: "main" }),
              content_tag(:div, "", data: { my_feature_island: "side" })
            ]
          ) %>
    <% end %>
  <% end %>
</div>

A DS host loads its component lazily but keeps the original server-rendered light-DOM nodes in place and later moves them into the resolved component's slot outlets without cloning (see Slot content in custom elements). Resolve the island targets synchronously in init, run the feature's own preloads, re-check the root's identity and connectivity, then mount; the islands keep their nodes whether the DS chunks resolve before or after the mount. What projection cannot keep is content the resolved component gives no outlet, or content inside a host whose chunk failed to load: such nodes are detached, so a swept island means a broken page and init gets no signal for it - call destroy from your error handling or accept it.

Guidelines:

  • Generate the root ID per render, not per entity, so the same record can be rendered twice (page and dialog).
  • Select islands by data-* attribute inside the root. If your feature can be nested within itself, mark the root too (data-my-feature-root above) and skip islands whose nearest marked ancestor is a different root.
  • Look mount targets up once, synchronously, in init, and keep the references: projection moves the nodes but never replaces them, so re-querying after the preloads buys nothing.
  • Re-check root identity and connectivity after any asynchronous preload, and expose a destroy(rootId) that unmounts the apps and prevents a pending initialisation from mounting later.
  • Share one Pinia per root across its islands, provide the Apollo client before the first mount, and pass per-island data with app.provide on the island that needs it.
  • Hosts and mount divs are extra boxes in the layout. When a DS grid or a height chain has to reach the island content, scope display: contents to your feature's root class instead of changing the DS stylesheets.
  • Derive element IDs from the root identity (or the DS field-ID generator) so two renders never collide.

issues/show_v2.html.erb with src/issue_detail_v2/main.ts is the reference implementation.

Slot content in custom elements

Registry-defined easy8-* custom elements move their original child nodes into Vue slot outlets (where the component renders a slot). Named slots must match a string or RegExp in the registry entry's slots.

  • Content stays in place while the component loads, then moves into its outlets without cloning.
  • Content without a rendered outlet is detached after mount and returns when a matching outlet appears. Load failures remove slot content.
  • Each slot displays its content once, in its last mounted outlet. If that outlet disappears, the last remaining outlet receives the content; with none left, it is detached until an outlet mounts again.
  • Content removed by external code stays removed across outlet toggles and host detach/reattach.
  • Focus and selection in input and textarea elements are restored after a move; blur/focus events may fire. contenteditable selections are not restored.

For Turbo request preservation and its limits, see Hotwire.

Custom element events

Registry custom elements automatically expose the resolved Vue component's runtime emits as DOM events on the custom-element host. Both array and object declarations are supported, including compiled defineEmits and defineModel. Discovery happens after lazy loading, before the component mounts; registering a tag does not load it. There is no need to duplicate declarations in the registry. Optional registry emits remain additive and are deduplicated with component declarations. Undeclared events are not inferred unless explicitly listed there.

Listen directly on the host, for example on a radio-group element rendered by a Rails helper:

const field = document.querySelector("easy8-ds-radio-group");
field.addEventListener("update:modelValue", event => {
  const [value] = event.detail;
  // Use the selected value in the surrounding form.
});

Ordinary emits follow Vue's custom-element conventions: zero arguments produce an empty event.detail array, one argument produces [value], and multiple arguments produce the full argument array. Values are not serialised. Camel-case event names also dispatch a hyphenated alias; listen to one spelling to avoid handling both. A plain-object first argument can supply CustomEvent options such as bubbles, composed, cancelable, or an explicit detail override, including a data payload that happens to use those key names. Without these options, ordinary events do not bubble, cross shadow boundaries, or support cancellation.

update:modelValue is special: it updates the host adapter's internal model and dispatches one event with [value] detail, bubbles: true, composed: true, and no hyphenated alias. Existing initial-value parsing and coercion still apply.

Named update:* events also mirror the emitted value back onto the matching child prop, which is what keeps a named defineModel working. A value supplied on the host seeds it and is replaced from the first emit onward. Their DOM events retain the ordinary Vue event conventions described above.

If the first emitted argument is an original same-named bubbling DOM event that is still propagating along a path through this host, the adapter does not dispatch a duplicate or Vue's hyphenated alias. The original event keeps its identity, target, and propagation. Non-bubbling, already stopped, undispatched, differently named, or off-host-path Event payloads are forwarded normally. Existing explicit namespaced bridges, including cancelable resize-start events, are unchanged.

Use the design system in Rails forms

For DS-backed forms, prefer easy_form_for or easy_form_tag. They use EasyFormBuilder and render DS form fields automatically.

Example:

<%= easy_form_for model: @project do |f| %>
  <%= ds_grid do %>
    <%= ds_grid_layout(distribution: :cols_2) do %>
      <%= ds_grid_item do %>
        <%= f.text_field :name, required: true %>
      <% end %>

      <%= ds_grid_item do %>
        <%= f.datepicker :start_date, clearable: true %>
      <% end %>
    <% end %>
  <% end %>

  <%= f.check_box :is_public, label: "Public project" %>
  <%= f.submit "Save" %>
<% end %>

Guidelines:

  • Prefer f.* builder methods inside easy_form_for
  • Use ds_grid, ds_grid_layout, and ds_grid_item for multi-field layouts
  • Do not mix classic Rails form helpers with DS form helpers unless there is a real compatibility reason

Tooltip

Tooltip is a framework-independent behaviour attached to an existing anchor. It renders supplemental plain text only; never put HTML, links, controls, essential instructions, or the only indication of state in a tooltip.

Register the Vue directive on each application that uses it:

import { vDsTooltip } from "@/src/design_system";

app.directive("ds-tooltip", vDsTooltip);
<DSButton
  v-ds-tooltip="{
    text: 'Create a new project',
    title: 'Create project',
    kbd: 'Ctrl + N',
    kbdMac: '⌘N',
    placement: 'bottom-start',
  }"
>
  Create
</DSButton>

The directive also accepts a string (v-ds-tooltip="'Create a project'") and placement shorthand such as v-ds-tooltip:top-start="'Create a project'". Set disabled: true, a falsy value, or blank text to remove the behaviour.

For Rails, merge the returned hash into the existing helper's data: option:

<%= link_to "Delete", issue_path(@issue),
            data: ds_tooltip(
              text: "Delete issue",
              kbd: "Del",
              kbd_mac: "⌘⌫",
              placement: :top_start
            ) %>

If the anchor already has data attributes, merge them rather than adding a second data: option:

<%= link_to "Delete", issue_path(@issue),
            data: { turbo_method: :delete }.merge(
              ds_tooltip(text: "Delete issue", placement: :top)
            ) %>

Supported placements are top, top-start, top-end, bottom, bottom-start, bottom-end, left, left-start, left-end, right, right-start, and right-end. Missing, blank, or unknown placement values fall back to bottom. Native CSS Anchor Positioning may flip the tooltip only on its placement axis near an edge.

Tooltip requires Chrome/Edge 144+, Firefox 148+, or Safari 26.2+, with runtime feature detection deciding actual support. Rails usage is guaranteed in v1 only on standard Easy8 base/easy-page layouts that load application JavaScript, design-system CSS, and the data-design-system scope. Unsupported browsers silently omit the enhanced tooltip.

The anchor must be focusable for keyboard users. Native disabled controls do not emit the required events; put the tooltip on a focusable wrapper when a disabled control needs one:

<span v-ds-tooltip="'Saving must finish first'" tabindex="0">
  <DSButton disabled>Save</DSButton>
</span>

Touch long-press is not supported, although a tap that gives the anchor focus can show it. Keep copy concise, normally one or two sentences, and do not repeat a label or description that is already visible.

This API coexists with legacy v-easy-tooltip and existing native-title tooltip APIs. It does not change or migrate them.

CSS variables

The design system relies on CSS variables and shared tokens.

To check whether all used CSS variables are defined, run:

bundle exec rake easy8:design_system_css_variables

What it does:

  • scans SCSS files in app/frontend/src/design_system/
  • checks variables used by DS components
  • compares them with variables defined in app/frontend/src/design_system/shared/stylesheets/
  • reports variables that are used but not defined

Run it after adding or refactoring component styles.

Do not hard-code DS colours or spacing values in component styles; use the shared tokens.