Skip to content

Design system

The design system is the set of values and building blocks that give every page of the app one look. The tokens are values. The components are building blocks made of those values.

The styleguide

Run the app in dev and open /styleguide. The page draws every token group and every catalog entry from the real stylesheet. A component appears there when its child card adds it.

The route exists in dev only. In production it does not exist.

The tokens

All tokens live in assets/styles/tokens.css. assets/styles/app.css imports it. A token is a CSS custom property, and the comment above it is its use. A @group comment starts a group.

GroupWhat it holds
colourThe raw colour values, and the Tailwind names for them
typeThe font stacks. The Tailwind text sizes carry the scale
spacingOne step of the spacing scale. Every gap and size is a multiple of it
radiusThe corner radii
shadowThe shadows of surfaces that float above the page
motionThe durations and the easing curves

A new token appears on the styleguide with no other change.

The stylesheets

  1. tokens.css holds the tokens and nothing else.
  2. components/<name>.css holds the styles of one building block. A child card adds each file with its component. components/shell.css holds the app shell, which has no Twig component.
  3. app.css imports both, and holds the rules of the features.

The widget and the email stylesheet cannot import tokens.css. They hold copies of the values they need, and a test keeps each copy equal to the token. In email.css, each copied value names its token in a trailing comment, such as /* --accent */, and EmailTokensMatchAppTest compares the two.

The components

A building block is a Twig component under templates/components/Ds/. Its CSS lives in assets/styles/components/<name>.css, and its entry in src/Module/DesignSystem/Catalog.php lists its root class, variants and states.

ComponentUseVariants
ButtonAny action or link that looks like a buttonprimary, inverse, outline, success, danger, ghost, danger-ghost, icon, compact, open, on-card. Sizes sm and lg
InputA one-line text fieldmono
SelectA native selectnone
TextareaA multi-line text fieldnone
LabelThe text that names a field. It can be a label, legend, p, span or divnone
FormFieldOne field of a form: its label, widget, hint and errors, or a wrapper for your own bodynone
FieldErrorsThe list of errors of one fieldnone
HintA line of help under a fieldnone
FlashA message after an action, with a dismiss buttonsuccess, error, warning, info
EmptyStateA panel that says a list or page has nothing yetnone. Takes an icon, a title, a body and one link
BadgeThe status of a document in a listin-review, draft, approved, changes-requested
TagA short label, such as a card type or a columnneutral, lime, purple, green, amber, red, teal, sky, blue, indigo, pink, orange
StatusChipA state with a coloured dot and an optional reason tooltippending, addressed, resolved, ok, failed, neutral
StateMarkAn icon that names what a card needs now, with its reason in an interactive tooltipstuck, needs-you, working, waiting
StatusBoxThe state of a card with its reason, its facts, what clears it, and the other states that applystuck, needs-you, working, waiting
DialogA modal that the modal Stimulus controller opensdocument, search
TabsA strip of links or tab buttons with an underline, or a pill group that switches a view or a settingsegmented
PaginationThe previous, next and page-number control of a listnone
TooltipA text bubble under its anchorinteractive, which stays open for a link
BrandMarkThe logo square and the brand name. Sizes sm and md. :markOnly="true" draws the square alonenone
NavItemOne link of the sidebar. Props url, icon, label, active and count. Its content adds anything after the labelnone
BreadcrumbsThe trail in the top bar. Prop items, a list of {label, url}. The last item is the current page and takes no link. Its content adds anything after the list, such as a version pill. A page fills the topbar_crumbs block with it and never starts the trail with the project namenone
AuthShellThe frame of the sign-in, sign-up and install pages. It has no Twig component: @Account/auth_base.html.twig draws it with the lp-auth classes, and a page fills the auth_body block. lp-auth__action makes a submit button fill the cardnone
PageHeaderThe heading row of a page: the title, an optional description and a block of actionsworkshop, which gives the title the workshop size
SplitViewA master-detail page: a held header over a list pane beside a detail panenone
ListRowOne row of a list: an icon, a title and a meta linenone. The state current marks the open row
WorkspaceThe page container: the frame that holds the content of a pagenarrow, for a form page, and full, which drops the width cap
SettingsLayoutA settings page: the page header, a section navigation and the content columnnone. The state active marks the open section
DrawerA panel that slides in from the right edge. The modal controller opens itnone

