Canvas Design System
Main Site Tokens

Composition Examples

Screen recipes first: whole screens built from real components, each with its loading, empty and error states. Then smaller pairs of components that often sit together. Every example renders the production helpers from fixture data, and the layout classes come from recipes.css, which uses tokens only.

Screen recipes

Start here when you are building a page rather than one component. npm run ds:find -- "a page listing workshops with filters" and the enforcer's suggest_component return these for screen-level requests. Each state has its own preview address, so you can open it alone and resize the window.

Filtered workshop list

A page listing workshops (or any library items) that people narrow down with search and filters, with loading, no-match and failed-load states.

Open the default state on its own to try it at any width.

Why these components

ws_filter_bar owns search, selects and active chips so filters look the same in every app. ws_skeleton's workshop variant mirrors the card shape, and sitting in the same ws_grid as the cards it keeps their columns too, so nothing jumps when data arrives. A stack spaces the page and each card; a cluster keeps the duration and category together. ws_empty_state gives the no-match case a way out (Show all workshops, which names where it goes), and ws_alert keeps the failed load inline beside the filters rather than in a modal. Inside /app/ the saved-workshop list is ws_workshop_list, which adds views and kanban.

On mobile

Below 768px the filter bar stacks the search field and each select full width. The cards and their loading placeholders share one ws_grid, so both fit as many columns as --grid-min-md allows and reach one column on a phone together; nothing moves when the cards arrive.

Components used

PHP

The whole recipe file, design-system/recipes/filtered-workshop-list.php. Swap the fixture arrays for the caller's own records and keep the four states.

<?php
/**
 * Recipe: filtered workshop list.
 *
 * Needs includes/components/helpers.php and includes/components/components.css,
 * which styles the layout helpers. $state is
 * default | loading | empty | error. The arrays are fixtures: a real page reads
 * the caller's own plans (WHERE user_id = ?) and passes the chosen filters back
 * as `value` and `activeChips`.
 */
$state = $state ?? 'default';

$workshops = [
    ['title' => 'Quarterly planning offsite', 'duration' => '3 hours', 'category' => 'Strategy', 'href' => '#plan-101'],
    ['title' => 'Sprint retrospective', 'duration' => '60 min', 'category' => 'Retrospective', 'href' => '#plan-102'],
    ['title' => 'New team kickoff', 'duration' => '90 min', 'category' => 'Team building', 'href' => '#plan-103'],
];
$filtered = $state === 'empty';

// ws_filter_bar uses each label as the select's empty "any" option, so the lists hold real values only.
$filters = [
    ['name' => 'category', 'label' => 'Category', 'value' => $filtered ? 'energiser' : '', 'options' => [
        'strategy' => 'Strategy', 'retrospective' => 'Retrospective', 'team' => 'Team building', 'energiser' => 'Energiser',
    ]],
    ['name' => 'length', 'label' => 'Length', 'value' => '', 'options' => [
        'short' => 'Under an hour', 'half' => 'Half a day', 'day' => 'A full day',
    ]],
];
?>
<?php ws_stack_start(['gap' => '5']) ?>
    <?= ws_section_header('Your workshops', [
        'tag' => 'h2',
        'align' => 'left',
        'id' => 'your-workshops',
        'description' => 'Filter by category or length to find the one you want to run next.',
    ]) ?>

    <?= ws_filter_bar($filters, [
        'search' => ['placeholder' => 'Search your workshops', 'name' => 'q'],
        'activeChips' => $filtered ? [['label' => 'Energiser', 'name' => 'category', 'value' => 'energiser']] : [],
    ]) ?>

    <?php if ($state === 'loading'): ?>
        <p class="ws-visually-hidden" role="status">Loading your workshops</p>
        <?php // The placeholders sit in the same grid as the cards, so nothing moves when the cards arrive. ?>
        <?php ws_grid_start() ?>
            <?= ws_skeleton(['variant' => 'workshop', 'count' => 3]) ?>
        <?php ws_grid_end() ?>

    <?php elseif ($state === 'error'): ?>
        <?= ws_alert('Your filters are kept. Try again in a moment.', [
            'variant' => 'error',
            'title' => 'Your workshops did not load',
            'actions' => [['text' => 'Try again', 'href' => '#your-workshops']],
        ]) ?>

    <?php elseif ($state === 'empty'): ?>
        <?= ws_empty_state('No workshops match these filters', [
            'description' => 'Search for a different word, or widen a filter.',
            'icon' => 'search_off',
            'action' => 'Show all workshops',
            'actionHref' => '?',
            'actionVariant' => 'secondary',
        ]) ?>

    <?php else: ?>
        <?php ws_grid_start(['tag' => 'ul']) ?>
            <?php foreach ($workshops as $workshop): ?>
                <li>
                    <?php ws_card_start(['variant' => 'content', 'bordered' => true]) ?>
                        <?php ws_stack_start(['gap' => '3']) ?>
                            <?= ws_text($workshop['title'], ['variant' => 'title']) ?>
                            <?php ws_cluster_start(['gap' => '2']) ?>
                                <?= ws_duration($workshop['duration']) ?>
                                <?= ws_category($workshop['category']) ?>
                            <?php ws_cluster_end() ?>
                            <?= ws_button('Open', ['variant' => 'secondary', 'size' => 'sm', 'href' => $workshop['href'],
                                // Three links all read "Open" out of context (a screen reader's links list), so each names its workshop.
                                'attrs' => ['aria-label' => 'Open ' . $workshop['title']]]) ?>
                        <?php ws_stack_end() ?>
                    <?php ws_card_end() ?>
                </li>
            <?php endforeach; ?>
        <?php ws_grid_end() ?>
    <?php endif; ?>
