---
name: drupal-theme
description: 'Build and modify s360-base-theme Drupal themes. Use when: creating Twig templates, writing preprocess functions in .theme files, defining libraries in split libraries/*.libraries.yml files, attaching CSS/JS assets, writing Drupal behaviors in JavaScript, using BEM methodology, implementing template suggestions, using s360-scss-toolkit functions, using ui-core mixins, writing component SCSS with @use, Drupal JavaScript once API, adding new components to the ui/ workspace.'
---

# Drupal Theme Development — s360-base-theme

## Theme Architecture

Square360 themes are based on `s360-base-theme`. Source lives in the `ui/` Yarn workspace, compiled to `ui/dist/`, and registered in split `libraries/*.libraries.yml` files.

**Source:** `https://github.com/Square360/s360-base-theme`

```
web/themes/custom/{theme}/
├── {theme}.info.yml
├── {theme}.theme               ← preprocess functions only
├── libraries/
│   ├── {theme}.paragraph.libraries.yml
│   ├── {theme}.node.libraries.yml
│   ├── {theme}.navigation.libraries.yml
│   ├── {theme}.component.libraries.yml
│   ├── {theme}.field.libraries.yml
│   ├── {theme}.form.libraries.yml
│   ├── {theme}.media.libraries.yml
│   ├── {theme}.block.libraries.yml
│   ├── {theme}.taxonomy.libraries.yml
│   └── {theme}.views.libraries.yml
├── templates/                  ← Drupal .html.twig files only (extends ui/ component twigs)
│   ├── node/
│   ├── paragraph/
│   ├── navigation/
│   └── ...
└── ui/                         ← Yarn workspace (webpack + Storybook source)
    ├── src/
    ├── dist/
    ├── .storybook/
    ├── webpack.config.js
    └── package.json
```

---

## Libraries — Split File Pattern

Libraries are split by entity type. Each component gets its own library entry. **Never** put all libraries in a single `{theme}.libraries.yml`.

```yaml
# libraries/{theme}.paragraph.libraries.yml
paragraph.card:
  css:
    component:
      ui/dist/paragraph/card/paragraph.card.css: {}

paragraph.hero:
  css:
    component:
      ui/dist/paragraph/hero/paragraph.hero.css: {}
  js:
    ui/dist/paragraph/hero/paragraph.hero.js: { attributes: { defer: true } }
  dependencies:
    - core/drupal
    - core/once
```

```yaml
# libraries/{theme}.navigation.libraries.yml
menu.main:
  css:
    component:
      ui/dist/navigation/main/menu.main.css: {}
  js:
    ui/dist/navigation/main/menu.main.js: { attributes: { defer: true } }
  dependencies:
    - core/drupal
```

Attach in Twig: `{{ attach_library('{theme}/paragraph.card') }}`
Attach in preprocess: `$variables['#attached']['library'][] = '{theme}/paragraph.card';`

After any library file change: `lando drush cr`

---

## SCSS Rules

Every component SCSS file **must** start with:

```scss
@use "@s360-base-theme/ui-core/site-config";
@use "s360-scss-toolkit";
```

**@import is banned.** Use `@use` and `@forward` only.

**One top-level selector per file** — the BEM block:

```scss
@use "@s360-base-theme/ui-core/site-config";
@use "s360-scss-toolkit";

.card {
  // Block styles

  &__image {
    // Element
  }

  &__title {
    // Element
    font-size: s360-scss-toolkit.font-size-ms(2);
  }

  &__body {
    // Element
  }

  &--featured {
    // Modifier
  }

  &--featured &__title {
    // Element within modifier context
  }
}
```

---

## s360-scss-toolkit — Function & Mixin Reference

The toolkit is `@use "s360-scss-toolkit"` and exposes five namespaced modules.

### helpers — unit conversion

```scss
// rem($number) — convert px to rem (base 16)
font-size: s360-scss-toolkit.rem(24);        // → 1.5rem

// em($number) — convert px to em (base 16)
margin: s360-scss-toolkit.em(16);             // → 1em
```

### scale — responsive sizing

```scss
// linear-scale($min, $max, $min-window: 360, $max-window: 1440, $unit: 'rem')
// Returns clamp() — fluid scaling between viewport sizes
font-size: s360-scss-toolkit.linear-scale(16, 24);
// → clamp(1rem, calc(...vw), 1.5rem)

// With custom viewport range:
padding: s360-scss-toolkit.linear-scale(16, 48, 768, 1440);

// modular-scale($scale) — returns pixel value at modular scale step
font-size: s360-scss-toolkit.modular-scale(2);
```

### typography

```scss
// font-size-ms($scale, $namespace: '') — modular scale font-size
// Without namespace: returns rem value
font-size: s360-scss-toolkit.font-size-ms(2);          // → rem(modular-scale(2))

// With namespace: returns CSS custom property with fallback
font-size: s360-scss-toolkit.font-size-ms(2, 'heading'); // → var(--heading-font-size, 1.25rem)

// font-size-ms mixin — sets the CSS custom property
@include s360-scss-toolkit.font-size-ms(2, 'heading');  // → --heading-font-size: 1.25rem;

// font-smoothing mixin
@include s360-scss-toolkit.font-smoothing;
```

### display — visibility helpers