The Drawer takes the named blocks header, body and footer, and draws a band only for a block that holds content. The block content replaces all three, for a drawer that fills the dialog itself, such as the card drawer. Pass the target attribute of the controller that opens it, such as data-modal-target="dialog". A drawer that loads a Turbo frame on opening pairs the modal controller with lazy-frame, which shows the loading and error states around the frame. A controller with more states than that calls LazyFrame from assets/lib/lazy_frame.js. Its CSS is assets/styles/components/drawer.css.

A Ds:NavItem icon is a prop, so bin/console ux:icons:lock cannot read it. Import a new sidebar icon by hand with bin/console ux:icons:import lucide:<name>.

The PageHeader takes title, description, variant and titleClass, and a named actions block that draws after the title block. Its CSS is assets/styles/components/page-header.css. It also holds the lp-workspace-title, lp-workshop-title and lp-workspace-desc rules.

<twig:Ds:PageHeader :title="'documents.title'|trans" :description="'documents.description'|trans">
<twig:block name="actions"><twig:Ds:Button variant="primary">New</twig:Ds:Button></twig:block>
</twig:Ds:PageHeader>

The SplitView draws the page root, lp-split-page, and takes any attribute, such as a Stimulus data-controller. The page writes the lp-split-region, lp-split-view and lp-split-pane classes inside it by hand, because each page puts its own controller on the region. A page with a header writes the header first, then the region. Its CSS is assets/styles/components/split-view.css.