<?php ws_stack_end() ?>

Settings form

A settings or preferences page: a short form of saved choices with a select and text fields, a save action, a switch that applies at once, and first-time, loading and validation-error states.

Open the default state on its own to try it at any width.

Why these components

ws_input and ws_select carry their own labels, hints and error wiring (aria-describedby, aria-invalid), so the form needs no hand-written label markup. One ws_alert at the top says the save failed while the field error says why. The form is a ws_stack and its actions a ws_cluster led by Save, aligned left with the fields (DESIGN.md 11.6). ws_toggle sits in its own card outside the form because a switch must apply the moment it is flipped; inside a form that saves on submit it would look saved when it is not.

On mobile

Fields are full width at every size and the actions cluster wraps, so Save stays reachable. Nothing sits side by side, so the page reads the same on a phone; the switch keeps its own card below the form.

Components used

PHP

The whole recipe file, design-system/recipes/settings-form.php. Swap the fixture arrays for the caller's own records and keep the four states.

<?php
/**
 * Recipe: settings form.
 *
 * Needs includes/components/helpers.php and includes/components/components.css,
 * which styles the layout helpers. $state is
 * default | loading | empty | error. $saved is a fixture: a real page loads the
 * caller's own settings row and posts back to an endpoint that re-checks
 * ownership and a CSRF token.
 */
$state = $state ?? 'default';