```scss
// visually-hidden mixin/placeholder — hide visually, keep for screen readers
@include s360-scss-toolkit.visually-hidden;
// or extend: @extend %visually-hidden;

// focusable — reveals visually-hidden element on focus (skip links)
@include s360-scss-toolkit.focusable;

// hidden — display: none
@include s360-scss-toolkit.hidden;

// invisible — visibility: hidden (keeps layout space)
@include s360-scss-toolkit.invisible;

// responsive-video — aspect ratio container for embedded video
@include s360-scss-toolkit.responsive-video;
```

### effects — shadows

```scss
// box-shadow($level: 1, $color: #000) — levels 1–5
@include s360-scss-toolkit.box-shadow(2);
@include s360-scss-toolkit.box-shadow(3, #1a1a1a);

// text-shadow($level: 1, $color: #000)
@include s360-scss-toolkit.text-shadow(1);

// drop-shadow($level, $color) — returns filter values
filter: s360-scss-toolkit.drop-shadow(2);
```

---

## ui-core — Site Config & Shared Mixins

`ui-core` is the project's zero-output SCSS workspace (`@s360-base-theme/ui-core`). It holds:
- `site-config` — CSS custom properties, breakpoints, design tokens, typography scale config
- Shared animation mixins (`_accordion-styles`, `_animate-*`, `_animators`)

Always `@use "@s360-base-theme/ui-core/site-config"` as the first import. This configures `s360-scss-toolkit` with the project's base font size and modular scale ratio.

---

## BEM Methodology

`.block__element--modifier` — strict. No utility class soup.

```html
<!-- Correct -->
<div class="card card--featured">
  <div class="card__image">...</div>
  <h2 class="card__title">...</h2>
</div>

<!-- WRONG — utility classes -->
<div class="flex p-4 shadow-md rounded">...</div>
```

Class names in Twig:
```twig
{% set classes = ['card', modifier ? 'card--' ~ modifier] %}
{% set attributes = create_attribute() %}
{% set attributes = attributes.setAttribute('class', classes) %}
<div{{ attributes }}>
```

---

## Twig Templates — Drupal vs Component

There are **two** Twig files per entity:

| File | Location | Used by |
|------|----------|---------|
| `paragraph--card.html.twig` | `templates/paragraph/` (Drupal) | Drupal runtime |
| `paragraph.card.twig` | `ui/src/templates/paragraph/card/component/` | Storybook + extended by Drupal template |

The Drupal template extends the component via `{% extends %}`:

```twig
{# templates/paragraph/paragraph--card.html.twig #}
{% set paragraph_classes = ['card', 'card--' ~ paragraph.bundle] %}
{% set attributes = attributes|default(create_attribute()) %}

{% extends "@ui-paragraph/card/component/paragraph.card.twig" %}

{% block paragraph_content %}
  {{ content }}
{% endblock %}
```

**Never** put the component Twig markup in the Drupal template — it belongs in the component twig.

---

## Template Suggestions

```php
/**
 * Implements hook_theme_suggestions_node_alter().
 */
function {theme}_theme_suggestions_node_alter(array &$suggestions, array $variables): void {
  $view_mode = $variables['elements']['#view_mode'];
  $node = $variables['elements']['#node'];
  $suggestions[] = 'node__' . $node->bundle() . '__' . $view_mode;
}
```

Suggestion priority (most specific wins):
```
paragraph--card--default.html.twig
paragraph--card.html.twig
paragraph--default.html.twig
paragraph.html.twig
```

---

## Preprocess Functions

All in `{theme}.theme`. One function per hook.

```php
/**
 * Implements template_preprocess_paragraph().
 */
function {theme}_preprocess_paragraph(&$variables): void {
  /** @var \Drupal\paragraphs\Entity\Paragraph $paragraph */
  $paragraph = $variables['paragraph'];
  $variables['bundle'] = $paragraph->bundle();
}
```

---

## Drupal Behaviors (JavaScript)

```javascript
// ui/src/templates/navigation/main/component/menu.main.js
import './menu.main.scss';

(function (Drupal, once) {
  'use strict';

  Drupal.behaviors.menuMain = {
    attach(context) {
      once('menu-main', '.menu--main', context).forEach((el) => {
        // behavior attached once per element
      });
    },
  };
})(Drupal, once);
```

**Rules:**
- Use `once()` — prevents re-attaching on AJAX
- Scope with `context`, not `document`
- `core/once` must be in `dependencies:` in libraries.yml

---

## File & Folder Naming

All file and folder names: **kebab-case, lowercase, no underscores between words.**

Each component directory's files match the parent folder name:

```
card/
  card.twig
  card.scss
  card.js
  card.stories.js
  card.yml
```

Underscore-prefixed `_card.scss` = SCSS partial only — not a webpack entry, not for Drupal templates.
Underscore-prefixed `_card.twig` = private Twig partial — never symlinked to Drupal templates.

---

## Common Anti-Patterns

| Anti-pattern | Correct approach |
|---|---|
| Single `{theme}.libraries.yml` file | Split library files by entity type in `libraries/` |
| `@import 'tokens'` in SCSS | `@use "@s360-base-theme/ui-core/site-config"` + `@use "s360-scss-toolkit"` |
| Utility classes (Tailwind, Bootstrap) | BEM — one component, one block, semantic selectors |
| `$(document).ready()` | `Drupal.behaviors` with `once()` |
| PHP logic in `.twig` | Preprocess in `.theme` |
| `{{ var\|raw }}` on user content | Sanitise server-side first |
| Inline `style=""` attributes | SCSS classes |
| Creating component files manually | `yarn generate` in `ui/` |