<twig:Ds:SplitView width="full">
<div class="lp-split-region">
<div class="lp-split-view">
<div class="lp-split-pane">{# the list #}</div>
<div class="lp-split-pane">{# the detail #}</div>
</div>
</div>
</twig:Ds:SplitView>

The Workspace takes width, which is default, narrow or full, and wraps the whole page body. Its CSS is assets/styles/components/workspace.css. A page keeps its own marker class beside it, such as lp-redesign-settings, for rules that style page-only parts. A page never restyles a design system part: write no rule such as .lp-redesign-settings .lp-btn. A split page passes width to the SplitView instead, which then draws the same frame.

<twig:Ds:Workspace width="narrow" class="lp-redesign-settings">
<twig:Ds:PageHeader :title="'rename.title'|trans" />
</twig:Ds:Workspace>

The shell has one width option. A page whose frame must reach the window edge, such as the board, sets {% block shell_width %}full{% endblock %}, which adds lp-shell--full. A page that needs a different ground sets {% block main_surface %} to paper (white) or fill (a column that clips its overflow).

The ListRow takes title, tag and current, and the named blocks icon and meta. Other attributes go to the row. An attribute with an icon: prefix goes to the icon span. The lp-list-row__flag class marks one item of the meta line, such as a blocking mark. Its CSS is assets/styles/components/list-row.css.

<twig:Ds:ListRow :title="row.title" :current="true" href="{{ path('app_home') }}" icon:data-kind="question">
<twig:block name="icon"><twig:ux:icon name="lucide:circle-help" /></twig:block>
<twig:block name="meta"><span>Loupe</span></twig:block>
</twig:Ds:ListRow>

The SettingsLayout takes title, description, items, navLabel and columns, and its default block holds the content. Each item is a hash of label, url, icon and active. columns gives the content its own column width. The component renders the PageHeader, so a page never writes the heading again. Its CSS is assets/styles/components/settings-layout.css. Write each panel of the content as a lp-settings-panel-card section inside the block.

@Account/_settings_layout.html.twig and @Project/_settings_layout.html.twig build the items of the account and the project settings and render the component. A settings page extends one of them and fills the settings_section and settings_content blocks. The icon names of the items are plain strings, so bin/console ux:icons:lock cannot read them. Import a new one with bin/console ux:icons:import.

Write a button like this:

<twig:Ds:Button variant="primary" size="sm" type="submit">Save</twig:Ds:Button>
<twig:Ds:Button variant="ghost" href="{{ path('app_home') }}">Back</twig:Ds:Button>

The component passes every other attribute to the element and appends your class. An href renders a link. A Symfony form button draws its own tag, so give it the classes with ds_button_class('primary'):

{{ form_widget(form.save, {attr: {class: ds_button_class('primary')}}) }}

Write the feedback parts like this:

<twig:Ds:Flash severity="error" :dismissLabel="'flash.dismiss'|trans">{{ message }}</twig:Ds:Flash>
<twig:Ds:EmptyState icon="lucide:inbox" :title="'x.empty'|trans" :body="'x.empty.body'|trans" />
<twig:Ds:Badge :status="document.status.value">{{ label }}</twig:Ds:Badge>
<twig:Ds:Tag tone="amber">{{ label }}</twig:Ds:Tag>
<twig:Ds:StatusChip modifier="ok" :label="'x.state'|trans" :reason="reason" />

A Tag takes as="li" inside a list. A StatusChip takes :dot="false" for no dot, and as="button" for a chip that toggles a panel.

Write a dialog, tabs, pagination and a tooltip like this:

<twig:Ds:Dialog size="document" aria-labelledby="edit-title" data-action="cancel->modal#close">
<h3 id="edit-title" class="lp-dialog-title">Edit</h3>
</twig:Ds:Dialog>
<twig:Ds:Tabs class="lp-analytics-tabs" aria-label="Sections">
<a class="lp-tabs__tab" href="{{ path('app_home') }}" aria-current="page">Home</a>
</twig:Ds:Tabs>
<twig:Ds:Pagination route="app_projects" :page="page" :totalPages="totalPages" :pageList="pageList" />
<twig:Ds:Tooltip id="why-1">The reason.</twig:Ds:Tooltip>

The dialog sets data-modal-target="dialog" itself. The tabs take tag="div" for a tab list that is not a navigation, and variant="segmented" for a pill group. A segmented switch that changes a setting uses buttons with aria-pressed. The pagination draws nothing when there is one page. The feature that owns the anchor decides when a tooltip shows. A tooltip that holds a link takes :interactive="true", and its anchor needs the interactive-tooltip controller, so use a StateMark for it.

Write the state of a card like this:

<twig:Ds:StateMark kind="stuck" :label="'board.card_state.stuck'|trans">{{ reason }}</twig:Ds:StateMark>
<twig:Ds:StatusBox kind="stuck" :title="'board.card_state.status'|trans" :chip="chip" :lead="lead" :fields="fields" :others="others" :othersTitle="'board.card_state.others'|trans" />

kind is stuck, needs-you, working or waiting for both blocks. The content of a StateMark is its tooltip. The chip of a StatusBox holds the state and how long it has held. Each entry of fields has a label and a value, and wide: true gives a sentence the full width. A StatusBox takes fields, others and othersTitle as optional props, and draws nothing for a prop that is empty.

The form parts

FormField draws a Symfony form field with its label, widget, hint and errors. Pass kind for a select or a textarea, and attr for the attributes of the widget:

<twig:Ds:FormField :fieldView="form.title" :widgetAttr="{placeholder: 'Title'}" hintText="Shown on the board" />
<twig:Ds:FormField :fieldView="form.color" widgetKind="select" />

Give FormField no field and it wraps your own body. as="fieldset" draws a fieldset.

A plain field uses Input, Select or Textarea. A Symfony widget in a custom layout takes the classes from functions, because it draws its own tag:

{{ form_label(form.name, null, {label_attr: {class: ds_label_class()}}) }}
{{ form_widget(form.kind, {attr: {class: ds_input_class('select')}}) }}
<twig:Ds:FieldErrors :fieldView="form.kind" data-field-errors="kind" />

just gamache blocks a template that writes lp-input, lp-select, lp-textarea, lp-label, lp-form-field, lp-field-errors or lp-form-hint by hand.

The page parts

A page part is the frame of a page. It sits in the layout group of the catalog. The catalog entry names its root class, and its export wrapper draws the inner structure.

PartUseRoot class
AppShellThe frame of every signed-in page: the sidebar, the top bar and the main columnlp-app

templates/base.html.twig draws the app shell. It stays there, because the sidebar reads the current user, the current project, the feature flags and the routes. The CSS lives in assets/styles/components/shell.css, which app.css and design-system.css both import.

The export wrapper in templates/Module/DesignSystem/export/layout/AppShell/ is a second copy of that markup. It holds the nav links with their English labels and draws the icons as inline SVG. ExportDesignSystemHandlerTest keeps the copy in step: every shell class and every nav.link.* key of base.html.twig must appear in the wrapper. The wrapper must also hide each part that base.html.twig shows only inside {% if project %}: a sidebar part behind hasProject, and a top bar part behind onProjectPage. It must hide each link that base.html.twig gates with is_feature_enabled() or is_granted('ROLE_ADMIN') too. Change both files when you change the shell markup.

The wrapper takes these props: projectName, projectPage, active (the name of the current nav link), crumbs, meta, actions, userName, counts, projects, features and admin. The app reads two projects. The sidebar shows sidebar_project(), which is the last project visited on a page outside a project. The top bar reads current_project(), which is null there. projectName is the sidebar project, and projectPage={false} hides New work on a page outside a project. Pass projectName={null} when there is no sidebar project. The wrapper then also hides the project switcher, the project nav and Project settings. features names the flags that are on, such as { inbox: true }, and each flag is off by default. admin shows the Admin link. projects lists the projects of the switcher as { name, documentCount } entries, and the entry named projectName gets the current marker. The page goes in as the children:

<AppShell projectName="Acme" active="board" crumbs={['Acme', 'Board']} userName="riley@example.com">
<h1>Board</h1>
</AppShell>

These are the documented shell classes.

ClassWhat it is
lp-appThe root. A flex row that fills the window, with the dark chrome colour
lp-sidebarThe left rail. lp-sidebar--drawer makes it a slide-in drawer below the lg breakpoint, and lp-sidebar--open opens it
lp-sidebar__closeThe button that closes the drawer
lp-sidebar__brandThe link around the Ds:BrandMark logo lockup
lp-sidebar__switcher and its __avatar, __text, __label, __name, __caret partsThe button that shows the current project
lp-switcherThe wrapper of the switcher button and its panel
lp-switcher__panelThe list of projects that drops under the button
lp-switcher__item and its __avatar, __text, __name, __count, __check partsOne project in the panel
lp-switcher__all and lp-switcher__all-glyphThe link to all projects
lp-sidebar__navA group of links. lp-sidebar__nav--secondary is the group at the bottom
lp-sidebar__nav-labelThe heading of a group. --spaced adds space above it
lp-sidebar__linkOne nav link. --active marks the current page, and __label holds its text
lp-sidebar__pillA count at the end of a link. --amber is the colour for something that waits on you
lp-sidebar__spacerPushes the bottom group down
lp-sidebar__account and its __avatar, __text, __name, __logout-form, __logout partsThe signed-in user and the log out button
lp-scrimDims the page behind an open drawer
lp-shellThe column to the right of the sidebar. It holds the top bar and the main column. --full drops the frame inset
lp-topbarThe bar above the page
lp-topbar__menuThe button that opens the drawer
lp-topbar__lead, __trail, __crumb, __sep, __here and __metaThe breadcrumb and the short line after it
lp-topbar__global-actionsThe tools at the right end of the bar
lp-topbar__new-workThe New work button. It is a Ds:Button with this class added
lp-mainThe main column. It is the only scroll container of the page. --paper gives it a white ground, and --fill makes it a column that clips its overflow

The marketing shell

A public page for a signed-out visitor extends templates/marketing_base.html.twig. The base draws the header, the page body and the footer. A page fills block body, or swaps marketing_header, marketing_main and marketing_footer when it draws its own, as the home page does. A signed-in visitor gets the app shell instead.

The shell is a base template with page blocks, so it has no Twig component. Its catalog entry has no template, and the lp-landing classes are documented here.

Every public footer takes the privacy, terms and AI policy links from templates/_legal_links.html.twig:

{% include '_legal_links.html.twig' with {class: 'lp-landing-footer__link'} only %}
{% include '_legal_links.html.twig' with {class: 'lp-home-footer__link', listItem: true} only %}

The sign-in shell

Every sign-in, sign-up, password reset, install and consent page extends @Account/auth_base.html.twig. The base draws the topbar with the brand mark, one centred card and the footer, through the classes of assets/styles/components/auth-shell.css. The footer lives in @Account/_auth_footer.html.twig, which the styleguide specimen also draws. A page fills the blocks title, topbar_nav and auth_body. A page that needs a wider card sets {% block auth_shell_class 'lp-auth__card lp-auth__card--wide' %}.

The forms in the card use the app form parts, Ds:FormField, Ds:Input, Ds:Label and Ds:Button, like any other form. Give the submit button class="lp-auth__action" so it fills the card.

The email layout

Every email extends templates/email/_layout.html.twig. The layout holds the stylesheet inlining, the doctype, the brand, the card and the footer. An email fills three blocks: title, content and footer.

{% extends 'email/_layout.html.twig' %}
{% block title %}{{ 'x.email.title'|trans }}{% endblock %}
{% block content %}
<h1>{{ 'x.email.heading'|trans }}</h1>
<a href="{{ url }}" class="btn">{{ 'x.email.cta'|trans }}</a>
{% endblock %}
{% block footer %}
{{ 'x.email.footer'|trans }}
{% endblock %}

An email has no catalog entry or styleguide specimen. It gets its styles from email.css and holds its own copy of the brand mark, because no app stylesheet reaches an email client.

The Claude Design copy

A Claude Design project holds a copy of the design system. The app repository is the source of the copy.

Run this command to write the copy into a directory:

Terminal window
bin/console app:design-system:export <dir>

The command prints each path it wrote, relative to the directory, one per line.

PathWhat it holds
tokens/*.cssThe tokens, split by group from tokens.css, and the fonts and base resets
styles.cssThe Tailwind build of assets/styles/design-system.css, with the real lp- rules
components/<group>/A React wrapper, a type file and a prompt file for each catalog entry, and one card for the group
guidelines/One card for each token group
readme.md and SKILL.mdThis page, and the skill that points at it
assets/fonts/The font files and their licence

The wrappers write the same class names as the Twig components. A wrapper adds no style of its own.

A part with an inner structure, such as a sidebar, sets export on its catalog entry and goes in the layout group. The value names a folder under templates/Module/DesignSystem/export/layout/. The folder holds the hand-written component.jsx.twig, component.d.ts.twig, component.prompt.md.twig and demo.jsx.twig of the part. The export renders those files in place of the generic templates. demo.jsx.twig draws the part in the layout group card. An entry with no Twig component sets template to null.

The command never writes _ds_bundle.js, _ds_manifest.json, _adherence.oxlintrc.json, .thumbnail or ds-mount.js. The platform makes those files.

The command does not copy icons. A wrapper that needs icons draws them as inline SVG, as the AppShell wrapper does.

The rules

  1. Write no arbitrary value. Use a token or a named Tailwind scale step. NoArbitraryValuesCheck enforces this in just gamache.
  2. Snap an off-scale value to the nearest token. A change to a token is a change to the system.
  3. Use a building block from the catalog. Write no raw markup for a block that the catalog lists.

Read project-frontend for the full conventions of CSS, Stimulus and Turbo.

just gamache blocks a template that writes lp-btn, lp-flash, lp-empty-state, lp-badge, lp-tag, lp-status-chip, lp-dialog, lp-tabs, lp-pagination, lp-tooltip, lp-brand or lp-drawer by hand.