$saved = $state === 'empty' ? [] : [
    'workshop_length' => '90',
    'reminder_email' => 'facilitator@example.com',
    'send_reminders' => true,
];
$errors = $state === 'error' ? ['reminder_email' => 'Enter an email address like name@example.com.'] : [];
$lengths = ['60' => '60 minutes', '90' => '90 minutes', '180' => 'Half a day', '480' => 'A full day'];
?>
<?php ws_stack_start(['gap' => '5']) ?>
    <?= ws_section_header('Workshop defaults', [
        'tag' => 'h2',
        'align' => 'left',
        'description' => 'New workshops start with these. You can change any of them per workshop.',
    ]) ?>

    <?php if ($state === 'loading'): ?>
        <p class="ws-visually-hidden" role="status">Loading your settings</p>
        <?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
            <?php ws_stack_start(['gap' => '5']) ?>
                <?= ws_skeleton(['variant' => 'input']) ?>
                <?= ws_skeleton(['variant' => 'input']) ?>
                <?= ws_skeleton(['variant' => 'button']) ?>
            <?php ws_stack_end() ?>
        <?php ws_card_end(); ?>
        <?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
            <?= ws_skeleton(['variant' => 'text', 'lines' => 2]) ?>
        <?php ws_card_end(); ?>

    <?php else: ?>
        <?php if ($errors): ?>
            <?= ws_alert('Fix the highlighted field, then save again. Nothing else was lost.', [
                'variant' => 'error',
                'title' => 'Your settings were not saved',
            ]) ?>
        <?php elseif (!$saved): ?>
            <?= ws_alert('Nothing is saved yet. Choose your defaults and save them once.', [
                'variant' => 'info',
            ]) ?>
        <?php endif; ?>

        <?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
            <?php ws_stack_start(['tag' => 'form', 'gap' => '5', 'attrs' => ['method' => 'post', 'action' => '#settings']]) ?>
                <?= ws_select('workshop_length', $lengths, [
                    'label' => 'Default length',
                    'value' => $saved['workshop_length'] ?? '',
                    'placeholder' => 'Choose a length',
                ]) ?>
                <?= ws_input('reminder_email', [
                    'type' => 'email',
                    'label' => 'Send reminders to',
                    'value' => $state === 'error' ? 'facilitator@example' : ($saved['reminder_email'] ?? ''),
                    'placeholder' => 'name@example.com',
                    'hint' => 'We email you the day before each workshop.',
                    'error' => $errors['reminder_email'] ?? null,
                    'autocomplete' => 'email',
                ]) ?>
                <?php // DESIGN.md 11.6: the primary action leads, aligned left with the fields. ?>
                <?php ws_cluster_start() ?>
                    <?= ws_button('Save settings', ['type' => 'submit']) ?>
                    <?= ws_button('Cancel', ['variant' => 'ghost', 'href' => '#settings']) ?>
                <?php ws_cluster_end() ?>
            <?php ws_stack_end() ?>
        <?php ws_card_end(); ?>

        <?php // A switch must take effect when flipped, so it sits outside the form: a real page posts it on change and confirms with wsToast(). ?>
        <?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
            <?= ws_toggle('send_reminders', [
                'label' => 'Email me a reminder',
                'description' => 'Changes as soon as you flip it. Turn it off if your calendar already reminds you.',
                'checked' => $saved['send_reminders'] ?? false,
            ]) ?>
        <?php ws_card_end(); ?>
    <?php endif; ?>
<?php ws_stack_end() ?>

Confirm before a destructive action

A flow that deletes or permanently removes something: the danger action, a confirmation step naming exactly what goes, an in-progress state, and a failure that says nothing was lost.

Open the loading state on its own to try it at any width.

Why these components

ws_modal traps focus and restores it, which a confirmation needs; a toast or side panel would let the action slip past. ws_modal_footer puts the safe choice first and the danger button last with the danger variant. The dialog names the thing and what goes with it. The error keeps the dialog open with a ws_alert rather than closing on failure.

On mobile

Below 600px the small dialog becomes a bottom sheet: full width, rounded top, a drag handle. The footer keeps Keep workshop before Delete, so the safe choice is read first.

Components used

PHP

The whole recipe file, design-system/recipes/destructive-confirmation.php. Swap the fixture arrays for the caller's own records and keep the four states.

<?php
/**
 * Recipe: confirm before a destructive action.
 *
 * Needs includes/components/helpers.php, includes/components/components.js
 * (data-modal-open and data-modal-close) and includes/components/components.css,
 * which styles the layout helpers. $state is default | loading | empty | error.
 * $workshop is a fixture: the real delete re-checks ownership server-side
 * (WHERE id = ? AND user_id = ?) and only then removes anything.
 */
$state = $state ?? 'default';

