Guidance for AI agents (and humans) creating or changing Single Directory
Components (SDC) in this theme so every component stays consistent and
Drupal Canvas–ready. This file follows the agents.md
convention; CLAUDE.md imports it.
Canonical reference:
components/atoms/section/section.twig. When in doubt, copy its structure, comment style, and ordering. Read it first.
Read the source when you need depth. This file is the contract, not the whole truth. When a task needs more than it states — an exact API, an edge case, the real migration logic, a Bootstrap option — open the relevant resource in §0 and read it; do not guess or invent. Verify a claim against the code/docs before acting on it.
| Topic | Read this | When |
|---|---|---|
| Component template pattern | components/atoms/section/section.twig (in this theme) |
Before writing/changing any .twig. |
| A component's real props/enums | that component's *.component.yml |
Before referencing any prop — never assume. |
| Starterkit / sub-theme | starterkit.md (this theme) + Varbase: theme development and creating-your-own-theme |
Generating/configuring a sub-theme. |
| Vartheme BS5 architecture | understanding-vartheme-bs5 | SCSS/SDC/CVA/build structure questions. |
| Theme-switch & component migration | varbase_components 4.0.x — src/EventSubscriber/ActiveThemeChangeSubscriber.php, src/Commands/VarbaseComponentsCommands.php (https://git.drupalcode.org/project/varbase_components/-/tree/4.0.x) + troubleshooting-theme-switch-issues |
Component IDs, version hashes, auto_switch_components, migration. |
| Storybook | integration-of-varbase-with-storybook + live demo https://storybook.demo.varbase.vardot.com/ | Stories, ddev init-storybook, running on :6006. |
| Preloaded fonts | add-preloaded-fonts-in-vartheme | Adding @font-face + preload. |
| Theming tools install | manually-install-needed-theming-tools | Node 20.x / Yarn / build deps setup. |
| Drupal SDC | https://www.drupal.org/docs/develop/theming-drupal/using-single-directory-components | attributes, slots, props, library auto-attach behaviour. |
| Drupal Canvas | the installed drupal/canvas module + its REST API under /canvas/api/v0/ |
Component versions, content templates, page authoring. |
| Bootstrap 5.3 | https://getbootstrap.com/docs/5.3/ | Utilities, components, SCSS variables, breakpoints. |
| This convention | agents.md | The AGENTS.md format itself. |
When a section below says "→ read X", consult X before acting.
Apply this standard to SDC components (a directory with a
*.component.yml + *.twig) under components/atoms, components/molecules,
components/organisms, components/pages, and components/templates.
Never restructure / keep upstream-unchanged:
components/foundation/**— Storybook foundation/reference pages.components/getting-started/**— Storybook intro pages.components/base/root/**— empty root-variables component.
These are demo/reference stubs; leave their .twig and docs exactly as
shipped upstream.
Every component .twig is written in this exact order:
{#
/**
* @file
* Vartheme BS5 Starter Kit implementation for the <Name> component.
*
* <Short description.>
*
* Available properties:
* - prop_name: <description>. (<enum a | enum b>) Defaults to `<default>`.
*
* Available attributes:
* - attributes: HTML attributes for the root element.
* - <region>_attributes: HTML attributes for the <region> element.
*
* Available slots:
* - slot_name: <description>.
*/
#}
{# AI guidance:
# - Do not alter property descriptions or existing logic unless new
# properties are added.
# - Keep the structure: 1) variables, 2) logical variable manipulations,
# 3) print the HTML.
# - Always handle classes as arrays with `merge` (never string
# concatenation) to avoid duplicate classes and data type conflicts.
#}
{# Variables: scalar props with schema defaults. #}
{% set x = x|default('...') %}
{# Variables: boolean props.
# SDC validates `type: boolean` props and applies their schema defaults,
# so the values arrive as real booleans — only a presence fallback is
# needed here, never type coercion.
#}
{% set flag = flag ?? false %}
{# Logic: merge classes as arrays. #}
{% set classes = ['base-class', flag ? 'active' : '']|merge(other_classes) %}
{% set attributes = attributes.addClass(classes) %}
{# Print the HTML. #}
<tag{{ attributes }}> ... </tag>@filefirst descriptive line is exactlyVartheme BS5 Starter Kit implementation for the <Name> component.(<Name>= the human title, e.g.Card Hero,Badge).- Copy the AI guidance comment block verbatim from section.twig.
- Sections:
Variables(scalars first, then booleans) →Logic→Print.
Drupal SDC automatically provides an attributes object
(Drupal\Core\Template\Attribute) to every component — both for top-level
render and for include('vartheme_bs5:<name>', …) sub-components.
(→ read §0 "Drupal SDC" if unsure how attributes/slots/props are injected.)
-
Do NOT add
{% set attributes = attributes|default(create_attribute()) %}for the root — it is redundant. (Verified: SDC always provides it.) -
The component's outermost / root element uses the provided
attributes, applied via asetthen printed bare — never inline:{% set attributes = attributes.addClass(classes) %} <div{{ attributes }}> {# correct: set in logic, bare at print #}<div{{ attributes.addClass(classes) }}> {# WRONG: inline addClass at print #} <div{{ create_attribute().addClass(classes) }}> {# WRONG: anonymous #} <div{{ card_attributes }}> {# WRONG: custom-named object on the root #}
-
Named
*_attributesobjects are only for nested / non-root elements (media wrapper, content body, overlay, footer, columns, …). Initialise them lazily so callers can pass their own:{% if not media_attributes %} {% set media_attributes = create_attribute() %} {% endif %} {% set media_attributes = media_attributes.addClass(media_classes) %} <div{{ media_attributes }}> … </div> -
Route static markup attributes (
id,tabindex,role,data-*,aria-*) through the attribute object too — never hard-code them inline:attributes.setAttribute('id', id|clean_id).setAttribute('tabindex', -1). Print bare<span{{ attributes }}>. -
Loops / recursion: create the attribute object fresh inside each iteration so per-item
id/aria/classstay correct and don't accumulate. -
Declare every
*_attributesobject you use in the docblockAvailable attributes:list.
- Build class lists as arrays merged with
|merge. Never string concatenation, neverclass="{{ a }} {{ b }}". - No empty-string /
nullarray members from else-less ternaries — usecond ? 'x' : ''then a|filter(v => v is not empty), or a conditional|merge(['x']). - Value→class mapping (
'col-md-' ~ md,preset_map[key]) is fine — that is render logic, not concatenation of the class list.
SDC validates props against the *.component.yml schema (types + enums) and
applies their defaults. Therefore:
- Booleans:
{% set flag = flag ?? <yml-default> %}. Never coerce strings (((flag ?? false) ~ '')|lower|trim in ['1','true',…]is banned). - Enums: never re-check membership (
{% if x not in ['a','b'] %}…); trust the value. Keep only the logic that maps a value to output. - Scalars:
{% set x = x|default('<yml-default>') %}(presence fallback only). - Do not edit
*.component.ymlunless you are genuinely adding a prop; keepexamples:matchingdefault:so Canvas stores default inputs.
Each SDC component directory (except the §1 untouched dirs) has a README.md
following components/atoms/section/README.md:
# Name + one-line intro → ## Bootstrap reference (a > [Bootstrap 5.3 — <Label>](https://getbootstrap.com/docs/5.3/…) blockquote when the component
maps to a Bootstrap component/utility) → ## What it does → ## Files →
## Props overview / ## Slots → ## Example → ## Notes. Use the real
props/enums/defaults from the .component.yml. Keep getbootstrap.com links
correct (valid page and #fragment).
molecules/dynamic-responsive-image(drimage): the field formatter providesitem_attributesfor the wrapper, not the SDCattributes. Keep<div{{ item_attributes }}>{% apply spaceless %}with its{% if not item_attributes %}guard.atoms/field: branch-specificattributes.addClass(classes, 'field__item')inside{% for %}loops stays inline — a cumulative{% set %}would wrongly accumulate classes across iterations.- Page-level Drupal templates (
pages/page,pages/page-user-*): Drupal does not passattributesto page templates. Build local*_classesarrays and printclass="{{ x_classes|join(' ') }}"; do not inventattributesthere. Entity/views/field/comment/form templates do receiveattributes— use it. - Cover / fill images must not pixelate (drimage). An image that fills a
fixed-height box with
object-fit: coverwill upscale a width-only derivative and blur. Let drimage size the derivative to the container's width AND height: render throughatoms/imagewithcover_fill: true(it sets drimageimage_handling: 'container_size'), which serves a derivative matched to the rendered box and re-fetches on resize (so large↔small swaps a landscape↔portrait derivative). Never stretch a width-only derivative with CSS. The first request for a new size 404s once while drimage generates that derivative, then serves 200 — that one-time miss is expected. - Equal-height columns. The
atoms/sectionrow defaults toalign-items-start(columns top-align, not equal height). For equal-height cards across a row, set the section'svertical_alignment: align-items-stretchand eachmolecules/organismscard'sequal_height: true(addsh-100). Do not add anequal_height/h-100prop toatoms/group; if a boxed group must match its siblings' height, usecard-textinstead ofgroup.
- Mirror every change to the products copy:
/var/www/html/products/vartheme_bs5/(same sub-path). - After editing:
drush cr, then render the affected component(s) and the demo pages. Target zero Twig/PHP errors, zero Canvas component version-mismatch warnings, and zero browser-console errors. (Benign theme font-preload + drimagesrcset-placeholder warnings are expected.) - Editing a component's
.twig/.component.ymlchanges its Canvas component version hash. If demo content /canvas.content_template.*pins an old hash you'll see "version … not found, falling back to active" warnings — fix them with the official command:drush varbase-components:fix-versions <theme>(corrects stored hashes that reference old snapshots). - Test responsive at real breakpoints, not one width. Use a real browser
(Playwright MCP) at the §21 sizes — ~375/390 (mobile), 768 (tablet),
1280/1440 (desktop) — and confirm the layout adapts, the image stays sharp
(drimage serves a derivative ≥ the displayed size at each width), and there is
no horizontal overflow (
document.documentElement.scrollWidth≤ viewport). - Test logged-OUT (anonymous) as well as logged-in. The admin/Gin toolbar offsets the viewport, so full-width / breakout layouts that look centred for an editor can be off-centre or overflow for real visitors. Verify both.
- Tests:
tests/features/09-drupal-canvas/components/*.feature(self-seeding viatests/step-definitions/drupal-canvas.steps.js). Runnpx cucumber-js --dry-run(no undefined/ambiguous) then the suite.
Do
- Bootstrap-first: use ready Bootstrap 5.3 classes +
--bs-*/$bsvariables; write new CSS only for genuine special cases (and build it from Bootstrap tokens). See §11. - Mirror section.twig structure, ordering, and comments.
- Root → provided
attributes; nested → named*_attributes. setthe attribute object in Logic, print it bare<tag{{ attributes }}>.- Classes as
|mergearrays; booleans?? default; scalars|default. - Keep a component's CSS in its own
<name>.scss(scoped to the block) and register a brand-new.scssinwebpack.config.components.js. - Size in
rem/em; make components responsive with the breakpoint mixins; test at every breakpoint and logged-out. - For a cover image filling a box, use drimage
container_sizeviaatoms/imagecover_fill: true. - Add a Bootstrap-referenced README; mirror to products; run tests.
Don't
- Don't reference Figma node IDs / file keys anywhere in code or comments.
- Don't put component styles in the global theme stylesheets, use
pxliterals, or write inlinestyle="…". - Don't break a box out of its container with
width: 100vw(toolbar/scrollbar skew) — use symmetric negativemargin-inline. - Don't add an
equal_height/h-100prop toatoms/group(usecard-text). - Don't add
attributes|default(create_attribute())for the root. - Don't use inline
{{ attributes.addClass(...) }}at the print, an anonymouscreate_attribute()root, or a custom-named root object. - Don't string-coerce booleans or re-validate enums.
- Don't string-concatenate the class list.
- Don't reinvent what Bootstrap already provides (colors, spacing, radii, shadows, breakpoints, components) — use the Bootstrap class/variable.
- Don't edit
components/foundation,components/getting-started, orcomponents/base/root. - Don't run a git commit unless explicitly asked.
Vartheme BS5 is a Drupal starterkit theme: developers generate a sub-theme from it and inherit these conventions. Keep every component self-contained and on-standard so generated themes stay consistent.
One directory per component; files share the component machine name:
components/<atoms|molecules|organisms|pages|templates>/<name>/
├── <name>.component.yml # SDC schema: name, status, description, props, slots,
│ # libraryOverrides, examples (examples MUST match defaults).
├── <name>.twig # template (see Part A).
├── <name>.scss # SOURCE styles (edit this).
├── <name>.css # COMPILED from .scss — do NOT hand-edit (build output).
├── <name>.js # behaviour (Drupal.behaviors + once), optional.
├── README.md # docs (see §6).
├── <name>.stories.json # Storybook story config (optional).
└── <name>.stories.twig # Storybook story template (optional).
SDC auto-attaches <name>.css and <name>.js to the component — you do not
register them in *.libraries.yml. Add only library dependencies (e.g.
core/drupal, core/once) via libraryOverrides.dependencies in the
*.component.yml.
A new
<name>.scssonly compiles if it is listed inwebpack.config.components.jsentry(an explicit map, not a glob). Add an entry (e.g.'organisms/media-banner/media-banner': ['./components/organisms/media-banner/media-banner.scss']), thenyarn components:build. Without it the.scssis silently ignored and no.cssis produced.
Bootstrap-first — this is the default, logical approach. Bootstrap 5.3 already ships almost everything. Reach for custom CSS only when nothing built-in expresses the need. Decision order, top to bottom — stop at the first that works:
- A ready Bootstrap 5.3 utility / component class in the Twig markup
(
d-flex,gap-3,text-center,rounded-3,shadow,col-md-6,btn btn-primary,card,ratio ratio-16x9, …). → read §0 "Bootstrap 5.3". - A ready Bootstrap CSS custom property (
--bs-*) or SCSS variable ($spacer,$primary,$border-radius,$font-weight-bold) — reference these so the component follows the active (sub-)theme automatically. - Only as a genuine special case, write new SCSS — and even then build it
from the Bootstrap tokens above (expose component-local
--name-*custom properties computed from$bs/--bs-*), never hard-coded magic values.
If you're adding a custom color, size, radius, shadow, or breakpoint that Bootstrap already defines, you're doing it wrong — use the Bootstrap one.
- Edit
.scss, never.css..cssis webpack/dart-sass build output. Rebuild withyarn components:build(oryarn theme:full-build); stylelint--fixruns as part of the build. Never commit a hand-edited.css. - Start each SCSS file with a
// @fileheader describing the component. @import "../../defaults";(components/_defaults.scss) to get Bootstrap 5 variables, functions, and mixins.- Theme through tokens — no hard-coded visual values. Every visual value
flows through:
- Bootstrap SCSS variables at compile time:
#{$spacer * 2.5},$primary,$border-radius-sm,$font-weight-bold; and/or - Bootstrap CSS custom properties at runtime:
var(--bs-primary),var(--bs-secondary-bg),var(--bs-border-radius). Expose component-local tokens as CSS custom properties on the block root and reference them everywhere, e.g.:
This makes components inherit the active (sub-)theme automatically — light/dark and brand changes "just work"..webshare { --webshare-button-size: #{$spacer * 2.5}; --webshare-button-bg: var(--bs-secondary-bg); }
- Bootstrap SCSS variables at compile time:
- Naming: BEM, namespaced to the component block. Block
.webshare, element.webshare__item, modifier.webshare--vertical. Do not leak generic class names. Prefer Bootstrap utility classes in the Twig markup; reserve SCSS for what utilities can't express. - Variants via CVA (Class Variance Authority). Vartheme BS5 manages
component variants with structured class definitions: a base class plus size
and color/style variant classes (e.g. button → base
btn, sizebtn-sm/btn-lg, variantbtn-primary/btn-secondary). Build variant class lists from enum props this way (still as|mergearrays in Twig). (→ read §0 "Vartheme BS5 architecture" for the CVA/SCSS structure.) - Keep specificity low (single class), use
&nesting for states (&:hover, &:focus), factor repeated patterns into@mixins. - No Figma references in code. Never write Figma node IDs, file keys, or
(Figma 1234-5678)notes in any.scss,.twig,.css,.yml,.js, or comment. Describe what a rule does and name the Bootstrap token it derives from — not where the value came from in a design tool. - Component styles live in the component, not the theme. Put every rule in
the component's own
<name>.scss, scoped to its block (.media-banner { … }), so SDC auto-attaches it only on pages that use the component. Do not add component CSS to the global stylesheets (scss/base/*,scss/layout/*,scss/theme/*) — those load site-wide on every page. A brand-new component.scssmust be registered inwebpack.config.components.js'sentrymap (the components build uses an explicit list, not a glob) or it will never compile to.css. - Units: rem / em, never px. Size everything in
rem/emderived from$spacerand Bootstrap tokens so it scales with the root/element font size. Nopxliterals — not even in a comment as the "real" value.%,vw/vh,min()/clamp()are fine for relative/viewport sizing. - No inline
style="…". Style through classes only (Bootstrap utilities first, then the component.scss). Never hand-write inline styles in.twig, content, or markup strings. - Responsive: use the Bootstrap breakpoint mixins
(
@include media-breakpoint-up(md) { … }) at the §21 breakpoints, not custom media queries. For a box that must break out of its container into a wider band, use symmetric negative inline margins (margin-inline: -#{$spacer * n}), neverwidth: 100vw—100vwignores the admin/Gin toolbar offset (off-centre for editors) and the scrollbar (horizontal overflow). Cap responsive heights withmin()/clamp()+ a viewport unit so they never exceed the screen.
- Only add a
<name>.jswhen the component needs behaviour; prefer CSS/Bootstrap data-API where possible. - Use the Drupal behaviors +
oncepattern, wrapped to avoid globals:((Drupal, once) => { Drupal.behaviors.<componentName> = { attach(context) { once('<unique-id>', '<root-selector>', context).forEach((el) => { // progressive enhancement here }); }, }; })(Drupal, once);
- Declare deps in
<name>.component.yml:libraryOverrides: dependencies: - core/drupal - core/once
- Start with a
/** @file */JSDoc block; document non-obvious behaviour, browser-API caveats, and expected error outcomes. Progressive enhancement only — the component must render and be usable without JS. ESLint clean (yarn lint:js).
name,status(stablefor shipped components),description,props(typed, withenum/default),slots, optionallibraryOverrides, andexamples.examplesmust matchdefaults for every prop so Drupal Canvas stores the default inputs when a component is dropped in (issue #3595562). Editing props/enums/defaults changes the component version hash — see §8.- Don't add a prop the template doesn't use, and don't rename a prop devs rely on without a migration note.
- Build:
yarn theme:build(theme assets),yarn components:build(component.scss→.css),yarn theme:full-build(both). Watch:yarn theme:watch. - Lint:
yarn lint:css(stylelint),yarn lint:js(eslint),yarn lint:yaml,yarn phpcs,yarn spellcheck. - Generate a sub-theme from this starterkit:
The generated theme inherits all components and these conventions; new/changed components in it MUST follow this AGENTS.md.
php core/scripts/drupal generate-theme my_theme --starterkit vartheme_bs5 \ --name "My Theme" --description "Custom theme generated from Vartheme BS5"
.twigfollows Part A (structure,attributes, classes, trust-SDC)..scsstoken-driven + BEM;.cssrebuilt via the build (not hand-edited)..js(if any) uses behaviors +once, deps declared, ESLint clean..component.ymlprops typed,examplesmatch defaults.README.mdpresent with a valid getbootstrap.com reference.- Mirrored to
/var/www/html/products/vartheme_bs5/. drush cr; zero Twig/PHP errors, zero Canvas version warnings, zero console errors; lints pass; component test (tests/features/09-drupal-canvas/) green.
Authoritative reference: https://docs.varbase.vardot.com/developers/theme-development-with-varbase. Live design system / Storybook: https://storybook.demo.varbase.vardot.com/. Extend Vartheme BS5 — don't build from scratch.
- Node.js 20.x, Yarn (use Yarn — not npm; npm causes install issues).
Also
sed+gawk(used by the generate-theme rename step). node_modules/is git-ignored — after any clone, reinstall before building.
# from docroot (web/)
php core/scripts/drupal generate-theme mytheme \
--starterkit vartheme_bs5 \
--path themes/custom \
--name "My Theme" \
--description "Custom theme generated from Vartheme BS5 theme"
cd themes/custom/mytheme
yarn install # Yarn only
yarn theme:init # REQUIRED immediately after generate + install
yarn theme:full-build # or: theme:build (styles), components:build, theme:watchTeammates after cloning the repo: yarn install && yarn theme:init && yarn theme:full-build.
- The generated
mytheme.info.ymlshipsauto_switch_components: true— this enables automatic Drupal Canvas component-ID migration when the theme is activated. Keep it. - The sub-theme is independent: after generating, Vartheme BS5 can be uninstalled and the sub-theme still works.
- New/changed components in the sub-theme must follow Parts A & B of this file (the standard is inherited).
SDC component IDs are theme-prefixed: sdc.<theme>.<name> (e.g.
sdc.vartheme_bs5.button). So forking/renaming the theme changes every
component ID, and stored Canvas component version hashes are per
component-config — both must migrate when the default theme changes.
The varbase_components module's ActiveThemeChangeSubscriber does this
automatically on default-theme change — but only when both the old and
new theme declare auto_switch_components: true in their .info.yml. It runs,
in order:
- Replace the old theme name in active config entities.
- Rewrite
component_treefield data in content entities:sdc.<old>.* → sdc.<new>.*. - Replace the old theme filesystem path with the new theme's path in text fields.
- Fix component versions: any stored
component_versionthat isn't a known/active version of its component is replaced with that component's active version.
Manual / diagnostic equivalents (@bootstrap full):
drush varbase-components:scan-refs <theme> # audit lingering refs (table)
drush varbase-components:switch-theme <old> <new> --dry-run # preview (always dry-run first)
drush varbase-components:switch-theme <old> <new> # apply all 4 steps
drush varbase-components:fix-versions <theme> # only step 4 (stale → active)"Components not migrating" almost always = missing
auto_switch_components: true in a theme's .info.yml. Add it, drush cr,
re-run. Use fix-versions after editing components in-place (no theme switch)
when demo content / canvas.content_template.* warns
"version … not found, falling back to active".
Stories live beside each component (<name>.stories.json, optional <name>.mdx).
Storybook runs on its own port (6006) but renders SDC by calling the Drupal site.
ddev init-storybook # installs deps, enables storybook module, perms, env
ddev yarn storybook:gen # drush storybook:generate-all-stories from SDC
ddev yarn storybook:dev # serve on :6006 (storybook:ddev binds 0.0.0.0)
ddev yarn storybook:build # static export (storybook:kill to stop)Config: .storybook/main.ts (component paths), preview.ts (Drupal asset
fetch / SDC param cleaning), middleware.js (asset proxy / CORS),
.env.storybook (STORYBOOK_SERVER_URL, STORYBOOK_SERVER_RENDER_URL).
- Font files →
MYTHEME/fonts/<name>/webfonts/. @font-faceCSS →MYTHEME/fonts/<name>/css/<name>.css.- Register in
MYTHEME.libraries.ymlunderglobal-styles→css: theme: { fonts/<name>/css/<name>.css: {} }. - Add the preload link in
MYTHEME/templates/includes/preload.twig:<link rel="preload" href="/{{ vartheme_bs5_path }}/fonts/<name>/<File>.ttf" as="font" type="font/ttf" crossorigin="anonymous">
preload.twigis included fromhtml.html.twig; available vars:vartheme_bs5_path(theme path) andhtml_dir(ltr/rtl).
xs 0 · sm 576 · md 768 · lg 992 · xl 1200 · xxl 1400 px
(vartheme_bs5.breakpoints.yml). Theme regions are defined in
vartheme_bs5.info.yml (Header, Content, Footer + more).