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 componentsapp/frontend/src/shared_modules/: higher-level shared components built on top of the DSapp/easy_design/design_system/: Ruby wrappers and helpers for server-rendered usageeasy_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:
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_systemexports for DS components - Check the matching
*.stories.tsfile before guessing prop names - Prefer shared modules from
app/frontend/src/shared_modules/for higher-level use cases - For entity autocomplete, prefer the shared
Autocompletemodule instead of assemblingDSSelectmanually
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 raweasy8-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-rootabove) 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.provideon 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: contentsto 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
inputandtextareaelements are restored after a move;blur/focusevents may fire.contenteditableselections 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 insideeasy_form_for - Use
ds_grid,ds_grid_layout, andds_grid_itemfor 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:
<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:
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.