$workshop = $state === 'empty' ? null : [
    'title' => 'Leadership offsite 2026',
    'items' => 14,
    'updated' => '28 September 2026',
];
$busy = $state === 'loading';
?>
<?php ws_stack_start(['gap' => '5']) ?>
    <?php if (!$workshop): ?>
        <?= ws_empty_state('No workshop selected', [
            'description' => 'Choose a workshop from your list. Delete appears once one is selected.',
            'icon' => 'inbox',
        ]) ?>
        <?= ws_button('Delete workshop', ['variant' => 'danger', 'icon' => 'delete', 'disabled' => true]) ?>

    <?php else: ?>
        <?= ws_card(
            '<h3>' . htmlspecialchars($workshop['title']) . '</h3>'
            . '<p>' . (int) $workshop['items'] . ' agenda items, last edited ' . htmlspecialchars($workshop['updated']) . '.</p>'
            . ws_button('Delete workshop', ['variant' => 'danger', 'icon' => 'delete', 'attrs' => ['data-modal-open' => 'confirm-delete']]),
            ['variant' => 'content', 'bordered' => true]
        ) ?>

        <?php
        $body = '';
        if ($state === 'error') {
            $body .= ws_alert('The workshop is still here. Check your connection and try again.', [
                'variant' => 'error',
                'title' => 'Delete did not finish',
            ]);
        }
        $body .= '<p>Delete <strong>' . htmlspecialchars($workshop['title']) . '</strong> and its '
               . (int) $workshop['items'] . ' agenda items? This cannot be undone.</p>';
        // ws_modal_footer forwards attrs, not loading or disabled, so the busy state sets them as attributes.
        $body .= ws_modal_footer([
            'cancel' => ['text' => 'Keep workshop', 'attrs' => $busy ? ['disabled' => 'disabled'] : ['data-modal-close' => 'confirm-delete']],
            'primary' => [
                'text' => $busy ? 'Deleting' : ($state === 'error' ? 'Try again' : 'Delete workshop'),
                'variant' => 'danger',
                'icon' => 'delete',
                'attrs' => $busy ? ['disabled' => 'disabled', 'aria-busy' => 'true'] : [],
            ],
        ]);
        echo ws_modal($body, [
            'id' => 'confirm-delete',
            'title' => 'Delete this workshop?',
            'size' => 'sm',
            'closable' => !$busy,
            'backdrop' => !$busy,
        ]);
        ?>
    <?php endif; ?>
<?php ws_stack_end() ?>

Component pairs

Smaller combinations that recur inside screens. Each renders the real helpers; copy the PHP beneath it.

Card + Avatar + Badge

A user profile or team member card. A category badge takes its colour from the category's own record; with none it stays neutral.

JD

Jane Doe

Lead Facilitator

Active
Design Sprint Retro 120+ hours
<?php ws_card_start(['variant' => 'content', 'bordered' => true]); ?>
  <div class="ds-recipe-person">
    <?= ws_avatar('Jane Doe', ['size' => 'md']) ?>
    <div class="ds-recipe-person__text">
      <p class="ds-recipe-person__name">Jane Doe</p>
      <p class="ds-recipe-person__role">Lead Facilitator</p>
    </div>
    <?= ws_status('Active', 'success') ?>
  </div>
  <?php ws_cluster_start(['gap' => '2']) ?>
    <?= ws_category('Design Sprint') ?>
    <?= ws_category('Retro') ?>
    <?= ws_duration('120+ hours') ?>
  <?php ws_cluster_end() ?>
<?php ws_card_end(); ?>

Section Header + Feature Cards

A feature/benefits section on a landing page.

Everything you need to run
world-class workshops.

Plan the agenda, run the room and keep what happened, in one place.

Curated library

Browse workshops, exercises and icebreakers.

Agenda planner

Drag activities into a timed agenda with breaks.

Live facilitation

Run the session with timers and participant reactions.

<?php ws_stack_start(['gap' => '6']) ?>
  <?= ws_section_header('Everything you need to run', [
      'subtitle' => 'world-class workshops.',
      'description' => 'Plan the agenda, run the room and keep what happened, in one place.',
  ]) ?>
  <?php ws_grid_start() ?>
    <?= ws_feature_card('Curated library', 'Browse workshops, exercises and icebreakers.', ['icon' => 'menu_book']) ?>
    <?= ws_feature_card('Agenda planner', 'Drag activities into a timed agenda with breaks.', ['icon' => 'calendar_month']) ?>
    <?= ws_feature_card('Live facilitation', 'Run the session with timers and participant reactions.', ['icon' => 'groups']) ?>
  <?php ws_grid_end() ?>
<?php ws_stack_end() ?>

Alert + Form Fields

A form with a validation error. The alert says the submit failed; the field's own error says why. ws_input carries its label and error wiring, so it needs no ws_form_row around it.

<?php ws_stack_start(['tag' => 'form', 'gap' => '5', 'attrs' => ['method' => 'post']]) ?>
  <?= ws_alert('Fix the highlighted field, then submit again.', ['variant' => 'error', 'title' => 'Check the form']) ?>
  <?= ws_input('name', ['label' => 'Name', 'required' => true, 'autocomplete' => 'name']) ?>
  <?= ws_input('email', [
      'type' => 'email', 'label' => 'Email', 'required' => true, 'autocomplete' => 'email',
      'error' => 'Enter an email address like name@example.com.',
  ]) ?>
  <?php ws_cluster_start() ?>
    <?= ws_button('Submit', ['type' => 'submit']) ?>
  <?php ws_cluster_end() ?>
<?php ws_stack_end() ?>

Breadcrumb + Section Header

Detail page intro pattern.

Design Sprint
by Jake Knapp

A 5-day process for answering critical business questions through design, prototyping, and testing.

Strategy 5 days Popular
<?php ws_stack_start(['gap' => '5']) ?>
  <?= ws_breadcrumb([
      ['label' => 'Home', 'href' => '/'],
      ['label' => 'Workshops', 'href' => '/library/workshops/'],
      ['label' => 'Design Sprint'],
  ]) ?>
  <?= ws_section_header('Design Sprint', [
      'subtitle' => 'by Jake Knapp',
      'description' => 'A 5-day process for answering critical business questions.',
      'align' => 'left',
  ]) ?>
  <?php ws_cluster_start(['gap' => '2']) ?>
    <?= ws_category('Strategy') ?>
    <?= ws_duration('5 days') ?>
    <?= ws_status('Popular', 'success') ?>
  <?php ws_cluster_end() ?>
<?php ws_stack_end() ?>

Empty State + Action

When a list has no results.

No workshops found

Try adjusting your search or browse all workshops.

<?= ws_empty_state('No workshops found', [
    'description' => 'Try adjusting your search or browse all workshops.',
    'icon' => 'search_off',
    'action' => 'Browse All Workshops',
    'actionHref' => '/library/workshops/',
]) ?>

Tabs + Content Panels

ws_tabs renders the tab row only. Give each panel id="{tab id}-panel" and the ws-tab-pane class; components.js moves is-active between them.

A two-hour session to agree the quarter's priorities.

Warm-up, context, three decision rounds, wrap-up.

Three notes from the last run.

<?= ws_tabs([
    ['id' => 'overview', 'label' => 'Overview', 'active' => true],
    ['id' => 'agenda',   'label' => 'Agenda'],
    ['id' => 'notes',    'label' => 'Notes', 'count' => 3],
], ['variant' => 'underline']) ?>

<div class="ws-tab-pane is-active" id="overview-panel" role="tabpanel" aria-label="Overview">
  <?= ws_card('<p>Workshop overview content...</p>', ['variant' => 'content']) ?>
</div>
<div class="ws-tab-pane" id="agenda-panel" role="tabpanel" aria-label="Agenda">
  <?= ws_card('<p>Agenda timeline...</p>', ['variant' => 'content']) ?>
</div>

Stats + Checklist

Marketing social proof section. A stat's color takes a token, never a hex.

50+ Workshops
120+ Exercises
  • Pre-built agendas for every session
  • A library of exercises and icebreakers
  • Timers and run mode for the live session
  • Free to get started
<div class="ds-recipe-split">
  <?= ws_stats([
      ['value' => '50+', 'label' => 'Workshops', 'color' => 'var(--brand-ink)'],
      ['value' => '120+', 'label' => 'Exercises', 'color' => 'var(--brand-ink)'],
  ]) ?>
  <?= ws_checklist([
      'Pre-built agendas for every session',
      'A library of exercises and icebreakers',
      'Timers and run mode for the live session',
      'Free to get started',
  ]) ?>
</div>