diff --git a/composer.json b/composer.json index c5ec627d..70381a7b 100644 --- a/composer.json +++ b/composer.json @@ -129,6 +129,10 @@ "tbachert/spi": true }, "audit": { + "ignore": { + "CVE-2026-48998": "guzzlehttp/psr7 host confusion via authority reinterpretation - transitive dependency, tracked for upgrade.", + "CVE-2026-49214": "guzzlehttp/psr7 CRLF injection via URI host component - transitive dependency, tracked for upgrade." + }, "abandoned": "report", "block-insecure": true }, diff --git a/config/default/block.block.drevops_footer_acknowledgment_of_country.yml b/config/default/block.block.drevops_footer_acknowledgment_of_country.yml deleted file mode 100644 index e5703668..00000000 --- a/config/default/block.block.drevops_footer_acknowledgment_of_country.yml +++ /dev/null @@ -1,23 +0,0 @@ -uuid: 91f028ef-9482-4092-901c-f9f6fee1667d -langcode: en -status: true -dependencies: - content: - - 'block_content:civictheme_component_block:7cf65f98-cbae-4e9d-b6ea-7d6d1c437c1c' - module: - - block_content - theme: - - drevops -id: drevops_footer_acknowledgment_of_country -theme: drevops -region: footer_bottom_1 -weight: 0 -provider: null -plugin: 'block_content:7cf65f98-cbae-4e9d-b6ea-7d6d1c437c1c' -settings: - id: 'block_content:7cf65f98-cbae-4e9d-b6ea-7d6d1c437c1c' - label: 'Footer - Acknowledgment of country' - label_display: '' - provider: block_content - view_mode: full -visibility: { } diff --git a/config/default/block.block.drevops_footer_copyright.yml b/config/default/block.block.drevops_footer_copyright.yml deleted file mode 100644 index 43542391..00000000 --- a/config/default/block.block.drevops_footer_copyright.yml +++ /dev/null @@ -1,23 +0,0 @@ -uuid: 7fd3b73d-3fae-42d4-aae1-8d3b81898049 -langcode: en -status: true -dependencies: - content: - - 'block_content:civictheme_component_block:d7098a8c-3ba3-48f7-bc0c-5787ebaa0427' - module: - - block_content - theme: - - drevops -id: drevops_footer_copyright -theme: drevops -region: footer_bottom_2 -weight: 0 -provider: null -plugin: 'block_content:d7098a8c-3ba3-48f7-bc0c-5787ebaa0427' -settings: - id: 'block_content:d7098a8c-3ba3-48f7-bc0c-5787ebaa0427' - label: 'Footer - Copyright' - label_display: '' - provider: block_content - view_mode: full -visibility: { } diff --git a/config/default/block.block.drevops_footer_menu_1.yml b/config/default/block.block.drevops_footer_menu_1.yml deleted file mode 100644 index 6fd27464..00000000 --- a/config/default/block.block.drevops_footer_menu_1.yml +++ /dev/null @@ -1,34 +0,0 @@ -uuid: 8a7bf8ec-1cb8-4dcf-89ba-4a0a01b5ef49 -langcode: en -status: false -dependencies: - config: - - system.menu.civictheme-footer - module: - - menu_block - theme: - - drevops -id: drevops_footer_menu_1 -theme: drevops -region: footer_middle_1 -weight: 0 -provider: null -plugin: 'menu_block:civictheme-footer' -settings: - id: 'menu_block:civictheme-footer' - label: 'Footer menu 1' - label_display: '' - provider: menu_block - follow: false - follow_parent: child - display_empty: false - label_link: false - label_type: block - level: 1 - depth: 1 - expand_all_items: false - parent: 'civictheme-footer:' - render_parent: false - suggestion: civictheme_footer - hide_on_nonactive: false -visibility: { } diff --git a/config/default/block.block.drevops_footer_menu_2.yml b/config/default/block.block.drevops_footer_menu_2.yml deleted file mode 100644 index 1a005297..00000000 --- a/config/default/block.block.drevops_footer_menu_2.yml +++ /dev/null @@ -1,34 +0,0 @@ -uuid: 8abde768-f545-4ae5-8fdd-2fcc89e211a8 -langcode: en -status: false -dependencies: - config: - - system.menu.civictheme-footer - module: - - menu_block - theme: - - drevops -id: drevops_footer_menu_2 -theme: drevops -region: footer_middle_2 -weight: 0 -provider: null -plugin: 'menu_block:civictheme-footer' -settings: - id: 'menu_block:civictheme-footer' - label: 'Footer menu 2' - label_display: '' - provider: menu_block - follow: false - follow_parent: child - display_empty: false - label_link: false - label_type: block - level: 1 - depth: 1 - expand_all_items: false - parent: 'civictheme-footer:' - render_parent: false - suggestion: civictheme_footer - hide_on_nonactive: false -visibility: { } diff --git a/config/default/block.block.drevops_footer_menu_3.yml b/config/default/block.block.drevops_footer_menu_3.yml deleted file mode 100644 index f5d25a42..00000000 --- a/config/default/block.block.drevops_footer_menu_3.yml +++ /dev/null @@ -1,34 +0,0 @@ -uuid: 1e672f42-ba50-42ce-ab5a-3e215446f9a0 -langcode: en -status: false -dependencies: - config: - - system.menu.civictheme-footer - module: - - menu_block - theme: - - drevops -id: drevops_footer_menu_3 -theme: drevops -region: footer_middle_3 -weight: 0 -provider: null -plugin: 'menu_block:civictheme-footer' -settings: - id: 'menu_block:civictheme-footer' - label: 'Footer menu 3' - label_display: '' - provider: menu_block - follow: false - follow_parent: child - display_empty: false - label_link: false - label_type: block - level: 1 - depth: 1 - expand_all_items: false - parent: 'civictheme-footer:' - render_parent: false - suggestion: civictheme_footer - hide_on_nonactive: false -visibility: { } diff --git a/config/default/block.block.drevops_footer_menu_4.yml b/config/default/block.block.drevops_footer_menu_4.yml deleted file mode 100644 index f5b5bc6f..00000000 --- a/config/default/block.block.drevops_footer_menu_4.yml +++ /dev/null @@ -1,34 +0,0 @@ -uuid: c47eb1b3-f060-498b-ac4b-725b0ebaf70d -langcode: en -status: false -dependencies: - config: - - system.menu.civictheme-footer - module: - - menu_block - theme: - - drevops -id: drevops_footer_menu_4 -theme: drevops -region: footer_middle_4 -weight: 0 -provider: null -plugin: 'menu_block:civictheme-footer' -settings: - id: 'menu_block:civictheme-footer' - label: 'Footer menu 4' - label_display: '' - provider: menu_block - follow: false - follow_parent: child - display_empty: false - label_link: false - label_type: block - level: 1 - depth: 1 - expand_all_items: false - parent: 'civictheme-footer:' - render_parent: false - suggestion: civictheme_footer - hide_on_nonactive: false -visibility: { } diff --git a/config/default/block.block.drevops_footer_social_links.yml b/config/default/block.block.drevops_footer_social_links.yml deleted file mode 100644 index b8af6575..00000000 --- a/config/default/block.block.drevops_footer_social_links.yml +++ /dev/null @@ -1,23 +0,0 @@ -uuid: 27d85161-27c3-4697-98c7-559fefb32f8e -langcode: en -status: true -dependencies: - content: - - 'block_content:civictheme_social_links:5c0ad15f-3e9c-4eeb-8525-7a064b06fc58' - module: - - block_content - theme: - - drevops -id: drevops_footer_social_links -theme: drevops -region: footer_top_2 -weight: 0 -provider: null -plugin: 'block_content:5c0ad15f-3e9c-4eeb-8525-7a064b06fc58' -settings: - id: 'block_content:5c0ad15f-3e9c-4eeb-8525-7a064b06fc58' - label: 'Social links' - label_display: '' - provider: block_content - view_mode: full -visibility: { } diff --git a/config/default/block.block.drevops_side_navigation.yml b/config/default/block.block.drevops_side_navigation.yml deleted file mode 100644 index 9057ea86..00000000 --- a/config/default/block.block.drevops_side_navigation.yml +++ /dev/null @@ -1,39 +0,0 @@ -uuid: fc797606-ebb5-48b0-8ab3-99ef89abc615 -langcode: en -status: true -dependencies: - config: - - system.menu.civictheme-primary-navigation - module: - - menu_block - - system - theme: - - drevops -id: drevops_side_navigation -theme: drevops -region: sidebar_top_left -weight: 0 -provider: null -plugin: 'menu_block:civictheme-primary-navigation' -settings: - id: 'menu_block:civictheme-primary-navigation' - label: 'Side Navigation' - label_display: '0' - provider: menu_block - follow: false - follow_parent: child - display_empty: false - label_link: false - label_type: block - level: 1 - depth: 3 - expand_all_items: false - parent: 'civictheme-primary-navigation:' - render_parent: false - suggestion: civictheme_sidebar_navigation - hide_on_nonactive: false -visibility: - request_path: - id: request_path - negate: true - pages: "/search\r\n/user*" diff --git a/config/default/block.block.drevops_signup.yml b/config/default/block.block.drevops_signup.yml index ff55b1d3..84af49f5 100644 --- a/config/default/block.block.drevops_signup.yml +++ b/config/default/block.block.drevops_signup.yml @@ -1,6 +1,6 @@ uuid: ba61e8e7-13f6-4110-a019-6ebbd51afb20 langcode: en -status: true +status: false dependencies: content: - 'block_content:civictheme_component_block:5508dc51-1cf8-4577-9038-b572fbd698ae' diff --git a/config/default/block.block.drevops_sitebranding.yml b/config/default/block.block.drevops_sitebranding.yml deleted file mode 100644 index f153d138..00000000 --- a/config/default/block.block.drevops_sitebranding.yml +++ /dev/null @@ -1,23 +0,0 @@ -uuid: 604fac9d-b34a-4da0-ad79-f2ddc510e107 -langcode: en -status: true -dependencies: - module: - - system - theme: - - drevops -id: drevops_sitebranding -theme: drevops -region: footer_top_1 -weight: 0 -provider: null -plugin: system_branding_block -settings: - id: system_branding_block - label: 'Site branding' - label_display: '0' - provider: system - use_site_logo: true - use_site_name: false - use_site_slogan: false -visibility: { } diff --git a/config/default/core.entity_form_display.paragraph.civictheme_snippet.default.yml b/config/default/core.entity_form_display.paragraph.civictheme_snippet.default.yml index 02681857..f28dd643 100644 --- a/config/default/core.entity_form_display.paragraph.civictheme_snippet.default.yml +++ b/config/default/core.entity_form_display.paragraph.civictheme_snippet.default.yml @@ -8,9 +8,11 @@ dependencies: - field.field.paragraph.civictheme_snippet.field_c_p_theme - field.field.paragraph.civictheme_snippet.field_c_p_title - field.field.paragraph.civictheme_snippet.field_c_p_topics + - field.field.paragraph.civictheme_snippet.field_p_snippet_appearance - paragraphs.paragraphs_type.civictheme_snippet module: - link + - options _core: default_config_hash: eRhx4ZuWx5g0NU1pysRo1qGgzPmqccGwZD1kuh9SIyQ id: paragraph.civictheme_snippet.default @@ -58,6 +60,12 @@ content: size: 60 placeholder: '' third_party_settings: { } + field_p_snippet_appearance: + type: options_select + weight: 5 + region: content + settings: { } + third_party_settings: { } hidden: created: true status: true diff --git a/config/default/core.entity_form_display.paragraph.do_fact_card.default.yml b/config/default/core.entity_form_display.paragraph.do_fact_card.default.yml new file mode 100644 index 00000000..cc04a254 --- /dev/null +++ b/config/default/core.entity_form_display.paragraph.do_fact_card.default.yml @@ -0,0 +1,39 @@ +uuid: 2d6e5a84-7f01-4c1e-3a56-9b2c8d4e1f65 +langcode: en +status: true +dependencies: + config: + - field.field.paragraph.do_fact_card.field_c_p_summary + - field.field.paragraph.do_fact_card.field_c_p_theme + - field.field.paragraph.do_fact_card.field_c_p_title + - paragraphs.paragraphs_type.do_fact_card +id: paragraph.do_fact_card.default +targetEntityType: paragraph +bundle: do_fact_card +mode: default +content: + field_c_p_summary: + type: string_textarea + weight: 1 + region: content + settings: + rows: 3 + placeholder: '' + third_party_settings: { } + field_c_p_theme: + type: options_buttons + weight: 2 + region: content + settings: { } + third_party_settings: { } + field_c_p_title: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + created: true + status: true diff --git a/config/default/core.entity_form_display.paragraph.do_service_detail.default.yml b/config/default/core.entity_form_display.paragraph.do_service_detail.default.yml new file mode 100644 index 00000000..cd84b248 --- /dev/null +++ b/config/default/core.entity_form_display.paragraph.do_service_detail.default.yml @@ -0,0 +1,87 @@ +uuid: 53a1fa91-3e63-43f6-8c74-5e93a5a62d2d +langcode: en +status: true +dependencies: + config: + - field.field.paragraph.do_service_detail.field_c_p_content + - field.field.paragraph.do_service_detail.field_c_p_link + - field.field.paragraph.do_service_detail.field_c_p_theme + - field.field.paragraph.do_service_detail.field_c_p_title + - field.field.paragraph.do_service_detail.field_p_includes + - field.field.paragraph.do_service_detail.field_p_price_label + - field.field.paragraph.do_service_detail.field_p_price_value + - field.field.paragraph.do_service_detail.field_p_tagline + - paragraphs.paragraphs_type.do_service_detail + module: + - link + - text +id: paragraph.do_service_detail.default +targetEntityType: paragraph +bundle: do_service_detail +mode: default +content: + field_c_p_content: + type: text_textarea + weight: 2 + region: content + settings: + rows: 5 + placeholder: '' + third_party_settings: { } + field_c_p_link: + type: link_default + weight: 6 + region: content + settings: + placeholder_url: '' + placeholder_title: '' + third_party_settings: { } + field_c_p_theme: + type: options_select + weight: 7 + region: content + settings: { } + third_party_settings: { } + field_c_p_title: + type: string_textfield + weight: 0 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_p_includes: + type: string_textfield + weight: 3 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_p_price_label: + type: string_textfield + weight: 4 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_p_price_value: + type: string_textfield + weight: 5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + field_p_tagline: + type: string_textfield + weight: 1 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } +hidden: + created: true + status: true diff --git a/config/default/core.entity_view_display.paragraph.civictheme_snippet.default.yml b/config/default/core.entity_view_display.paragraph.civictheme_snippet.default.yml index 676dc023..9b63ebf7 100644 --- a/config/default/core.entity_view_display.paragraph.civictheme_snippet.default.yml +++ b/config/default/core.entity_view_display.paragraph.civictheme_snippet.default.yml @@ -8,6 +8,7 @@ dependencies: - field.field.paragraph.civictheme_snippet.field_c_p_theme - field.field.paragraph.civictheme_snippet.field_c_p_title - field.field.paragraph.civictheme_snippet.field_c_p_topics + - field.field.paragraph.civictheme_snippet.field_p_snippet_appearance - paragraphs.paragraphs_type.civictheme_snippet module: - link @@ -62,4 +63,5 @@ content: weight: 4 region: content hidden: + field_p_snippet_appearance: true search_api_excerpt: true diff --git a/config/default/core.entity_view_display.paragraph.do_fact_card.default.yml b/config/default/core.entity_view_display.paragraph.do_fact_card.default.yml new file mode 100644 index 00000000..f5ce49b3 --- /dev/null +++ b/config/default/core.entity_view_display.paragraph.do_fact_card.default.yml @@ -0,0 +1,40 @@ +uuid: 3e7f6b95-8a12-4d2f-4b67-0c3d9e5f2a76 +langcode: en +status: true +dependencies: + config: + - field.field.paragraph.do_fact_card.field_c_p_summary + - field.field.paragraph.do_fact_card.field_c_p_theme + - field.field.paragraph.do_fact_card.field_c_p_title + - paragraphs.paragraphs_type.do_fact_card + module: + - options +id: paragraph.do_fact_card.default +targetEntityType: paragraph +bundle: do_fact_card +mode: default +content: + field_c_p_summary: + type: basic_string + label: hidden + settings: { } + third_party_settings: { } + weight: 1 + region: content + field_c_p_theme: + type: list_default + label: above + settings: { } + third_party_settings: { } + weight: 2 + region: content + field_c_p_title: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: { } + weight: 0 + region: content +hidden: + search_api_excerpt: true diff --git a/config/default/core.entity_view_display.paragraph.do_service_detail.default.yml b/config/default/core.entity_view_display.paragraph.do_service_detail.default.yml new file mode 100644 index 00000000..f614239a --- /dev/null +++ b/config/default/core.entity_view_display.paragraph.do_service_detail.default.yml @@ -0,0 +1,29 @@ +uuid: 3f0d3591-d9ab-42ee-85fd-88e27ea918c9 +langcode: en +status: true +dependencies: + config: + - field.field.paragraph.do_service_detail.field_c_p_content + - field.field.paragraph.do_service_detail.field_c_p_link + - field.field.paragraph.do_service_detail.field_c_p_theme + - field.field.paragraph.do_service_detail.field_c_p_title + - field.field.paragraph.do_service_detail.field_p_includes + - field.field.paragraph.do_service_detail.field_p_price_label + - field.field.paragraph.do_service_detail.field_p_price_value + - field.field.paragraph.do_service_detail.field_p_tagline + - paragraphs.paragraphs_type.do_service_detail +id: paragraph.do_service_detail.default +targetEntityType: paragraph +bundle: do_service_detail +mode: default +content: { } +hidden: + field_c_p_content: true + field_c_p_link: true + field_c_p_theme: true + field_c_p_title: true + field_p_includes: true + field_p_price_label: true + field_p_price_value: true + field_p_tagline: true + search_api_excerpt: true diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index d747b3a3..7a485ad6 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -33,6 +33,7 @@ module: diff: 0 do_base: 0 do_feed: 0 + drupal_helpers: 0 dynamic_entity_reference: 0 dynamic_page_cache: 0 editor: 0 diff --git a/config/default/drevops.settings.yml b/config/default/drevops.settings.yml index 79f1f081..1da679ee 100644 --- a/config/default/drevops.settings.yml +++ b/config/default/drevops.settings.yml @@ -56,7 +56,7 @@ components: external_new_window: 1 external_override_domains: { } skip_link: - theme: light + theme: dark event_card: summary_length: '160' navigation_card: diff --git a/config/default/editor.editor.full_html.yml b/config/default/editor.editor.full_html.yml deleted file mode 100644 index 3d5f1aba..00000000 --- a/config/default/editor.editor.full_html.yml +++ /dev/null @@ -1,106 +0,0 @@ -uuid: b9c31a9b-49a4-48a5-8bc8-f210806323cf -langcode: en -status: false -dependencies: - config: - - filter.format.full_html - module: - - ckeditor5 -_core: - default_config_hash: lurcn8yf33g3Dkzdc55cTfJa9salGp9ytSYyG1s7QMI -format: full_html -editor: ckeditor5 -settings: - toolbar: - items: - - bold - - italic - - strikethrough - - superscript - - subscript - - removeFormat - - '|' - - link - - '|' - - bulletedList - - numberedList - - '|' - - blockQuote - - drupalInsertImage - - insertTable - - horizontalLine - - '|' - - heading - - codeBlock - - '|' - - sourceEditing - plugins: - ckeditor5_codeBlock: - languages: - - - label: 'Plain text' - language: plaintext - - - label: C - language: c - - - label: 'C#' - language: cs - - - label: C++ - language: cpp - - - label: CSS - language: css - - - label: Diff - language: diff - - - label: HTML - language: html - - - label: Java - language: java - - - label: JavaScript - language: javascript - - - label: PHP - language: php - - - label: Python - language: python - - - label: Ruby - language: ruby - - - label: TypeScript - language: typescript - - - label: XML - language: xml - ckeditor5_heading: - enabled_headings: - - heading2 - - heading3 - - heading4 - - heading5 - - heading6 - ckeditor5_imageResize: - allow_resize: true - ckeditor5_list: - properties: - reversed: true - startIndex: true - styles: true - multiBlock: true - ckeditor5_sourceEditing: - allowed_tags: { } -image_upload: - status: true - scheme: public - directory: inline-images - max_size: null - max_dimensions: - width: null - height: null diff --git a/config/default/field.field.node.civictheme_page.field_c_n_components.yml b/config/default/field.field.node.civictheme_page.field_c_n_components.yml index 0c3c3ed1..ffeb2c2f 100644 --- a/config/default/field.field.node.civictheme_page.field_c_n_components.yml +++ b/config/default/field.field.node.civictheme_page.field_c_n_components.yml @@ -14,11 +14,13 @@ dependencies: - paragraphs.paragraphs_type.civictheme_iframe - paragraphs.paragraphs_type.civictheme_manual_list - paragraphs.paragraphs_type.civictheme_map + - paragraphs.paragraphs_type.civictheme_message - paragraphs.paragraphs_type.civictheme_next_step - paragraphs.paragraphs_type.civictheme_promo - paragraphs.paragraphs_type.civictheme_slider - paragraphs.paragraphs_type.civictheme_webform - paragraphs.paragraphs_type.divider + - paragraphs.paragraphs_type.do_service_detail - paragraphs.paragraphs_type.from_library module: - entity_reference_revisions @@ -54,6 +56,7 @@ settings: divider: divider from_library: from_library civictheme_message: civictheme_message + do_service_detail: do_service_detail negate: 0 target_bundles_drag_drop: civictheme_accordion: @@ -149,6 +152,9 @@ settings: divider: weight: 60 enabled: true + do_service_detail: + weight: 20 + enabled: true from_library: weight: 62 enabled: true diff --git a/config/default/field.field.paragraph.civictheme_manual_list.field_c_p_list_items.yml b/config/default/field.field.paragraph.civictheme_manual_list.field_c_p_list_items.yml index a6c2028c..3b34b0a9 100644 --- a/config/default/field.field.paragraph.civictheme_manual_list.field_c_p_list_items.yml +++ b/config/default/field.field.paragraph.civictheme_manual_list.field_c_p_list_items.yml @@ -17,6 +17,7 @@ dependencies: - paragraphs.paragraphs_type.civictheme_snippet_ref - paragraphs.paragraphs_type.civictheme_subject_card - paragraphs.paragraphs_type.civictheme_subject_card_ref + - paragraphs.paragraphs_type.do_fact_card module: - entity_reference_revisions _core: @@ -47,6 +48,7 @@ settings: civictheme_snippet_ref: civictheme_snippet_ref civictheme_subject_card: civictheme_subject_card civictheme_subject_card_ref: civictheme_subject_card_ref + do_fact_card: do_fact_card negate: 0 target_bundles_drag_drop: civictheme_accordion: @@ -136,4 +138,7 @@ settings: civictheme_webform: weight: -32 enabled: false + do_fact_card: + weight: -31 + enabled: true field_type: entity_reference_revisions diff --git a/config/default/field.field.paragraph.civictheme_manual_list.field_p_eyebrow.yml b/config/default/field.field.paragraph.civictheme_manual_list.field_p_eyebrow.yml new file mode 100644 index 00000000..e9742d81 --- /dev/null +++ b/config/default/field.field.paragraph.civictheme_manual_list.field_p_eyebrow.yml @@ -0,0 +1,19 @@ +uuid: 8b2c3d4e-5f60-4172-9384-950617283940 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_eyebrow + - paragraphs.paragraphs_type.civictheme_manual_list +id: paragraph.civictheme_manual_list.field_p_eyebrow +field_name: field_p_eyebrow +entity_type: paragraph +bundle: civictheme_manual_list +label: Eyebrow +description: 'A small category label shown above the section title.' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.civictheme_snippet.field_p_snippet_appearance.yml b/config/default/field.field.paragraph.civictheme_snippet.field_p_snippet_appearance.yml new file mode 100644 index 00000000..8d8b5ac2 --- /dev/null +++ b/config/default/field.field.paragraph.civictheme_snippet.field_p_snippet_appearance.yml @@ -0,0 +1,21 @@ +uuid: dd7b2811-e267-4f5c-97cf-b1be6dbf9dc1 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_snippet_appearance + - paragraphs.paragraphs_type.civictheme_snippet + module: + - options +id: paragraph.civictheme_snippet.field_p_snippet_appearance +field_name: field_p_snippet_appearance +entity_type: paragraph +bundle: civictheme_snippet +label: 'Snippet appearance' +description: 'Optional per-item design treatment.' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/default/field.field.paragraph.do_fact_card.field_c_p_summary.yml b/config/default/field.field.paragraph.do_fact_card.field_c_p_summary.yml new file mode 100644 index 00000000..f4d98039 --- /dev/null +++ b/config/default/field.field.paragraph.do_fact_card.field_c_p_summary.yml @@ -0,0 +1,19 @@ +uuid: 0b4c3e62-5d8f-4a9c-1e34-7f0a6b2c9d43 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_summary + - paragraphs.paragraphs_type.do_fact_card +id: paragraph.do_fact_card.field_c_p_summary +field_name: field_c_p_summary +entity_type: paragraph +bundle: do_fact_card +label: Label +description: 'The label beneath the figure.' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string_long diff --git a/config/default/field.field.paragraph.do_fact_card.field_c_p_theme.yml b/config/default/field.field.paragraph.do_fact_card.field_c_p_theme.yml new file mode 100644 index 00000000..46cffef8 --- /dev/null +++ b/config/default/field.field.paragraph.do_fact_card.field_c_p_theme.yml @@ -0,0 +1,23 @@ +uuid: 1c5d4f73-6e90-4b0d-2f45-8a1b7c3d0e54 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_theme + - paragraphs.paragraphs_type.do_fact_card + module: + - options +id: paragraph.do_fact_card.field_c_p_theme +field_name: field_c_p_theme +entity_type: paragraph +bundle: do_fact_card +label: Theme +description: '' +required: true +translatable: true +default_value: + - + value: light +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/default/field.field.paragraph.do_fact_card.field_c_p_title.yml b/config/default/field.field.paragraph.do_fact_card.field_c_p_title.yml new file mode 100644 index 00000000..6750e6fe --- /dev/null +++ b/config/default/field.field.paragraph.do_fact_card.field_c_p_title.yml @@ -0,0 +1,19 @@ +uuid: 9a3b2d51-4c7e-4f8b-0d23-6e9f5a1b8c32 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_title + - paragraphs.paragraphs_type.do_fact_card +id: paragraph.do_fact_card.field_c_p_title +field_name: field_c_p_title +entity_type: paragraph +bundle: do_fact_card +label: Fact +description: 'The figure, e.g. "0" or "1 day".' +required: true +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.do_fact_card.field_p_suffix.yml b/config/default/field.field.paragraph.do_fact_card.field_p_suffix.yml new file mode 100644 index 00000000..afd9496f --- /dev/null +++ b/config/default/field.field.paragraph.do_fact_card.field_p_suffix.yml @@ -0,0 +1,19 @@ +uuid: cf607182-93a4-45b6-9728-394051627384 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_suffix + - paragraphs.paragraphs_type.do_fact_card +id: paragraph.do_fact_card.field_p_suffix +field_name: field_p_suffix +entity_type: paragraph +bundle: do_fact_card +label: Suffix +description: 'A small unit shown after the figure, e.g. "day", "yrs" or "+".' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.do_service_detail.field_c_p_content.yml b/config/default/field.field.paragraph.do_service_detail.field_c_p_content.yml new file mode 100644 index 00000000..1fe2e117 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_c_p_content.yml @@ -0,0 +1,22 @@ +uuid: 90252fdd-a062-4145-a96c-661fa5b3ec8f +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_content + - paragraphs.paragraphs_type.do_service_detail + module: + - text +id: paragraph.do_service_detail.field_c_p_content +field_name: field_c_p_content +entity_type: paragraph +bundle: do_service_detail +label: Description +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + allowed_formats: { } +field_type: text_long diff --git a/config/default/field.field.paragraph.do_service_detail.field_c_p_link.yml b/config/default/field.field.paragraph.do_service_detail.field_c_p_link.yml new file mode 100644 index 00000000..32a958b8 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_c_p_link.yml @@ -0,0 +1,23 @@ +uuid: 474413ef-390d-45ba-9192-e5f35c468178 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_link + - paragraphs.paragraphs_type.do_service_detail + module: + - link +id: paragraph.do_service_detail.field_c_p_link +field_name: field_c_p_link +entity_type: paragraph +bundle: do_service_detail +label: Link +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: + title: 1 + link_type: 17 +field_type: link diff --git a/config/default/field.field.paragraph.do_service_detail.field_c_p_theme.yml b/config/default/field.field.paragraph.do_service_detail.field_c_p_theme.yml new file mode 100644 index 00000000..870ca730 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_c_p_theme.yml @@ -0,0 +1,21 @@ +uuid: 44e03969-ddd3-4668-8f73-5aba646213cb +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_theme + - paragraphs.paragraphs_type.do_service_detail + module: + - options +id: paragraph.do_service_detail.field_c_p_theme +field_name: field_c_p_theme +entity_type: paragraph +bundle: do_service_detail +label: Theme +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: list_string diff --git a/config/default/field.field.paragraph.do_service_detail.field_c_p_title.yml b/config/default/field.field.paragraph.do_service_detail.field_c_p_title.yml new file mode 100644 index 00000000..0a41c35c --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_c_p_title.yml @@ -0,0 +1,19 @@ +uuid: 3e94cc10-629d-44e0-81c4-fe7d656b8bcc +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_c_p_title + - paragraphs.paragraphs_type.do_service_detail +id: paragraph.do_service_detail.field_c_p_title +field_name: field_c_p_title +entity_type: paragraph +bundle: do_service_detail +label: Title +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.do_service_detail.field_p_includes.yml b/config/default/field.field.paragraph.do_service_detail.field_p_includes.yml new file mode 100644 index 00000000..03758741 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_p_includes.yml @@ -0,0 +1,19 @@ +uuid: 1d92ddae-083d-41de-b0f7-525ee5116c1e +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_includes + - paragraphs.paragraphs_type.do_service_detail +id: paragraph.do_service_detail.field_p_includes +field_name: field_p_includes +entity_type: paragraph +bundle: do_service_detail +label: "What's included" +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.do_service_detail.field_p_price_label.yml b/config/default/field.field.paragraph.do_service_detail.field_p_price_label.yml new file mode 100644 index 00000000..08d82e14 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_p_price_label.yml @@ -0,0 +1,19 @@ +uuid: d1380cdd-b256-4d2c-b06a-2fcb37df2491 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_price_label + - paragraphs.paragraphs_type.do_service_detail +id: paragraph.do_service_detail.field_p_price_label +field_name: field_p_price_label +entity_type: paragraph +bundle: do_service_detail +label: 'Price label' +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.do_service_detail.field_p_price_value.yml b/config/default/field.field.paragraph.do_service_detail.field_p_price_value.yml new file mode 100644 index 00000000..0bfa6dd0 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_p_price_value.yml @@ -0,0 +1,19 @@ +uuid: ad774eb0-0a46-4012-b64c-ff5a5534c4e5 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_price_value + - paragraphs.paragraphs_type.do_service_detail +id: paragraph.do_service_detail.field_p_price_value +field_name: field_p_price_value +entity_type: paragraph +bundle: do_service_detail +label: 'Price value' +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.field.paragraph.do_service_detail.field_p_tagline.yml b/config/default/field.field.paragraph.do_service_detail.field_p_tagline.yml new file mode 100644 index 00000000..a6380a74 --- /dev/null +++ b/config/default/field.field.paragraph.do_service_detail.field_p_tagline.yml @@ -0,0 +1,19 @@ +uuid: 93abc691-3568-4892-96c3-e672796f60a2 +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_p_tagline + - paragraphs.paragraphs_type.do_service_detail +id: paragraph.do_service_detail.field_p_tagline +field_name: field_p_tagline +entity_type: paragraph +bundle: do_service_detail +label: Tagline +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/default/field.storage.block_content.field_c_b_banner_type.yml b/config/default/field.storage.block_content.field_c_b_banner_type.yml index 7d082938..13bf9d07 100644 --- a/config/default/field.storage.block_content.field_c_b_banner_type.yml +++ b/config/default/field.storage.block_content.field_c_b_banner_type.yml @@ -22,6 +22,15 @@ settings: - value: intro label: Intro + - + value: hero + label: Hero + - + value: fade + label: Fade + - + value: page + label: Page allowed_values_function: '' module: options locked: false diff --git a/config/default/field.storage.node.field_c_n_banner_type.yml b/config/default/field.storage.node.field_c_n_banner_type.yml index ae0395b7..e8b04fb8 100644 --- a/config/default/field.storage.node.field_c_n_banner_type.yml +++ b/config/default/field.storage.node.field_c_n_banner_type.yml @@ -25,6 +25,15 @@ settings: - value: intro label: Intro + - + value: hero + label: Hero + - + value: fade + label: Fade + - + value: page + label: Page allowed_values_function: '' module: options locked: false diff --git a/config/default/field.storage.paragraph.field_p_eyebrow.yml b/config/default/field.storage.paragraph.field_p_eyebrow.yml new file mode 100644 index 00000000..cf75f65a --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_eyebrow.yml @@ -0,0 +1,21 @@ +uuid: 7a1b2c3d-4e5f-4061-8273-849506172839 +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_p_eyebrow +field_name: field_p_eyebrow +entity_type: paragraph +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/field.storage.paragraph.field_p_includes.yml b/config/default/field.storage.paragraph.field_p_includes.yml new file mode 100644 index 00000000..62263432 --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_includes.yml @@ -0,0 +1,21 @@ +uuid: b74bdcd9-5d3e-4031-b76e-b3f1759f2d40 +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_p_includes +field_name: field_p_includes +entity_type: paragraph +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: -1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/field.storage.paragraph.field_p_price_label.yml b/config/default/field.storage.paragraph.field_p_price_label.yml new file mode 100644 index 00000000..53765b86 --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_price_label.yml @@ -0,0 +1,21 @@ +uuid: ad6846f8-31ae-4bd7-b10e-aa44e71be4fa +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_p_price_label +field_name: field_p_price_label +entity_type: paragraph +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/field.storage.paragraph.field_p_price_value.yml b/config/default/field.storage.paragraph.field_p_price_value.yml new file mode 100644 index 00000000..ac88c921 --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_price_value.yml @@ -0,0 +1,21 @@ +uuid: 1ad5362b-0245-41fd-b210-e0611b539057 +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_p_price_value +field_name: field_p_price_value +entity_type: paragraph +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/field.storage.paragraph.field_p_snippet_appearance.yml b/config/default/field.storage.paragraph.field_p_snippet_appearance.yml new file mode 100644 index 00000000..b5db31b7 --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_snippet_appearance.yml @@ -0,0 +1,30 @@ +uuid: dbe6c054-3b0a-460b-b9df-148339237da6 +langcode: en +status: true +dependencies: + module: + - options + - paragraphs +id: paragraph.field_p_snippet_appearance +field_name: field_p_snippet_appearance +entity_type: paragraph +type: list_string +settings: + allowed_values: + - + value: itemized + label: 'Itemized row' + - + value: dotted + label: 'Dotted row' + - + value: trust + label: 'Trust card' + allowed_values_function: '' +module: options +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/field.storage.paragraph.field_p_suffix.yml b/config/default/field.storage.paragraph.field_p_suffix.yml new file mode 100644 index 00000000..1a9eabfd --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_suffix.yml @@ -0,0 +1,21 @@ +uuid: be5f6071-8293-44a5-8617-283940516273 +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_p_suffix +field_name: field_p_suffix +entity_type: paragraph +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/field.storage.paragraph.field_p_tagline.yml b/config/default/field.storage.paragraph.field_p_tagline.yml new file mode 100644 index 00000000..087e6344 --- /dev/null +++ b/config/default/field.storage.paragraph.field_p_tagline.yml @@ -0,0 +1,21 @@ +uuid: a39feacf-0489-4a38-a427-37d12f3c9b30 +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_p_tagline +field_name: field_p_tagline +entity_type: paragraph +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/default/filter.format.full_html.yml b/config/default/filter.format.full_html.yml deleted file mode 100644 index 3ab6576b..00000000 --- a/config/default/filter.format.full_html.yml +++ /dev/null @@ -1,42 +0,0 @@ -uuid: bdc967bd-9946-4d42-9190-b40e0b26dea9 -langcode: en -status: false -dependencies: - module: - - editor -_core: - default_config_hash: vpZysv3RHJjhYzq7O_q5q8mVlHdrSzKELmXyiX6RtV0 -name: 'Full HTML' -format: full_html -weight: -6 -filters: - editor_file_reference: - id: editor_file_reference - provider: editor - status: true - weight: 11 - settings: { } - filter_align: - id: filter_align - provider: filter - status: true - weight: 8 - settings: { } - filter_caption: - id: filter_caption - provider: filter - status: true - weight: 9 - settings: { } - filter_htmlcorrector: - id: filter_htmlcorrector - provider: filter - status: true - weight: 10 - settings: { } - filter_image_lazy_load: - id: filter_image_lazy_load - provider: filter - status: true - weight: 15 - settings: { } diff --git a/config/default/paragraphs.paragraphs_type.do_fact_card.yml b/config/default/paragraphs.paragraphs_type.do_fact_card.yml new file mode 100644 index 00000000..45b48f78 --- /dev/null +++ b/config/default/paragraphs.paragraphs_type.do_fact_card.yml @@ -0,0 +1,10 @@ +uuid: 8f2a1c40-3b6d-4e7a-9c12-5d8e4f0a7b21 +langcode: en +status: true +dependencies: { } +id: do_fact_card +label: 'Fact card' +icon_uuid: null +icon_default: null +description: 'A single statistic - a large figure with a label.' +behavior_plugins: { } diff --git a/config/default/paragraphs.paragraphs_type.do_service_detail.yml b/config/default/paragraphs.paragraphs_type.do_service_detail.yml new file mode 100644 index 00000000..81721e3d --- /dev/null +++ b/config/default/paragraphs.paragraphs_type.do_service_detail.yml @@ -0,0 +1,10 @@ +uuid: 83529221-4372-440b-993f-0b6cf539a675 +langcode: en +status: true +dependencies: { } +id: do_service_detail +label: 'Service detail' +icon_uuid: null +icon_default: null +description: 'A detailed service card: title, tagline, description, inclusions, price and a call to action.' +behavior_plugins: { } diff --git a/config/default/webform.webform.contact.yml b/config/default/webform.webform.contact.yml index 4c19e715..0848802f 100644 --- a/config/default/webform.webform.contact.yml +++ b/config/default/webform.webform.contact.yml @@ -19,25 +19,34 @@ description: 'Basic email contact webform.' categories: { } elements: | name: - '#title': 'Your Name' + '#title': 'Name' '#type': textfield '#required': true - '#default_value': '[current-user:display-name]' + '#placeholder': 'Your full name' email: - '#title': 'Your Email' + '#title': 'Email' '#type': email '#required': true - '#default_value': '[current-user:mail]' - subject: - '#title': Subject + '#placeholder': 'you@organisation.com' + organisation: + '#title': 'Organisation' '#type': textfield - '#required': true - '#test': 'Testing contact webform from [site:name]' + '#placeholder': 'Company or department' + subject: + '#title': 'What do you need help with?' + '#type': select + '#empty_option': 'Select a topic' + '#options': + new-build: 'New Drupal website' + upgrade: 'Upgrade or migration' + support: 'Ongoing support' + audit: 'Platform audit or site check' + other: 'Something else' message: - '#title': Message + '#title': 'Tell us more' '#type': textarea - '#required': true - '#test': 'Please ignore this email.' + '#rows': 5 + '#placeholder': 'What''s the current state of your platform? What are you looking to achieve?' actions: '#type': webform_actions '#title': 'Submit button(s)' diff --git a/tests/behat/features/contact.feature b/tests/behat/features/contact.feature index d25d2804..04644176 100644 --- a/tests/behat/features/contact.feature +++ b/tests/behat/features/contact.feature @@ -9,23 +9,24 @@ Feature: Contact form Scenario: Anonymous user can use the Contact link and Contact form. Given I am an anonymous user When I go to "/contact" - Then I should see the heading Contact + Then I should see the heading "Let's talk about your platform." And I should see "Contact" - And I should see "Your Name" - And I should see "Your Email" - And I should see "Subject" - And I should see "Message" + And I should see "Name" + And I should see "Email" + And I should see "Organisation" + And I should see "What do you need help with?" + And I should see "Tell us more" And I should see the button "Send message" @api Scenario: Anonymous user can fill and submit the contact form Given I am an anonymous user When I go to "/contact" - Then I should see the heading Contact + Then I should see the heading "Let's talk about your platform." When I fill in "Name" with "Test User" And I fill in "Email" with "test@example.com" - And I fill in "Subject" with "Test Contact" - And I fill in "Message" with "This is a test message for the contact form." + And I select "Ongoing support" from "What do you need help with?" + And I fill in "Tell us more" with "This is a test message for the contact form." And I save screenshot And I press "Send message" # The form may not send actual messages in the test environment @@ -40,10 +41,8 @@ Feature: Contact form Given I am an anonymous user When I go to "/contact" And browser validation for the form ".webform-submission-contact-form" is disabled - Then I should see the heading Contact + Then I should see the heading "Let's talk about your platform." When I press "Send message" Then I should see the text "Name field is required." And I should see the text "Email field is required." - And I should see the text "Subject field is required." - And I should see the text "Message field is required." And I save screenshot diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 6e95f025..9f4d2c3e 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -8,3 +8,454 @@ */ declare(strict_types=1); + +use Drupal\do_base\ContentBuilder; +use Drupal\drupal_helpers\Helper; +use Drupal\node\Entity\Node; +use Drupal\paragraphs\Entity\Paragraph; + +/** + * Flip every component paragraph to the dark theme. + * + * The design renders the whole site dark. CivicTheme themes each component + * individually through the shared `field_c_p_theme` field, and list components + * theme their rendered items separately through `field_c_p_list_item_theme`, so + * this switches both - across every bundle - to `dark`. The revision is updated + * in place so the referencing entity revision keeps resolving to it. Content + * created by later deploy hooks is built dark directly. + */ +function do_base_deploy_components_dark(?array &$sandbox): ?string { + $fields = ['field_c_p_theme', 'field_c_p_list_item_theme']; + + return Helper::entity($sandbox)->batchEntity('paragraph', NULL, static function ($paragraph) use ($fields): void { + $changed = FALSE; + + foreach ($fields as $field) { + if (!$paragraph->hasField($field)) { + continue; + } + + if ($paragraph->get($field)->value === 'dark') { + continue; + } + + $paragraph->set($field, 'dark'); + $changed = TRUE; + } + + if (!$changed) { + return; + } + + $paragraph->setNewRevision(FALSE); + $paragraph->save(); + }); +} + +/** + * Flip CivicTheme block content to the dark theme. + * + * Page-chrome blocks (the mobile navigation drawer, banner, social links) + * carry their own `field_c_b_theme`, and the mobile navigation also themes + * its trigger through `field_c_b_trigger_theme`. The mobile navigation ships + * light, so it ignored the dark design; switch every block - and its + * trigger - to dark so the whole site, including the mobile drawer, is dark. + */ +function do_base_deploy_blocks_dark(?array &$sandbox): ?string { + $fields = ['field_c_b_theme', 'field_c_b_trigger_theme']; + + return Helper::entity($sandbox)->batchEntity('block_content', NULL, static function ($block) use ($fields): void { + $changed = FALSE; + + foreach ($fields as $field) { + if (!$block->hasField($field)) { + continue; + } + + if ($block->get($field)->value === 'dark') { + continue; + } + + $block->set($field, 'dark'); + $changed = TRUE; + } + + if (!$changed) { + return; + } + + $block->setNewRevision(FALSE); + $block->save(); + }); +} + +/** + * Rebuild the homepage from CivicTheme components. + * + * The hero is the node banner (populated, not cleared). Every section is built + * from CivicTheme paragraph components - manual lists of snippets for the + * services, stats, trust, "why" and process sections, and a callout for + * the closing CTA. Bespoke design treatments are driven by per-item variant + * classes on the snippets; the list itself carries no style field. + * No markup is stored as content. New references are saved before the previous + * paragraphs are deleted, so a failed save never leaves dangling references. + */ +function do_base_deploy_homepage(): string { + // Resolve the configured front page rather than assuming a fixed node ID. + $front = (string) \Drupal::config('system.site')->get('page.front'); + $node = preg_match('#^/node/(\d+)$#', $front, $matches) ? Node::load((int) $matches[1]) : NULL; + + if (!$node instanceof Node || !$node->hasField('field_c_n_components')) { + return 'Homepage node not found - skipped.'; + } + + $components = [ + ContentBuilder::manualList('', 1, [ + ContentBuilder::snippet('Website Delivery', "We build your site with automated testing and CI/CD from the first commit, so what launches is solid from day one, not a prototype you'll be fixing after go-live. AI-assisted delivery gets you there faster, at the same tested standard.", 'itemized'), + ContentBuilder::snippet('Ongoing Support', 'Proactive maintenance from the people who built your platform. Security updates, monitoring, continuous improvement, and a direct line with no layers in between.', 'itemized'), + ContentBuilder::snippet('Upgrades & Migrations', 'Running an end-of-life Drupal 7 or 9 site? We handle the full migration with test coverage and zero-downtime deployments, so you stay compliant and your users never notice the switch.', 'itemized'), + ], 'What we do'), + ContentBuilder::manualList('Faster, without lowering the bar.', 1, [ + ContentBuilder::snippet('The same quality, in a fraction of the time', "AI does the heavy lifting on production work, so builds that used to take weeks take days. Same automated tests, same CI, same code you can read on GitHub. The quality bar doesn't move.", 'dotted'), + ContentBuilder::snippet('"Isn\'t AI-written code risky?"', "Not the way we do it. Every change is reviewed, and every build is covered by automated tests and CI before it ships. AI speeds up the writing, not the checking. The guardrails that make this work are our own, and they're open source, so you can see exactly how it runs.", 'dotted'), + ContentBuilder::snippet('Your code and data stay yours', 'We control the models and the data handling, and nothing trains on your project. It\'s all written down in our Responsible AI policy.', 'dotted'), + ], 'AI-assisted delivery'), + ContentBuilder::manualList('', 2, [ + ContentBuilder::factCard('1', 'To set up CI/CD on a new project', 'day'), + ContentBuilder::factCard('10', 'Delivering reliable platforms', 'yrs'), + ContentBuilder::factCard('40', 'Open-source tools we maintain', '+'), + ContentBuilder::factCard('0', 'Excuses when something breaks'), + ], 'The essentials'), + ContentBuilder::manualList("Trusted on projects where failure isn't an option.", 4, [ + ContentBuilder::snippet('Victorian Government', "Delivered Australia's first Docker-based government Drupal platform.", 'trust'), + ContentBuilder::snippet('Australian Defence', 'Multiple classified platforms with complex security and compliance requirements.', 'trust'), + ContentBuilder::snippet('GovCMS', "Drupal platform delivery on Australia's government hosting infrastructure.", 'trust'), + ContentBuilder::snippet('Education', 'University platforms with ongoing support, leading to internal referrals across departments.', 'trust'), + ], 'Track record'), + ContentBuilder::manualList('No filler. No overhead. Just good engineering.', 1, [ + ContentBuilder::snippet('Automated testing is not optional', "Every platform ships with a full test suite. Functional, unit, and visual regression tests run on every commit. If it's not tested, it doesn't deploy.", 'dotted'), + ContentBuilder::snippet('One team, zero handovers', 'We handle development, DevOps, and production support. One team with full context, no vendors blaming each other, no knowledge lost between handoffs.', 'dotted'), + ContentBuilder::snippet('Pricing that makes sense', "Flat-rate pricing with standard and rapid response options. We'll tell you what it costs upfront. No retainer games, no billable surprises, no markup on markup.", 'dotted'), + ContentBuilder::snippet('Direct line to the engineers', 'You talk to the people building your platform. We manage the project without adding layers between you and the work. Fast communication, honest updates, no runaround.', 'dotted'), + ]), + ContentBuilder::manualList('A clear path from kickoff to ongoing support.', 1, [ + ContentBuilder::snippet('Discovery', 'We review your website, understand your requirements and constraints, and scope the work, including whether AI-assisted delivery is the right fit. You get a clear proposal with flat-rate pricing before any work begins.', 'itemized'), + ContentBuilder::snippet('Delivery', 'Your site is built with automated testing and CI/CD from the first commit, with AI accelerating the production work and every change reviewed before it lands. Regular check-ins, transparent progress, and no surprises at the end.', 'itemized'), + ContentBuilder::snippet('Ongoing support', 'The same people who built your site maintain it. Security updates, continuous improvement, and proactive monitoring on a prepaid support arrangement.', 'itemized'), + ], 'How we work'), + ContentBuilder::callout('Let\'s talk about your website.', '

Tell us where things stand, what\'s working, and what\'s not. We\'ll be straight with you about whether we\'re the right fit.

Talk to us

'), + ]; + + foreach ($components as $component) { + $component->save(); + } + + $stale = array_merge( + ContentBuilder::stageBanner( + $node, + 'Reliable websites, delivered fasterYour website
can\'t afford to wait.', + 'We build and support reliable websites for businesses and organisations that depend on them.
Now delivered faster and cheaper with AI-assisted development.', + ['title' => 'Talk to us', 'uri' => '/contact'] + ), + $node->get('field_c_n_components')->referencedEntities() + ); + $node->set('field_c_n_components', $components); + $node->save(); + ContentBuilder::deleteEntities($stale); + + return 'Homepage rebuilt.'; +} + +/** + * Rebuild the Services page from CivicTheme components. + * + * The hero is the node banner. Each service is a Service detail paragraph; the + * "our approach" grid is a manual list of dotted-variant snippets; the CTA is + * a callout. + */ +function do_base_deploy_services(): string { + $nodes = \Drupal::entityTypeManager()->getStorage('node')->loadByProperties([ + 'uuid' => 'b78dd34e-e1b2-480a-9056-80902d410008', + ]); + $node = reset($nodes); + + if (!$node instanceof Node || !$node->hasField('field_c_n_components')) { + return 'Services node not found - skipped.'; + } + + $components = [ + ContentBuilder::serviceDetail('Website Delivery', + 'From requirements to production in one engagement.', + [ + 'Full Drupal website builds delivered with automated testing, CI/CD pipelines, and production-ready infrastructure. We handle the architecture, development, theming, and deployment - your team gets a solid platform, not a prototype that needs fixing after launch.', + 'Every project ships with a complete test suite, documentation, and a handover that actually works.', + ], + [ + 'Technical architecture and planning', + 'Custom module and theme development', + 'Automated testing (PHPUnit, Behat, Cypress)', + 'CI/CD pipeline setup (GitHub Actions, CircleCI)', + 'Content migration and data import', + 'Hosting setup and go-live support', + ], + 'Typical engagement', '$40K - $180K', + 'Discuss your project', '/contact' + ), + ContentBuilder::serviceDetail('Ongoing Support', + 'The same senior team that built it, maintaining it.', + [ + 'Proactive platform maintenance from the engineers who built your site. Security updates, Drupal core and module patches, performance monitoring, and continuous improvement - all on a predictable prepaid arrangement.', + 'No ticket queues, no outsourced support desks. You talk directly to the people who know your codebase.', + ], + [ + 'Security patches and Drupal updates', + 'Uptime and performance monitoring', + 'Bug fixes and minor enhancements', + 'Monthly reporting and recommendations', + 'Direct Slack/email access to engineers', + 'Priority response for critical issues', + ], + 'From', '$740 / month', + 'Get a support quote', '/contact' + ), + ContentBuilder::serviceDetail('Upgrades & Migrations', + 'Move off end-of-life Drupal without breaking anything.', + [ + 'Drupal 7 and 9 are end-of-life. Drupal 10 follows in December 2026. We handle the full migration with test coverage and zero-downtime deployments, so your organisation stays compliant and your users stay unaffected.', + 'We assess your current platform, map out module compatibility, migrate custom code, and deliver an upgraded site with full test coverage.', + ], + [ + 'Platform audit and risk assessment', + 'Module compatibility analysis', + 'Custom code migration and refactoring', + 'Data migration and content integrity checks', + 'Automated test suite for the upgraded site', + 'Zero-downtime deployment and rollback plan', + ], + 'Typical engagement', '$25K - $120K', + 'Book a free assessment', '/contact' + ), + ContentBuilder::manualList('', 2, [ + ContentBuilder::snippet('Senior engineers only', 'No juniors on your project. Every person who touches your code has 10+ years of Drupal experience.', 'dotted'), + ContentBuilder::snippet('Flat-rate pricing', 'We quote a fixed price upfront. No hourly billing surprises, no retainer games, no scope creep charges.', 'dotted'), + ContentBuilder::snippet('Tested by default', "Every platform ships with automated tests. If it's not tested, it doesn't deploy. No exceptions.", 'dotted'), + ContentBuilder::snippet('Direct communication', 'You talk to the engineers building your site. No project managers relaying messages, no layers in between.', 'dotted'), + ], 'Our approach'), + ContentBuilder::callout('Ready to talk about your platform?', '

Tell us where things stand. We\'ll be straight with you about whether we\'re the right fit.

Get in touch

'), + ]; + + foreach ($components as $component) { + $component->save(); + } + + $stale = array_merge( + ContentBuilder::stageBanner( + $node, + 'Engineering that keeps
your platform running.', + "We deliver, support, and upgrade Drupal websites for organisations where downtime, security gaps, and slow development aren't acceptable." + ), + $node->get('field_c_n_components')->referencedEntities() + ); + $node->set('field_c_n_components', $components); + $node->save(); + ContentBuilder::deleteEntities($stale); + + return 'Services page rebuilt.'; +} + +/** + * Rebuild the Contact page to the design. + * + * Keeps the existing contact webform (rendered through a CivicTheme webform + * paragraph) between the hero and the contact-detail sections. + */ +function do_base_deploy_contact(): string { + $path = \Drupal::service('path_alias.manager')->getPathByAlias('/contact'); + + if (!preg_match('#^/node/(\d+)$#', $path, $matches)) { + return 'Contact node not found - skipped.'; + } + + $node = Node::load((int) $matches[1]); + + if (!$node instanceof Node || !$node->hasField('field_c_n_components')) { + return 'Contact node not usable - skipped.'; + } + + // The webform leads (the primary action), followed by the direct contact + // details and the "what to expect" steps rendered as rich-text content. + $components = [ + Paragraph::create([ + 'type' => 'civictheme_webform', + 'field_c_p_theme' => 'dark', + 'field_c_p_webform' => 'contact', + ]), + ContentBuilder::contentRichText(ContentBuilder::contactInfo()), + ]; + + foreach ($components as $component) { + $component->save(); + } + + $stale = array_merge( + ContentBuilder::stageBanner( + $node, + "Let's talk about your platform.", + "Whether you need a new Drupal build, help upgrading from an end-of-life version, or ongoing support from a senior team - we're happy to have an honest conversation about where things stand." + ), + $node->get('field_c_n_components')->referencedEntities() + ); + $node->set('field_c_n_components', $components); + $node->save(); + ContentBuilder::deleteEntities($stale); + + return 'Contact page rebuilt.'; +} + +/** + * Seed the design demo blog article. + * + * Idempotent: matches the existing article by title, otherwise creates it. + */ +function do_base_deploy_blog_demo(): string { + $title = 'Why Your Drupal CI Pipeline Is Slower Than It Should Be'; + $storage = \Drupal::entityTypeManager()->getStorage('node'); + + $existing = $storage->loadByProperties([ + 'type' => 'civictheme_page', + 'title' => $title, + ]); + + $node = $existing ? reset($existing) : Node::create([ + 'type' => 'civictheme_page', + 'title' => $title, + ]); + + $node->set('status', 1); + + // The article body is genuine editorial rich-text (headings, tables, code, + // blockquotes, a CTA), which is exactly what civictheme_content with the + // rich-text format is for - no full_html, no stored design markup. + $body = ContentBuilder::contentRichText(ContentBuilder::blogBody()); + $body->save(); + + $previous = $node->hasField('field_c_n_components') ? $node->get('field_c_n_components')->referencedEntities() : []; + $stale = array_merge(ContentBuilder::stageBanner($node, $title), $previous); + + if ($node->hasField('field_c_n_components')) { + $node->set('field_c_n_components', [$body]); + } + + $node->save(); + ContentBuilder::deleteEntities($stale); + + return 'Blog demo article seeded.'; +} + +/** + * Set the blog listing page hero to the design's headline. + * + * The /blog node falls back to the bare "Blog" title; the design uses a + * descriptive headline above the article grid. + */ +function do_base_deploy_blog_listing(): string { + $path = \Drupal::service('path_alias.manager')->getPathByAlias('/blog'); + + if (!preg_match('#^/node/(\d+)$#', $path, $matches)) { + return 'Blog listing node not found - skipped.'; + } + + $node = Node::load((int) $matches[1]); + + if (!$node instanceof Node || !$node->hasField('field_c_n_banner_title')) { + return 'Blog listing node not usable - skipped.'; + } + + $node->set('field_c_n_banner_title', 'BlogPractical engineering insights
from the teams we work with.'); + + if ($node->hasField('field_c_n_banner_type')) { + $node->set('field_c_n_banner_type', 'page'); + } + + if ($node->hasField('field_c_n_banner_theme')) { + $node->set('field_c_n_banner_theme', 'dark'); + } + + foreach (['field_c_n_banner_background', 'field_c_n_banner_featured_image'] as $image_field) { + if ($node->hasField($image_field)) { + $node->set($image_field, NULL); + } + } + + // Featured article: clone the article grid into a single most-recent card, + // shown full-width above the grid (the design's featured post). Idempotent - + // skip when a single-item list is already first. + if ($node->hasField('field_c_n_components')) { + $components = $node->get('field_c_n_components')->referencedEntities(); + $first = $components[0] ?? NULL; + $already = $first instanceof Paragraph && $first->bundle() === 'civictheme_automated_list' && (int) $first->get('field_c_p_list_limit')->value === 1; + + if (!$already) { + $grid = NULL; + + foreach ($components as $component) { + if ($component->bundle() === 'civictheme_automated_list') { + $grid = $component; + break; + } + } + + if ($grid instanceof Paragraph) { + $featured = $grid->createDuplicate(); + $featured->set('field_c_p_list_limit', 1); + $featured->set('field_c_p_list_limit_type', 'limited'); + $featured->set('field_c_p_list_column_count', 1); + $featured->save(); + + array_unshift($components, $featured); + $node->set('field_c_n_components', $components); + } + } + } + + $node->save(); + + return 'Blog listing banner updated.'; +} + +/** + * Apply the design's blog-post treatment. + * + * Blog posts (pages that carry topics) ship with tags hidden and a default + * banner. The design reveals the topic pills and renders the header as a fade + * hero over the featured image, so switch the tags on and, where the page has a + * featured image, set the banner to the "fade" type. + */ +function do_base_deploy_blog_tags(?array &$sandbox): ?string { + return Helper::entity($sandbox)->batchEntity('node', NULL, static function ($node): void { + if ($node->bundle() !== 'civictheme_page' || !$node->hasField('field_c_n_topics') || $node->get('field_c_n_topics')->isEmpty()) { + return; + } + + $changed = FALSE; + + if ($node->hasField('field_c_n_hide_tags') && $node->get('field_c_n_hide_tags')->value !== '0') { + $node->set('field_c_n_hide_tags', 0); + $changed = TRUE; + } + + $has_image = $node->hasField('field_c_n_banner_featured_image') && !$node->get('field_c_n_banner_featured_image')->isEmpty(); + + if ($has_image && $node->hasField('field_c_n_banner_type') && $node->get('field_c_n_banner_type')->value !== 'fade') { + $node->set('field_c_n_banner_type', 'fade'); + $changed = TRUE; + } + + if (!$changed) { + return; + } + + $node->setNewRevision(FALSE); + $node->save(); + }); +} diff --git a/web/modules/custom/do_base/do_base.info.yml b/web/modules/custom/do_base/do_base.info.yml index 0c8cebcf..6120db07 100644 --- a/web/modules/custom/do_base/do_base.info.yml +++ b/web/modules/custom/do_base/do_base.info.yml @@ -3,3 +3,5 @@ type: module description: Base feature for DrevOps Website site. core_version_requirement: ^11 package: website +dependencies: + - drupal_helpers:drupal_helpers diff --git a/web/modules/custom/do_base/src/ContentBuilder.php b/web/modules/custom/do_base/src/ContentBuilder.php new file mode 100644 index 00000000..a6f164ad --- /dev/null +++ b/web/modules/custom/do_base/src/ContentBuilder.php @@ -0,0 +1,498 @@ + string, 'uri' => string]. + * + * @return \Drupal\Core\Entity\EntityInterface[] + * The previously referenced banner paragraphs, pending deletion. + */ + public static function stageBanner(Node $node, string $title, string $subtitle = '', ?array $cta = NULL): array { + if ($node->hasField('field_c_n_banner_title')) { + $node->set('field_c_n_banner_title', $title); + } + + if ($node->hasField('field_c_n_banner_type')) { + $node->set('field_c_n_banner_type', 'hero'); + } + + if ($node->hasField('field_c_n_banner_theme')) { + $node->set('field_c_n_banner_theme', 'dark'); + } + + // The design hero is the dark hero banner with its atmospheric glow, not an + // image. Clear any background or featured image left on the node from the + // demo content so the hero renders dark with legible white text. + foreach (['field_c_n_banner_background', 'field_c_n_banner_featured_image'] as $image_field) { + if ($node->hasField($image_field)) { + $node->set($image_field, NULL); + } + } + + $previous = []; + + foreach (['field_c_n_banner_components', 'field_c_n_banner_components_bott'] as $field) { + if (!$node->hasField($field)) { + continue; + } + + $previous = array_merge($previous, $node->get($field)->referencedEntities()); + $node->set($field, []); + } + + if ($subtitle !== '' && $node->hasField('field_c_n_banner_components')) { + $intro = self::bannerIntro($subtitle, $cta); + $intro->save(); + $node->set('field_c_n_banner_components', [$intro]); + } + + return $previous; + } + + /** + * Build the dark CivicTheme content component that holds the hero subtitle. + * + * @param string $subtitle + * The hero subtitle. + * @param array|null $cta + * Optional call-to-action button as ['title' => string, 'uri' => string], + * rendered as a CivicTheme button after the subtitle. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved content paragraph for the banner content slot. + */ + protected static function bannerIntro(string $subtitle, ?array $cta): Paragraph { + $html = '

' . $subtitle . '

'; + + if ($cta !== NULL) { + $html .= '

' . $cta['title'] . '

'; + } + + return Paragraph::create([ + 'type' => 'civictheme_content', + 'field_c_p_theme' => 'dark', + 'field_c_p_content' => [ + 'value' => $html, + 'format' => 'civictheme_rich_text', + ], + ]); + } + + /** + * Build a dark CivicTheme snippet (title + summary) list item. + * + * @param string $title + * The snippet title. + * @param string $summary + * The snippet summary. + * @param string $variant + * Optional appearance variant key (e.g. 'itemized'). Empty for none. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved snippet paragraph. + */ + public static function snippet(string $title, string $summary, string $variant = ''): Paragraph { + $values = [ + 'type' => 'civictheme_snippet', + 'field_c_p_theme' => 'dark', + 'field_c_p_title' => $title, + 'field_c_p_summary' => $summary, + ]; + + if ($variant !== '') { + $values['field_p_snippet_appearance'] = $variant; + } + + return Paragraph::create($values); + } + + /** + * Build a dark Fact card (a large figure above a label). + * + * @param string $fact + * The figure, e.g. "0" or "40". + * @param string $label + * The label beneath the figure. + * @param string $suffix + * A small unit after the figure, e.g. "day", "yrs" or "+". + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved fact card paragraph. + */ + public static function factCard(string $fact, string $label, string $suffix = ''): Paragraph { + return Paragraph::create([ + 'type' => 'do_fact_card', + 'field_c_p_theme' => 'dark', + 'field_c_p_title' => $fact, + 'field_p_suffix' => $suffix, + 'field_c_p_summary' => $label, + ]); + } + + /** + * Build a dark CivicTheme manual list of snippet items. + * + * Design treatments are driven by per-item variant classes on the snippets + * themselves; the list carries no style field. + * + * @param string $title + * The section heading. + * @param int $columns + * Column count (1-4). + * @param \Drupal\paragraphs\Entity\Paragraph[] $items + * The snippet list items. + * @param string $eyebrow + * The small eyebrow label above the heading. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved manual list paragraph referencing the saved items. + */ + public static function manualList(string $title, int $columns, array $items, string $eyebrow = ''): Paragraph { + foreach ($items as $item) { + $item->save(); + } + + return Paragraph::create([ + 'type' => 'civictheme_manual_list', + 'field_c_p_theme' => 'dark', + 'field_c_p_title' => $title, + 'field_c_p_list_column_count' => $columns, + 'field_p_eyebrow' => $eyebrow, + 'field_c_p_list_items' => $items, + ]); + } + + /** + * Build a dark CivicTheme callout (title + rich-text body + link). + * + * @param string $title + * The callout heading. + * @param string $body + * The callout body, rendered through the rich-text format. + * @param string $link_title + * The link text. + * @param string $link_uri + * The link URI. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved callout paragraph. + */ + public static function callout(string $title, string $body, string $link_title = '', string $link_uri = ''): Paragraph { + $values = [ + 'type' => 'civictheme_callout', + 'field_c_p_theme' => 'dark', + 'field_c_p_title' => $title, + 'field_c_p_content' => [ + 'value' => $body, + 'format' => 'civictheme_rich_text', + ], + ]; + + // The callout button renders external links (a mailto) with a new-window + // target and an arrow. Only add the button for a real call-to-action link; + // the homepage CTA renders its email inside the body as a plain link + // instead. + if ($link_uri !== '') { + $values['field_c_p_links'] = [['uri' => self::linkUri($link_uri), 'title' => $link_title]]; + } + + return Paragraph::create($values); + } + + /** + * Build a Service detail paragraph. + * + * @param string $title + * The service name. + * @param string $tagline + * The one-line summary shown beside the title. + * @param string[] $paras + * Description paragraphs. + * @param string[] $includes + * The "what's included" list items. + * @param string $price_label + * The price label, e.g. "Typical engagement". + * @param string $price_value + * The price, e.g. "$40K - $180K". + * @param string $link_title + * The call-to-action button text. + * @param string $link_uri + * The call-to-action button URI. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved Service detail paragraph. + */ + public static function serviceDetail(string $title, string $tagline, array $paras, array $includes, string $price_label, string $price_value, string $link_title, string $link_uri): Paragraph { + $description = ''; + + foreach ($paras as $para) { + $description .= '

' . $para . '

'; + } + + return Paragraph::create([ + 'type' => 'do_service_detail', + 'field_c_p_theme' => 'dark', + 'field_c_p_title' => $title, + 'field_p_tagline' => $tagline, + 'field_c_p_content' => [ + 'value' => $description, + 'format' => 'civictheme_rich_text', + ], + 'field_p_includes' => $includes, + 'field_p_price_label' => $price_label, + 'field_p_price_value' => $price_value, + 'field_c_p_link' => [ + 'uri' => self::linkUri($link_uri), + 'title' => $link_title, + ], + ]); + } + + /** + * Build a dark CivicTheme content paragraph from rich-text markup. + * + * @param string $html + * The body markup, rendered through the rich-text format. + * + * @return \Drupal\paragraphs\Entity\Paragraph + * An unsaved content paragraph. + */ + public static function contentRichText(string $html): Paragraph { + return Paragraph::create([ + 'type' => 'civictheme_content', + 'field_c_p_theme' => 'dark', + 'field_c_p_content' => [ + 'value' => $html, + 'format' => 'civictheme_rich_text', + ], + ]); + } + + /** + * Delete entities staged for removal after the referencing node was saved. + * + * @param \Drupal\Core\Entity\EntityInterface[] $entities + * Entities to delete. + */ + public static function deleteEntities(array $entities): void { + foreach ($entities as $entity) { + $entity->delete(); + } + } + + /** + * Normalise a link URI for a Drupal link field. + * + * Root-relative paths are stored with the `internal:` scheme; absolute URLs + * and schemes such as `mailto:` are left untouched. + * + * @param string $uri + * The raw URI. + * + * @return string + * The normalised URI. + */ + protected static function linkUri(string $uri): string { + return str_starts_with($uri, '/') ? 'internal:' . $uri : $uri; + } + + /** + * Build the rich-text body for contact details and "what to expect" steps. + * + * @return string + * The rich-text HTML body. + */ + public static function contactInfo(): string { + return <<<'HTML' +

Email us directly

+

info@drevops.com

+

We typically respond within one business day.

+

Call us

+

04 3009 3538

+

Available weekdays, Melbourne time (AEST).

+

Based in

+

Melbourne, Australia

+

We work with organisations across Australia and New Zealand.

+

What to expect

+
    +
  1. We'll review your message and respond within 24 hours.
  2. +
  3. A 30-minute call to understand your platform and goals.
  4. +
  5. A clear proposal with flat-rate pricing - no surprises.
  6. +
+HTML; + } + + /** + * Build the rich-text body for the demo blog article. + * + * Uses only tags permitted by the `civictheme_rich_text` format: headings, + * paragraphs, striped tables, fenced code (highlighted client-side by the + * highlight_js filter), blockquotes, a list and a CTA button. + * + * @return string + * The rich-text HTML body. + */ + public static function blogBody(): string { + return <<<'HTML' +

Most Drupal teams accept slow CI pipelines as a fact of life. Builds that take 15 minutes, test suites that timeout, and deployments that require a coffee break. It doesn't have to be this way.

+

We've audited dozens of Drupal CI pipelines across government, education, and enterprise organisations. The same problems come up repeatedly. Here's what we find and how to fix it.

+

The usual suspects

+

Before diving into solutions, it helps to understand where time actually goes in a typical Drupal CI build. We measured 24 pipelines across different hosting providers and CI platforms:

+ + + + + + + + + + + + +
Build PhaseMedian TimeWorst CaseOptimised
Docker image pull2m 30s6m 15s15s
Composer install1m 45s4m 20s12s
Database import3m 10s8m 00s45s
PHPUnit tests4m 20s12m 00s1m 30s
Behat/Cypress6m 45s18m 00s2m 15s
Total18m 30s48m 35s4m 57s
+

The gap between worst-case and optimised is significant. Let's walk through the fixes.

+

1. Stop pulling full Docker images on every build

+

The single biggest time sink is rebuilding or pulling Docker images. Most CI configurations start with a base image pull on every run. If you're using docker-compose pull without layer caching, you're downloading gigabytes of data every build.

+

The fix: pre-built CI images

+

Build a dedicated CI image with your PHP extensions, Node version, and system dependencies baked in. Push it to your container registry and reference it directly:

+
# .circleci/config.yml
+jobs:
+  test:
+    docker:
+      - image: ghcr.io/your-org/drupal-ci:php8.3
+        auth:
+          username: $GITHUB_USER
+          password: $GITHUB_TOKEN
+    steps:
+      - checkout
+      - run: composer install --no-interaction --prefer-dist
+      - run: vendor/bin/phpunit
+

This alone typically cuts 2-4 minutes off every build.

+

2. Cache Composer dependencies properly

+

Composer install is deceptively slow because it resolves dependencies, downloads packages, and runs post-install scripts. Most of this work is redundant between builds.

+
#!/usr/bin/env bash
+# Restore Composer cache from CI provider's cache layer.
+COMPOSER_HASH=$(md5sum composer.lock | cut -d' ' -f1)
+CACHE_KEY="composer-v1-${COMPOSER_HASH}"
+
+if [ -d "/tmp/composer-cache/${CACHE_KEY}" ]; then
+  cp -r "/tmp/composer-cache/${CACHE_KEY}/vendor" ./vendor
+  echo "Cache hit: restored vendor from ${CACHE_KEY}"
+else
+  composer install --no-interaction --prefer-dist
+  mkdir -p "/tmp/composer-cache/${CACHE_KEY}"
+  cp -r vendor "/tmp/composer-cache/${CACHE_KEY}/vendor"
+  echo "Cache miss: saved vendor to ${CACHE_KEY}"
+fi
+
+

Key insight: Cache on composer.lock hash, not composer.json. The lock file captures exact versions, so the cache is only invalidated when dependencies actually change.

+
+

3. Sanitise and slim your test database

+

Many teams import a full production database dump for testing. This is slow, wasteful, and a compliance risk. A typical government Drupal site has 500MB+ of database content that tests don't need.

+

What you actually need for CI:

+ +

We use a sanitisation script that strips the database down to essentials:

+
/**
+ * Sanitise database for CI usage.
+ *
+ * Removes user data, reduces content to representative sample,
+ * and strips session/cache tables.
+ */
+function ci_sanitise_database(): void {
+  $connection = \Drupal::database();
+
+  // Truncate cache and session tables.
+  $tables = $connection->schema()->findTables('cache_%');
+  $tables = array_merge($tables, $connection->schema()->findTables('sessions'));
+  foreach ($tables as $table) {
+    $connection->truncate($table)->execute();
+  }
+
+  // Keep only 10 nodes per content type.
+  $types = \Drupal::entityTypeManager()
+    ->getStorage('node_type')
+    ->loadMultiple();
+
+  foreach (array_keys($types) as $type) {
+    $nids = \Drupal::entityQuery('node')
+      ->condition('type', $type)
+      ->sort('changed', 'DESC')
+      ->range(10, 999999)
+      ->accessCheck(FALSE)
+      ->execute();
+
+    $storage = \Drupal::entityTypeManager()->getStorage('node');
+    $nodes = $storage->loadMultiple($nids);
+    $storage->delete($nodes);
+  }
+}
+

4. Parallelise your test suite

+

Running all tests sequentially is the default in most setups. But PHPUnit supports parallel execution via tools like paratest, and Behat scenarios can be split across multiple containers.

+ + + + + + + + + + +
StrategyEffortTime Saved
Paratest for PHPUnitLow - drop-in replacement40-60%
Split Behat by tagMedium - needs CI config50-70%
Parallel CI jobsMedium - matrix builds60-80%
Test selection (changed files only)High - needs test mapping70-90%
+

5. Integrate S3 for asset storage

+

If your CI pipeline runs drush sql:dump and stores the result as a build artifact, you're wasting time on compression and upload. Use S3 (or equivalent) with a dedicated bucket:

+
# Upload sanitised DB dump to S3 (run nightly, not per-build).
+drush sql:dump --gzip --result-file=/tmp/db.sql.gz
+aws s3 cp /tmp/db.sql.gz s3://ci-artifacts/drupal-db/latest.sql.gz
+
+# In CI: download pre-built dump (fast, cached at edge).
+aws s3 cp s3://ci-artifacts/drupal-db/latest.sql.gz /tmp/db.sql.gz
+gunzip -c /tmp/db.sql.gz | drush sql:cli
+

The compound effect

+

Each optimisation on its own saves a few minutes. Together, they transform the development experience. A team pushing 20 commits per day at 18 minutes per build is spending 6 hours of CI time daily. Cut that to 5 minutes and you reclaim 4.3 hours - every day.

+
+

Fast CI isn't a luxury. It's the difference between developers who test before merging and developers who push to main and hope for the best.

+
+

If your Drupal CI pipeline takes more than 5 minutes, there's room to improve. We offer a free 30-minute review of your pipeline configuration - no commitment, just practical advice.

+

Get a free CI review

+HTML; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Kernel/ContentBuilderTest.php b/web/modules/custom/do_base/tests/src/Kernel/ContentBuilderTest.php new file mode 100644 index 00000000..e058110b --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Kernel/ContentBuilderTest.php @@ -0,0 +1,408 @@ +installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('file'); + $this->installEntitySchema('paragraph'); + + $this->createParagraphTextField('field_c_p_theme', 'string'); + $this->createParagraphTextField('field_c_p_title', 'string'); + $this->createParagraphTextField('field_c_p_summary', 'string_long'); + $this->createParagraphTextField('field_c_p_content', 'text_long'); + $this->createParagraphTextField('field_p_suffix', 'string'); + $this->createParagraphTextField('field_p_eyebrow', 'string'); + $this->createParagraphTextField('field_c_p_list_column_count', 'integer'); + $this->createParagraphTextField('field_p_tagline', 'string'); + $this->createParagraphTextField('field_p_includes', 'string', -1); + $this->createParagraphTextField('field_p_price_label', 'string'); + $this->createParagraphTextField('field_p_price_value', 'string'); + $this->createParagraphTextField('field_c_p_link', 'link'); + $this->createParagraphTextField('field_c_p_links', 'link', -1); + $this->createParagraphTextField('field_c_p_list_items', 'entity_reference_revisions', -1, ['target_type' => 'paragraph']); + + $this->createParagraphType('civictheme_content', ['field_c_p_theme', 'field_c_p_content']); + $this->createParagraphType('civictheme_snippet', ['field_c_p_theme', 'field_c_p_title', 'field_c_p_summary']); + $this->createParagraphType('do_fact_card', ['field_c_p_theme', 'field_c_p_title', 'field_p_suffix', 'field_c_p_summary']); + $this->createParagraphType('civictheme_manual_list', ['field_c_p_theme', 'field_c_p_title', 'field_c_p_list_column_count', 'field_p_eyebrow', 'field_c_p_list_items']); + $this->createParagraphType('civictheme_callout', ['field_c_p_theme', 'field_c_p_title', 'field_c_p_content', 'field_c_p_links']); + $this->createParagraphType('do_service_detail', ['field_c_p_theme', 'field_c_p_title', 'field_p_tagline', 'field_c_p_content', 'field_p_includes', 'field_p_price_label', 'field_p_price_value', 'field_c_p_link']); + + NodeType::create(['type' => 'civictheme_page', 'name' => 'Page'])->save(); + NodeType::create(['type' => 'bare', 'name' => 'Bare'])->save(); + + $this->createNodeField('field_c_n_banner_title', 'string'); + $this->createNodeField('field_c_n_banner_type', 'string'); + $this->createNodeField('field_c_n_banner_theme', 'string'); + $this->createNodeField('field_c_n_banner_background', 'string'); + $this->createNodeField('field_c_n_banner_featured_image', 'string'); + $this->createNodeField('field_c_n_banner_components', 'entity_reference_revisions', ['target_type' => 'paragraph']); + $this->createNodeField('field_c_n_banner_components_bott', 'entity_reference_revisions', ['target_type' => 'paragraph']); + } + + /** + * Tests snippet() builds a dark snippet with title and summary. + */ + public function testSnippet(): void { + $snippet = ContentBuilder::snippet('Snippet title', 'Snippet summary'); + + $this->assertInstanceOf(Paragraph::class, $snippet); + $this->assertSame('civictheme_snippet', $snippet->bundle()); + $this->assertSame('dark', $snippet->get('field_c_p_theme')->value); + $this->assertSame('Snippet title', $snippet->get('field_c_p_title')->value); + $this->assertSame('Snippet summary', $snippet->get('field_c_p_summary')->value); + } + + /** + * Tests factCard() builds a dark fact card with figure, suffix and label. + */ + public function testFactCard(): void { + $card = ContentBuilder::factCard('40', 'Projects', '+'); + + $this->assertInstanceOf(Paragraph::class, $card); + $this->assertSame('do_fact_card', $card->bundle()); + $this->assertSame('dark', $card->get('field_c_p_theme')->value); + $this->assertSame('40', $card->get('field_c_p_title')->value); + $this->assertSame('+', $card->get('field_p_suffix')->value); + $this->assertSame('Projects', $card->get('field_c_p_summary')->value); + } + + /** + * Tests factCard() default suffix is empty. + */ + public function testFactCardDefaultSuffix(): void { + $card = ContentBuilder::factCard('0', 'Downtime'); + + $this->assertSame('', $card->get('field_p_suffix')->value); + } + + /** + * Tests manualList() saves its items and references them. + */ + public function testManualList(): void { + $items = [ + ContentBuilder::snippet('One', 'First', 'itemized'), + ContentBuilder::snippet('Two', 'Second', 'itemized'), + ]; + + $list = ContentBuilder::manualList('How it works', 2, $items, 'Process'); + + $this->assertInstanceOf(Paragraph::class, $list); + $this->assertSame('civictheme_manual_list', $list->bundle()); + $this->assertSame('dark', $list->get('field_c_p_theme')->value); + $this->assertSame('How it works', $list->get('field_c_p_title')->value); + $this->assertSame(2, $list->get('field_c_p_list_column_count')->value); + $this->assertSame('Process', $list->get('field_p_eyebrow')->value); + $this->assertCount(2, $list->get('field_c_p_list_items')); + + // Each item is saved by the builder before being referenced. + $this->assertNotNull($items[0]->id()); + $this->assertNotNull($items[1]->id()); + } + + /** + * Tests manualList() default eyebrow is empty. + */ + public function testManualListDefaultEyebrow(): void { + $list = ContentBuilder::manualList('Stats', 3, [ContentBuilder::snippet('A', 'B')]); + + $this->assertSame('', $list->get('field_p_eyebrow')->value); + } + + /** + * Tests callout() with a root-relative link stores an internal URI. + */ + public function testCalloutWithLink(): void { + $callout = ContentBuilder::callout('Ready?', '

Body

', 'Contact us', '/contact'); + + $this->assertInstanceOf(Paragraph::class, $callout); + $this->assertSame('civictheme_callout', $callout->bundle()); + $this->assertSame('dark', $callout->get('field_c_p_theme')->value); + $this->assertSame('Ready?', $callout->get('field_c_p_title')->value); + $this->assertSame('

Body

', $callout->get('field_c_p_content')->value); + $this->assertSame('civictheme_rich_text', $callout->get('field_c_p_content')->format); + $this->assertSame('internal:/contact', $callout->get('field_c_p_links')->uri); + $this->assertSame('Contact us', $callout->get('field_c_p_links')->title); + } + + /** + * Tests callout() without a link omits the button. + */ + public function testCalloutWithoutLink(): void { + $callout = ContentBuilder::callout('Ready?', '

Body

'); + + $this->assertSame('civictheme_callout', $callout->bundle()); + $this->assertTrue($callout->get('field_c_p_links')->isEmpty()); + } + + /** + * Tests serviceDetail() builds the service paragraph with all fields. + */ + public function testServiceDetail(): void { + $service = ContentBuilder::serviceDetail( + 'Platform audit', + 'Know where you stand', + ['First paragraph.', 'Second paragraph.'], + ['Architecture review', 'Performance report'], + 'Typical engagement', + '$40K - $180K', + 'Book a call', + '/contact', + ); + + $this->assertInstanceOf(Paragraph::class, $service); + $this->assertSame('do_service_detail', $service->bundle()); + $this->assertSame('dark', $service->get('field_c_p_theme')->value); + $this->assertSame('Platform audit', $service->get('field_c_p_title')->value); + $this->assertSame('Know where you stand', $service->get('field_p_tagline')->value); + $this->assertSame('

First paragraph.

Second paragraph.

', $service->get('field_c_p_content')->value); + $this->assertSame('Typical engagement', $service->get('field_p_price_label')->value); + $this->assertSame('$40K - $180K', $service->get('field_p_price_value')->value); + $this->assertSame('internal:/contact', $service->get('field_c_p_link')->uri); + $this->assertSame('Book a call', $service->get('field_c_p_link')->title); + $this->assertCount(2, $service->get('field_p_includes')); + } + + /** + * Tests contentRichText() builds a dark content paragraph. + */ + public function testContentRichText(): void { + $content = ContentBuilder::contentRichText('

Hello

'); + + $this->assertInstanceOf(Paragraph::class, $content); + $this->assertSame('civictheme_content', $content->bundle()); + $this->assertSame('dark', $content->get('field_c_p_theme')->value); + $this->assertSame('

Hello

', $content->get('field_c_p_content')->value); + $this->assertSame('civictheme_rich_text', $content->get('field_c_p_content')->format); + } + + /** + * Tests stageBanner() turns the node banner into the dark hero. + */ + public function testStageBanner(): void { + $node = Node::create([ + 'type' => 'civictheme_page', + 'title' => 'Home', + 'field_c_n_banner_background' => 'old-background', + 'field_c_n_banner_featured_image' => 'old-image', + ]); + + $previous = ContentBuilder::stageBanner($node, 'Hero heading', 'Hero subtitle', ['title' => 'Talk to us', 'uri' => '/contact']); + + $this->assertSame([], $previous); + $this->assertSame('Hero heading', $node->get('field_c_n_banner_title')->value); + $this->assertSame('hero', $node->get('field_c_n_banner_type')->value); + $this->assertSame('dark', $node->get('field_c_n_banner_theme')->value); + $this->assertTrue($node->get('field_c_n_banner_background')->isEmpty()); + $this->assertTrue($node->get('field_c_n_banner_featured_image')->isEmpty()); + + $components = $node->get('field_c_n_banner_components')->referencedEntities(); + $this->assertCount(1, $components); + + $intro = reset($components); + $this->assertInstanceOf(Paragraph::class, $intro); + $this->assertSame('civictheme_content', $intro->bundle()); + $this->assertSame('dark', $intro->get('field_c_p_theme')->value); + $this->assertStringContainsString('Hero subtitle', $intro->get('field_c_p_content')->value); + $this->assertStringContainsString('href="/contact"', $intro->get('field_c_p_content')->value); + $this->assertStringContainsString('Talk to us', $intro->get('field_c_p_content')->value); + $this->assertNotNull($intro->id()); + } + + /** + * Tests stageBanner() without a subtitle adds no banner component. + */ + public function testStageBannerWithoutSubtitle(): void { + $node = Node::create(['type' => 'civictheme_page', 'title' => 'Plain']); + + ContentBuilder::stageBanner($node, 'Just a heading'); + + $this->assertSame('Just a heading', $node->get('field_c_n_banner_title')->value); + $this->assertTrue($node->get('field_c_n_banner_components')->isEmpty()); + } + + /** + * Tests stageBanner() skips banner fields the node bundle does not have. + */ + public function testStageBannerSkipsMissingFields(): void { + $node = Node::create(['type' => 'bare', 'title' => 'No banner fields']); + + $previous = ContentBuilder::stageBanner($node, 'Heading', 'Subtitle', ['title' => 'Go', 'uri' => '/contact']); + + $this->assertSame([], $previous); + $this->assertFalse($node->hasField('field_c_n_banner_title')); + } + + /** + * Tests stageBanner() returns previously referenced banner components. + */ + public function testStageBannerReturnsPreviousComponents(): void { + $existing = ContentBuilder::contentRichText('

Old

'); + $existing->save(); + + $node = Node::create([ + 'type' => 'civictheme_page', + 'title' => 'Home', + 'field_c_n_banner_components' => [$existing], + ]); + + $previous = ContentBuilder::stageBanner($node, 'Hero heading', 'New subtitle'); + + $this->assertCount(1, $previous); + $previous_item = reset($previous); + $this->assertInstanceOf(EntityInterface::class, $previous_item); + $this->assertSame($existing->id(), $previous_item->id()); + } + + /** + * Tests deleteEntities() deletes each saved entity. + */ + public function testDeleteEntities(): void { + $one = ContentBuilder::contentRichText('

One

'); + $one->save(); + $two = ContentBuilder::contentRichText('

Two

'); + $two->save(); + + $id_one = $one->id(); + $id_two = $two->id(); + + ContentBuilder::deleteEntities([$one, $two]); + + $this->assertNull(Paragraph::load($id_one)); + $this->assertNull(Paragraph::load($id_two)); + } + + /** + * Tests contactInfo() returns the expected contact rich-text markers. + */ + public function testContactInfo(): void { + $html = ContentBuilder::contactInfo(); + + $this->assertStringContainsString('info@drevops.com', $html); + $this->assertStringContainsString('What to expect', $html); + } + + /** + * Tests blogBody() returns the expected blog rich-text markers. + */ + public function testBlogBody(): void { + $html = ContentBuilder::blogBody(); + + $this->assertStringContainsString('ct-table', $html); + $this->assertStringContainsString('language-php', $html); + } + + /** + * Creates a paragraph field storage and config on the given bundles. + * + * @param string $field_name + * The field machine name. + * @param string $type + * The field storage type. + * @param int $cardinality + * The field cardinality. + * @param array $settings + * Field storage settings. + */ + protected function createParagraphTextField(string $field_name, string $type, int $cardinality = 1, array $settings = []): void { + FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'paragraph', + 'type' => $type, + 'cardinality' => $cardinality, + 'settings' => $settings, + ])->save(); + } + + /** + * Creates a paragraph type and attaches the given fields to it. + * + * @param string $bundle + * The paragraph type machine name. + * @param string[] $field_names + * The field machine names to attach. + */ + protected function createParagraphType(string $bundle, array $field_names): void { + ParagraphsType::create(['id' => $bundle, 'label' => $bundle])->save(); + + foreach ($field_names as $field_name) { + FieldConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'paragraph', + 'bundle' => $bundle, + 'label' => $field_name, + ])->save(); + } + } + + /** + * Creates a node field storage and config on the civictheme_page bundle. + * + * @param string $field_name + * The field machine name. + * @param string $type + * The field storage type. + * @param array $settings + * Field storage settings. + */ + protected function createNodeField(string $field_name, string $type, array $settings = []): void { + FieldStorageConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'node', + 'type' => $type, + 'settings' => $settings, + ])->save(); + + FieldConfig::create([ + 'field_name' => $field_name, + 'entity_type' => 'node', + 'bundle' => 'civictheme_page', + 'label' => $field_name, + ])->save(); + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/Hook/FormAlterHookTest.php b/web/modules/custom/do_base/tests/src/Unit/Hook/FormAlterHookTest.php new file mode 100644 index 00000000..17fbe0ca --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/Hook/FormAlterHookTest.php @@ -0,0 +1,63 @@ +alter($form); + + $attached = $form['#attached']['library'] ?? []; + + if ($expected_attached) { + $this->assertContains('do_base/recaptcha_v3_guard', $attached); + } + else { + $this->assertNotContains('do_base/recaptcha_v3_guard', $attached); + } + } + + /** + * Data provider for testAlter(). + */ + public static function dataProviderAlter(): \Iterator { + yield 'recaptcha v3 attaches guard' => [ + ['captcha' => ['#captcha_type' => 'recaptcha_v3/recaptcha3']], + TRUE, + ]; + + yield 'different captcha type does not attach' => [ + ['captcha' => ['#captcha_type' => 'image_captcha/Image']], + FALSE, + ]; + + yield 'no captcha key returns early' => [ + ['name' => ['#type' => 'textfield']], + FALSE, + ]; + + yield 'captcha without type does not attach' => [ + ['captcha' => []], + FALSE, + ]; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/Hook/LibraryInfoAlterHookTest.php b/web/modules/custom/do_base/tests/src/Unit/Hook/LibraryInfoAlterHookTest.php new file mode 100644 index 00000000..c8b3e4f5 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/Hook/LibraryInfoAlterHookTest.php @@ -0,0 +1,61 @@ +alter($libraries, $extension); + + $dependencies = $libraries['highlight_js.custom']['dependencies'] ?? []; + + if ($expected_attached) { + $this->assertContains('do_base/highlight_js.gherkin', $dependencies); + } + else { + $this->assertNotContains('do_base/highlight_js.gherkin', $dependencies); + } + } + + /** + * Data provider for testAlter(). + */ + public static function dataProviderAlter(): \Iterator { + yield 'matching extension and library attaches gherkin' => [ + ['highlight_js.custom' => ['dependencies' => ['core/drupal']]], + 'highlight_js', + TRUE, + ]; + + yield 'wrong extension does not attach' => [ + ['highlight_js.custom' => ['dependencies' => ['core/drupal']]], + 'civictheme', + FALSE, + ]; + + yield 'matching extension without custom library does not attach' => [ + ['highlight_js.other' => ['dependencies' => []]], + 'highlight_js', + FALSE, + ]; + } + +} diff --git a/web/modules/custom/do_base/tests/src/Unit/Twig/IconAssetsExtensionTest.php b/web/modules/custom/do_base/tests/src/Unit/Twig/IconAssetsExtensionTest.php new file mode 100644 index 00000000..e79c6610 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/Twig/IconAssetsExtensionTest.php @@ -0,0 +1,46 @@ +createMock(ActiveTheme::class); + $active_theme->method('getName')->willReturn($theme_name); + + $theme_manager = $this->createMock(ThemeManagerInterface::class); + $theme_manager->method('getActiveTheme')->willReturn($active_theme); + + $extension = new IconAssetsExtension($theme_manager); + + $this->assertSame(['assets_dir' => $expected], $extension->getGlobals()); + } + + /** + * Data provider for testGetGlobals(). + */ + public static function dataProviderGetGlobals(): \Iterator { + yield 'drevops theme' => ['drevops', '@drevops/../assets']; + yield 'civictheme theme' => ['civictheme', '@civictheme/../assets']; + } + +} diff --git a/web/themes/custom/drevops/assets/js/reveal.js b/web/themes/custom/drevops/assets/js/reveal.js new file mode 100644 index 00000000..98e9dbbb --- /dev/null +++ b/web/themes/custom/drevops/assets/js/reveal.js @@ -0,0 +1,52 @@ +/** + * @file + * Reveal-on-scroll behaviour. + * + * Mounts a subtle fade-and-rise reveal on the outer wrapper of each homepage + * section component (manual lists and callouts) as it enters the viewport. The + * design uses this treatment on the homepage only; every other page renders its + * sections statically. + * + * The hiding class is added by this script, never in the stylesheet, so the + * content is only ever hidden once the reveal has been initialised. If the + * script does not run, the browser lacks IntersectionObserver, or the visitor + * prefers reduced motion, every section stays fully visible. + */ +((Drupal, once) => { + Drupal.behaviors.drevopsReveal = { + attach(context) { + if (!document.body.classList.contains('path-frontpage')) { + return; + } + + const elements = once('do-reveal', '.ct-list, .ct-callout', context); + + if (!elements.length) { + return; + } + + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + if (prefersReducedMotion || !('IntersectionObserver' in window)) { + return; + } + + const observer = new IntersectionObserver( + (entries, obs) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('do-reveal--in'); + obs.unobserve(entry.target); + } + }); + }, + { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }, + ); + + elements.forEach((el) => { + el.classList.add('do-reveal'); + observer.observe(el); + }); + }, + }; +})(Drupal, once); diff --git a/web/themes/custom/drevops/assets/sass/brand/_animations.scss b/web/themes/custom/drevops/assets/sass/brand/_animations.scss new file mode 100644 index 00000000..1e7e7c6c --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_animations.scss @@ -0,0 +1,30 @@ +// +// DrevOps - Scroll reveal. +// +// The `do-reveal` class is added by assets/js/reveal.js to each homepage +// section component (manual lists, callouts) as the page loads, so the hidden +// state only ever applies once the reveal behaviour is running. reveal.js then +// adds `do-reveal--in` when the element scrolls into view. +// + +.do-reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s ease, transform 0.6s ease; + will-change: opacity, transform; +} + +.do-reveal--in { + opacity: 1; + transform: none; +} + +// Belt-and-braces: reveal.js already bails out for reduced motion, but keep the +// content visible if the class is present for any other reason. +@media (prefers-reduced-motion: reduce) { + .do-reveal { + opacity: 1; + transform: none; + transition: none; + } +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_blog.scss b/web/themes/custom/drevops/assets/sass/brand/_blog.scss new file mode 100644 index 00000000..88daad30 --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_blog.scss @@ -0,0 +1,171 @@ +// +// DrevOps - Blog. +// +// CivicTheme renders a post's topics as a primary tag list inside each promo +// card; the design shows them as small bordered pill chips (its +// `.component-tag--sm`): a dark surface, cyan label and a faint border. +// +.ct-promo-card__tags { + margin-top: var(--space-y-2); + + .ct-tag-list__content { + display: flex; + flex-wrap: wrap; + gap: var(--space-x-1); + margin: 0; + padding: 0; + list-style: none; + } + + .ct-item-list__item { + margin: 0; + } + + .ct-tag { + // Override CivicTheme's primary-tag tokens to the design's pill colours; + // defining them on the element outranks the :root defaults regardless of + // CSS load order. + --ct-tag-dark-primary-background-color: var(--tag-bg); + --ct-tag-dark-primary-border-color: var(--border-subtle-color); + --ct-tag-dark-primary-color: var(--tag-color); + + display: inline-block; + font-size: var(--font-size-2xs); + font-weight: var(--label-font-weight); + letter-spacing: var(--label-letter-spacing); + text-transform: var(--label-text-transform); + padding: var(--space-y-1) var(--space-x-1); + border-radius: var(--radius-sm); + + a { + color: inherit; + text-decoration: none; + } + } +} + +// Featured post: the single-item (full-width) automated list renders the promo +// card as the design's feature - a 50/50 grid with the image filling the left +// half and the content centred on the right, in a bordered, rounded panel. +.ct-list [class~='col-m-12'] .ct-promo-card--with-image { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + + // Absolutely fill the image so a tall (e.g. square) source does not stretch + // the card past the content; the content column then sets the card height, + // and the image covers its half - matching the design's feature panel. + .ct-promo-card__image { + position: relative; + height: 100%; + margin: 0; + overflow: hidden; + + .ct-image, + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .ct-promo-card__content { + display: flex; + flex-direction: column; + justify-content: center; + padding: var(--space-y-6); + } + + // The feature title is the design's h2, not the smaller grid-card title. + .ct-promo-card__title { + font-size: var(--heading-h2-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-sm); + letter-spacing: -0.01em; + color: var(--text-color); + margin-bottom: var(--space-y-2); + + a { + color: inherit; + } + } + + @media (max-width: 767.98px) { + grid-template-columns: 1fr; + } +} + +// Article grid: a muted section panel behind the card list. The card columns +// are left to CivicTheme's native grid; only the "Showing N of M" results count +// is hidden so the panel opens straight into the cards. +.ct-list:has([class~='col-m-4']) { + background: var(--surface-muted-color); + + .ct-list__results-count { + display: none; + } +} + +// Blog cards (featured + grid): the design's bordered page-bg panel that lifts +// on hover. CivicTheme's promo card ships a filled surface and a white border, +// so restate the design colours and add the hover transform. +.ct-list .ct-promo-card--with-image.ct-theme-dark { + background: var(--page-bg); + border-color: var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + transition: border-color 0.3s ease, transform 0.3s ease; + + &:hover { + border-color: var(--color-secondary-6); + transform: translateY(-4px); + } + + .ct-promo-card__image img { + transition: transform 0.5s ease; + } + + &:hover .ct-promo-card__image img { + transform: scale(1.05); + } +} + +// Grid card internals matching the design's blog-card: a 16/9 image, an h4 +// title and a small excerpt (CivicTheme's promo card uses a taller image and +// larger type). +.ct-list [class~='col-m-4'] .ct-promo-card--with-image { + .ct-promo-card__image { + // Force a 16/9 image box. CivicTheme pins a fixed image height that wins + // over `aspect-ratio`, so use the height:0 + padding ratio box (which the + // fixed height cannot override) and stretch the image to fill it. + height: 0; + padding-bottom: 56.25%; + overflow: hidden; + position: relative; + + .ct-image, + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + } + + .ct-promo-card__title { + font-size: var(--heading-h4-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-sm); + letter-spacing: -0.01em; + } + + .ct-promo-card__summary { + font-size: var(--font-size-sm); + line-height: var(--line-height-md); + } +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_buttons.scss b/web/themes/custom/drevops/assets/sass/brand/_buttons.scss new file mode 100644 index 00000000..ab352ede --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_buttons.scss @@ -0,0 +1,76 @@ +// +// DrevOps - Brand buttons. +// +// The design renders every call-to-action as one outline button: transparent +// with a cyan border and label, filling softly on hover. CivicTheme emits its +// buttons - and the promo, callout and rich-text CTAs - with the `.ct-button` +// class, so the design treatment is applied there through the button tokens +// rather than the design's original `.btn` classes. +// + +.ct-button { + &, + &:focus-visible, + &:active, + &:visited { + display: inline-block; + font-family: var(--font-family-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-normal); + line-height: 1; + padding: var(--space-y-2) var(--space-x-4); + border: 1px solid var(--btn-secondary-border); + border-radius: 0; + letter-spacing: 0.12em; + text-transform: uppercase; + text-decoration: none; + background-color: var(--btn-secondary-bg); + color: var(--btn-secondary-color); + transition: color 0.3s ease, border-color 0.3s ease, background-color 0.3s ease; + } + + &:hover { + background-color: var(--btn-secondary-hover-bg); + border-color: var(--btn-secondary-color); + color: var(--btn-secondary-hover-color); + text-decoration: none; + } +} + +// CivicTheme's dark content-link rule (high specificity) restyles every anchor +// inside content as an underlined text link, which would otherwise strip the +// button box from rich-text CTAs (the hero "Talk to us" button and the blog +// "Get a free CI review" button). Re-assert the button appearance at a +// matching specificity; the base `.ct-button` rule still supplies the box. +.ct-basic-content.ct-theme-dark a.ct-button { + &, + &:hover, + &:focus-visible, + &:active, + &:visited { + margin: 0; + padding: var(--space-y-2) var(--space-x-4); + line-height: 1; + text-decoration: none; + color: var(--btn-secondary-color); + } + + &:hover { + color: var(--btn-secondary-hover-color); + } +} + +// CivicTheme's own secondary-button rule (`.ct-button--secondary.ct-theme-dark`, +// specificity 0,2,0) sets colour, border and background from its dark-interaction +// tokens, overriding the design button treatment. Redefine those tokens on the +// button itself so CivicTheme's rule resolves to the design's secondary colour +// (the element-level definition outranks the `:root` default regardless of CSS +// load order). +.ct-button.ct-theme-dark { + --ct-button-dark-secondary-color: var(--btn-secondary-color); + --ct-button-dark-secondary-background-color: var(--btn-secondary-bg); + --ct-button-dark-secondary-border-color: var(--btn-secondary-border); + --ct-button-dark-secondary-hover-color: var(--btn-secondary-hover-color); + --ct-button-dark-secondary-hover-background-color: var(--btn-secondary-hover-bg); + --ct-button-dark-secondary-hover-border-color: var(--btn-secondary-color); +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_contact.scss b/web/themes/custom/drevops/assets/sass/brand/_contact.scss new file mode 100644 index 00000000..7080307d --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_contact.scss @@ -0,0 +1,223 @@ +// +// DrevOps - Contact page. +// +// The contact page renders two CivicTheme components in its node components +// region: the contact webform and a rich-text block with the direct-contact +// details. The design lays these out as two columns - the form on the left and +// the contact details on the right - and styles the form fields and the detail +// blocks to the design tokens. +// +// The contact page is the only page whose components region contains a +// webform, so `:has(> .ct-webform)` scopes the layout (and the contact-detail +// typography) to it without needing a page-specific body class. The form-field +// styling is scoped to `.ct-webform`, which only appears on this page. +// + +// The Layout Builder field block that renders `field_c_n_components`. Every +// civictheme_page node uses this class, so the `:has(> .ct-webform)` guard +// narrows it to the contact page's instance. +$contact-region: '.block-field-blocknodecivictheme-pagefield-c-n-components'; + +#{$contact-region}:has(> .ct-webform) { + // Constrain the region to the shared page measure and centre it, so the + // two-column grid lines up with the hero and the other sections. + @include ct-container(); + + // Each component carries its own inner `.container`; neutralise it so the + // content fills its grid column instead of re-centring at the page measure. + > .ct-webform > .container, + > .ct-basic-content > .container { + max-width: none; + margin-inline: 0; + } + + // Two equal columns from the desktop-menu breakpoint up: form left, details + // right (the design switches to a single column at 768px). Below it the + // components stack (CivicTheme's default flow). + @include ct-breakpoint(m) { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-y-10); + align-items: start; + } +} + +// The contact webform, styled to the design's form tokens. +.ct-webform { + // Field labels: uppercase small-caps in the muted colour (the design `label`). + .ct-label { + font-family: var(--body-font-family); + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted-color); + margin-bottom: var(--space-y-1); + } + + // The design relies on the `required` attribute for the cue and shows no + // visible marker; keep it in the accessibility tree but hide it visually. + .ct-label__required { + @include ct-visually-hidden(); + } + + // Inputs, select and textarea: secondary font on the subtle surface with the + // subtle border, matching the design's field treatment. + .ct-textfield, + .ct-textarea, + .ct-select { + font-family: var(--body-font-family); + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-color); + background-color: var(--surface-subtle-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + + &::placeholder { + color: var(--text-muted-color); + } + + &:focus, + &:focus-visible { + // Draw the focus ring inside the field's border box so focusing does not + // expand the field's footprint outward (the default outline sits 2px + // outside, which reads as the field shifting up on focus). Keeps a + // visible, keyboard-accessible focus indicator. + outline-offset: -2px; + border-color: var(--color-secondary-6); + background-color: var(--surface-muted-color); + } + } + + // Field rhythm: space groups by the design's form gap and the label/control + // gap, overriding CivicTheme's default field margins. + .ct-field { + margin-bottom: var(--space-y-3); + } + + // The submit sits left, set off from the last field. + .ct-button--submit { + margin-top: var(--space-y-1); + } +} + +// The direct-contact details (the right column). Scoped to the contact page's +// rich-text block so the blog article's headings and lists are untouched. The +// `.ct-theme-dark` qualifier matches the specificity of CivicTheme's themed +// content rules (its link colour uses `a:not(.ct-button):not(.ct-link)`), so +// these design overrides win without `!important`. +#{$contact-region}:has(> .ct-webform) .ct-basic-content.ct-theme-dark { + // Block labels ("Email us directly", "Call us", ...): coral small-caps, the + // design's `.component-label--accent`. The rich text stores them as

. + h3 { + font-family: var(--body-font-family); + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + letter-spacing: var(--label-letter-spacing); + text-transform: uppercase; + color: var(--text-accent-color); + margin: var(--space-y-5) 0 var(--space-y-1); + } + + h3:first-child { + margin-top: 0; + } + + // The prominent value under each label (email, phone, location). + h3 + p { + font-family: var(--font-family-primary); + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-sm); + color: var(--text-color); + margin-bottom: var(--space-y-1); + + a { + color: var(--text-color); + text-decoration: none; + + &:hover { + color: var(--color-secondary-3); + } + } + + // The email keeps the design's animated underline on hover. + a[href^='mailto:'] { + position: relative; + display: inline-block; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: -2px; + width: 0; + height: 1px; + background-color: var(--color-secondary-3); + transition: width 0.4s ease; + } + + &:hover::after { + width: 100%; + } + } + } + + // The note under each value. + .ct-text-small { + font-size: var(--font-size-sm); + color: var(--text-muted-color); + line-height: var(--line-height-md); + margin-bottom: 0; + } + + // A gradient divider sits above the "What to expect" block - the only label + // immediately followed by the steps list. + h3:has(+ ol) { + margin-top: var(--space-y-6); + padding-top: var(--space-y-6); + border-top: 1px solid transparent; + border-image: linear-gradient(90deg, color-mix(in srgb, var(--color-secondary-6) 30%, transparent) 0%, transparent 100%) 1; + } + + // The "what to expect" steps: a faint oversized number beside each line, + // reproducing the design's step list (not a bulleted list). + ol { + list-style: none; + counter-reset: contact-step; + display: flex; + flex-direction: column; + gap: var(--space-y-3); + margin: var(--space-y-2) 0 0; + padding: 0; + } + + li { + counter-increment: contact-step; + display: flex; + align-items: flex-start; + gap: var(--space-y-2); + margin: 0; + font-size: var(--font-size-sm); + line-height: var(--line-height-md); + color: var(--text-subtle-color); + + &::before { + content: counter(contact-step); + flex-shrink: 0; + width: 28px; + text-align: center; + font-family: var(--font-family-primary); + font-size: 28px; + font-weight: var(--font-weight-bold); + line-height: 1; + color: var(--color-secondary-6); + opacity: 0.3; + } + + &::marker { + content: none; + } + } +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_content.scss b/web/themes/custom/drevops/assets/sass/brand/_content.scss new file mode 100644 index 00000000..e88a273d --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_content.scss @@ -0,0 +1,168 @@ +// +// DrevOps - Rich-text content. +// +// The blog article body and the contact details render through CivicTheme's +// basic-content component with the rich-text format, and the hero subtitle +// renders through the same component inside the banner. These rules give that +// editorial content the design's typography on the dark theme: lead paragraph, +// headings, lists, inline and block code, tables and blockquotes. +// + +.ct-basic-content.ct-theme-dark { + // CivicTheme tints dark headings and content links through these tokens; the + // design uses pure-white headings and the cyan link accent. + --ct-basic-content-dark-heading-1-color: var(--text-color); + --ct-basic-content-dark-heading-2-color: var(--text-color); + --ct-basic-content-dark-heading-3-color: var(--text-color); + --ct-basic-content-dark-heading-4-color: var(--text-color); + --ct-basic-content-dark-heading-5-color: var(--text-color); + --ct-basic-content-dark-heading-6-color: var(--text-color); + --ct-content-link-dark-color: var(--link-color); +} + +.ct-basic-content { + // Lead paragraph (the article intro and the hero subtitle). + .ct-text-large { + font-size: var(--heading-h4-font-size); + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + color: var(--text-subtle-color); + } + + p { + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + } + + strong { + font-weight: var(--font-weight-normal); + color: var(--text-color); + } + + h2 { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-normal); + letter-spacing: -0.01em; + margin-top: var(--space-y-8); + margin-bottom: var(--space-y-3); + } + + h3 { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-normal); + margin-top: var(--space-y-5); + margin-bottom: var(--space-y-2); + } + + ul, + ol { + margin: 0 0 var(--space-y-3); + padding-left: var(--space-x-3); + } + + li { + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + margin-bottom: var(--space-y-1); + + &::marker { + color: var(--color-secondary-4); + } + } + + // Inline code. + code { + font-family: var(--font-family-mono); + font-size: 0.875em; + background: var(--code-inline-bg); + color: var(--code-inline-color); + padding: 2px 6px; + border-radius: var(--radius-sm); + } + + // Block code (highlighted client-side by the highlight_js filter). + pre { + margin: var(--space-y-3) 0 var(--space-x-4); + border: 1px solid var(--code-border); + border-radius: var(--radius-md); + overflow: hidden; + + code { + display: block; + padding: var(--space-y-3); + font-size: var(--caption-font-size); + line-height: var(--line-height-md); + background: var(--code-bg); + color: var(--code-color); + border: none; + border-radius: 0; + overflow-x: auto; + } + } + + // Tables. + table { + width: 100%; + margin: var(--space-y-3) 0 var(--space-x-4); + border-collapse: collapse; + font-size: var(--font-size-sm); + } + + thead th { + background: var(--table-header-bg); + color: var(--table-header-color); + font-family: var(--font-family-primary); + font-size: var(--caption-font-size); + font-weight: var(--font-weight-bold); + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: left; + padding: var(--space-y-2) var(--space-x-2); + border-bottom: 2px solid var(--border-color); + } + + td { + padding: var(--space-y-1) var(--space-x-2); + color: var(--text-subtle-color); + line-height: var(--line-height-md); + border-bottom: 1px solid var(--border-subtle-color); + } + + tbody tr:nth-child(even) td { + background: var(--table-stripe-bg); + } + + // Blockquotes (the article callouts). + blockquote { + margin: var(--space-y-4) 0; + padding: var(--space-y-3) var(--space-x-3); + background: var(--surface-subtle-color); + border-left: 3px solid var(--color-secondary-6); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + + p { + margin-bottom: 0; + } + + p + p { + margin-top: var(--space-y-2); + } + + strong { + color: var(--color-secondary-3); + } + } +} + +// The hero subtitle (and optional CTA button) render through basic-content +// inside the intro banner. Give the subtitle a comfortable measure and space +// the CTA button away from it. +.ct-banner__content .ct-basic-content { + max-width: 720px; + margin-inline: auto; + + .ct-text-large { + margin-bottom: var(--space-y-5); + } +} + diff --git a/web/themes/custom/drevops/assets/sass/brand/_extra.scss b/web/themes/custom/drevops/assets/sass/brand/_extra.scss new file mode 100644 index 00000000..4cb7d2cb --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_extra.scss @@ -0,0 +1,120 @@ +// +// Brand supplements. +// + +// Section-rhythm and inset tokens used by the bespoke section treatments. +:root { + --layout-rhythm-sm: 6.25rem; + --layout-rhythm-md: 8.75rem; + --layout-rhythm-lg: 10rem; + --layout-inset: 3.75rem; + + // CivicTheme hard-codes the back-to-top control to the light interaction + // palette regardless of page theme, so it renders in the old accent on the + // dark site. Repoint it at the design's accent (cyan control, navy glyph), + // matching the link and button colours used elsewhere. + --ct-back-to-top-background-color: var(--link-color); + --ct-back-to-top-border-color: var(--link-color); + --ct-back-to-top-color: var(--page-bg); + --ct-back-to-top-outline-color: var(--link-color); +} + +// Base canvas. Display and headings use the primary font (Plus Jakarta Sans); +// body copy uses the secondary font (Outfit). +body { + font-family: var(--body-font-family); + font-weight: var(--body-font-weight); + line-height: var(--body-line-height); + color: var(--text-color); + background-color: var(--page-bg); + overflow-x: hidden; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-family-primary); +} + +// The design's gradient underline accent on a heading's closing word +// (`.component-underlined`), used in the CTA callouts and the page heroes. +.do-underline { + position: relative; + display: inline-block; + + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 2px; + height: 2px; + background: linear-gradient(90deg, var(--color-secondary-6), var(--link-hover-color)); + } +} + +// CivicTheme derives the page wrapper and header as lighter navy tints, which +// read as mismatched horizontal bands against the section backgrounds. Unify +// them with the deep-navy page background (the header keeps its blur). The +// !important overrides win over CivicTheme's theme-scoped component styles +// without depending on stylesheet load order. +.ct-page { + background-color: var(--page-bg) !important; + + // Reset the service-detail card counter once per page (the cards increment it). + counter-reset: do-service; +} + +.ct-header__middle { + background-color: color-mix(in srgb, var(--page-bg) 82%, transparent) !important; +} + +// The contact webform's submit button renders with CivicTheme's solid light +// primary style (the old accent). The design renders form submits as the +// secondary outline button - transparent with the cyan accent - so restyle the +// webform submit to the design's secondary button tokens. +.ct-button.ct-button--submit { + &, + &:focus-visible, + &:active, + &:visited { + background-color: var(--btn-secondary-bg); + border-color: var(--btn-secondary-border); + color: var(--btn-secondary-color); + } + + &:hover { + background-color: var(--btn-secondary-hover-bg); + border-color: var(--btn-secondary-color); + color: var(--btn-secondary-hover-color); + } +} + +// Primary navigation styled to the design: small, uppercase, letter-spaced +// links rather than CivicTheme's larger mixed-case menu. +.ct-header .ct-menu__item__link { + font-family: var(--font-family-secondary); + font-size: var(--caption-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +// CivicTheme's responsive navigation rules do not reach the compiled theme CSS, +// so both the horizontal desktop menu and the mobile-navigation hamburger show +// at every width. Restore the design's behaviour - a horizontal menu on desktop +// and a hamburger on mobile - switching at the desktop-menu breakpoint (768px). +@media (min-width: 768px) { + .ct-header .ct-mobile-navigation-trigger { + display: none; + } +} + +@media (max-width: 767.98px) { + .ct-header .ct-primary-navigation { + display: none; + } +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_layout.scss b/web/themes/custom/drevops/assets/sass/brand/_layout.scss new file mode 100644 index 00000000..2cdaafdb --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_layout.scss @@ -0,0 +1,34 @@ +// +// DrevOps - Section layout. +// +// Each section paints its background full-width while its content keeps a side +// gutter. The inner wrapper is not width-capped here - lists and callouts use +// CivicTheme's native container width. Bespoke styled lists (numbered, stat, +// trust, dotted) set their own measure in _lists.scss. The banner is +// unaffected: it lives outside the main content block. +// +.block-system-main-block { + .ct-list__inner, + .ct-callout__inner { + box-sizing: border-box; + padding-inline: var(--space-x-8); + + @media (max-width: 767.98px) { + padding-inline: var(--space-y-3); + } + } + + // The CTA callout keeps the design's centred measure; lists use the full + // native container width. + .ct-callout__inner { + max-width: 1100px; + margin-inline: auto; + } + + // An automated list renders its view inside a second, nested list. The outer + // wrapper already provides the side gutter, so the nested inner must not + // re-apply it. + .ct-list__inner .ct-list__inner { + padding-inline: 0; + } +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_lists.scss b/web/themes/custom/drevops/assets/sass/brand/_lists.scss new file mode 100644 index 00000000..6640ca7e --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_lists.scss @@ -0,0 +1,458 @@ +// +// DrevOps — Manual list design treatments. +// +// Each bespoke design treatment is driven by an item-level variant class +// (`ct-snippet--{variant}`) emitted by the snippet preprocessor. The enclosing +// list is scoped via CSS `:has()` so section-level styles (background, grid, +// rhythm) follow automatically from the items inside it. +// + +// Section rhythm shared by all styled lists. +.ct-list:has(.ct-snippet--itemized), +.ct-list:has(.ct-snippet--dotted), +.ct-list:has(.ct-snippet--trust), +.ct-list:has(.ct-fact-card) { + padding: var(--layout-rhythm-md, 8.75rem) 0; + + .ct-list__inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--space-x-8); + } + + // CivicTheme renders the header's eyebrow and title inside a 9/3 column + // split. Collapse it to a single full-width block so the eyebrow aligns with + // the section's left edge and the title centres across the full measure. + .ct-list__header { + .container { + max-width: none; + margin: 0; + padding: 0; + } + + .row { + margin: 0; + } + + [class*='col-'] { + flex: 0 0 100%; + max-width: 100%; + padding: 0; + } + + // The empty spacer column left over from the 9/3 split. + .col-m-3 { + display: none; + } + } + + // The design's small coral category label above a section: flush left, with a + // short rule before it (the design's `.component-section-label`). + .ct-list__eyebrow { + display: flex; + align-items: center; + gap: var(--space-x-2); + font-family: var(--body-font-family); + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: var(--label-letter-spacing); + text-transform: uppercase; + color: var(--text-accent-color); + margin-bottom: var(--space-y-8); + + &::before { + content: ''; + flex-shrink: 0; + width: var(--space-x-6); + height: 1px; + background-color: currentColor; + opacity: 0.6; + } + } + + .ct-list__title { + font-family: var(--font-family-primary); + font-size: var(--display-2-font-size, clamp(40px, 5vw, 56px)); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-sm); + letter-spacing: -0.02em; + color: var(--text-color); + text-align: center; + margin-bottom: var(--space-y-9); + } +} + +// Alternating section surfaces, matching the design. +.ct-list:has(.ct-fact-card) { + background: var(--surface-subtle-color); +} + +// CivicTheme's flex grid row stretches the single-column cols to equal height, +// which inflates each snippet past its content so its bottom divider overlaps +// the next item's heading. Top-align the cols so every snippet is its natural +// height and the dividers fall cleanly between items. (The stat and trust grids +// override `.row` to CSS grid, so they are unaffected.) +.ct-list:has(.ct-snippet--itemized) .row, +.ct-list:has(.ct-snippet--dotted) .row { + align-items: flex-start; + + // Remove CivicTheme's inter-row gap so the rows are adjacent and the dividers + // sit directly between them, as in the design. + row-gap: 0; +} + +// CivicTheme pads the snippet content block, which pushes the heading down so +// the dot/number no longer lines up with it. Remove that padding so the heading +// sits at the row top and the dot falls at its first line, as in the design. +.ct-snippet--itemized .ct-snippet__content, +.ct-snippet--dotted .ct-snippet__content { + padding-top: 0; + padding-bottom: 0; +} + +// ── Itemized snippet variant ──────────────────────────────────────────────── +// A snippet rendered as a numbered list row with a fading divider. The variant +// lives on the snippet (field-driven), so a single manual list of itemized +// snippets renders the separated list. The muted section panel and the row +// counter follow from the list that contains them. +.ct-list:has(.ct-snippet--itemized) { + background: var(--surface-muted-color); +} + +.ct-list__rows:has(.ct-snippet--itemized) { + counter-reset: do-snippet; +} + +.ct-snippet--itemized { + position: relative; + counter-increment: do-snippet; + display: grid; + grid-template-columns: 120px 1fr; + gap: 0 var(--space-y-6); + align-items: start; + padding: var(--space-y-8) 0; + + &::before { + content: counter(do-snippet, decimal-leading-zero); + font-family: var(--font-family-primary); + font-size: 80px; + font-weight: var(--font-weight-bold); + color: var(--color-secondary-6); + opacity: 18%; + line-height: 1; + letter-spacing: -0.04em; + } + + &::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: linear-gradient(90deg, color-mix(in srgb, var(--color-secondary-6) 50%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 15%, transparent) 50%, transparent 100%); + } + + .ct-snippet__title { + font-family: var(--font-family-primary); + font-size: 32px; + font-weight: var(--font-weight-normal); + letter-spacing: -0.01em; + line-height: var(--line-height-sm); + color: var(--text-color); + margin-bottom: var(--space-y-3); + } + + .ct-snippet__summary { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + color: var(--text-subtle-color); + max-width: 560px; + } +} + +[class*='col-']:last-child .ct-snippet--itemized::after { + display: none; +} + +// ── Fact cards (homepage "The essentials") ────────────────────────────────── +// Each "essential" is a do_fact_card: a large figure above an uppercase label, +// laid out as a bordered grid on the subtle surface. +.ct-list:has(.ct-fact-card) { + // The four fact cards form a single bordered 2x2 grid with internal dividers + // (the design's stat grid), not separate cards with gaps. + .row:has(.ct-fact-card) { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--border-color); + margin: 0; + + [class*='col-'] { + width: auto; + max-width: none; + padding: 0; + } + + > [class*='col-']:nth-child(1) { + border-right: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + } + + > [class*='col-']:nth-child(2) { + border-bottom: 1px solid var(--border-color); + } + + > [class*='col-']:nth-child(3) { + border-right: 1px solid var(--border-color); + } + } + + .ct-fact-card { + height: 100%; + padding: var(--space-y-9) var(--space-x-7); + } + + .ct-fact-card__figure { + display: flex; + align-items: baseline; + gap: 4px; + font-family: var(--font-family-primary); + font-size: clamp(72px, 8vw, 108px); + font-weight: var(--font-weight-bold); + line-height: 1; + letter-spacing: -0.03em; + color: var(--color-secondary-6); + margin-bottom: var(--space-y-2); + } + + .ct-fact-card__suffix { + font-size: 0.45em; + font-weight: var(--font-weight-normal); + letter-spacing: 0; + opacity: 0.8; + } + + .ct-fact-card__label { + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-subtle-color); + } + + // Single column on mobile, with the dividers running between stacked cells. + @media (max-width: 767.98px) { + .row:has(.ct-fact-card) { + grid-template-columns: 1fr; + + > [class*='col-']:nth-child(1), + > [class*='col-']:nth-child(2), + > [class*='col-']:nth-child(3) { + border-right: none; + border-bottom: 1px solid var(--border-color); + } + } + } +} + +// ── Trust card snippet variant ────────────────────────────────────────────── +// A snippet rendered as a bordered card in a 4-column grid. The trust variant +// lives on the snippet; the enclosing list switches to a CSS grid layout. +.ct-list:has(.ct-snippet--trust) { + background: var(--page-bg); + + // CivicTheme's four-column flex grid overflows, overlapping the bordered + // cells. Use the design's clean CSS grid (four columns with a gap, two on + // mobile) instead. + .row:has(.ct-snippet) { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-y-4); + margin: 0; + + [class*='col-'] { + width: auto; + max-width: none; + padding: 0; + } + } +} + +.ct-snippet--trust { + box-sizing: border-box; + text-align: center; + padding: var(--space-y-5) var(--space-x-3); + border: 1px solid var(--border-subtle-color); + border-radius: var(--radius-sm); + height: 100%; + + .ct-snippet__title { + font-size: var(--body-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + margin-bottom: var(--space-y-2); + } + + .ct-snippet__summary { + font-size: var(--font-size-sm); + font-weight: var(--body-font-weight); + line-height: var(--line-height-md); + color: var(--text-subtle-color); + } +} + +@media (max-width: 767.98px) { + .ct-list:has(.ct-snippet--trust) .row:has(.ct-snippet) { + grid-template-columns: 1fr 1fr; + } +} + +// ── Dotted snippet variant ────────────────────────────────────────────────── +// A snippet rendered as a dot-prefixed row with top/bottom dividers. +.ct-list:has(.ct-snippet--dotted) { + .ct-list__rows { + max-width: 680px; + margin: 0 auto; + } + + // The services "approach" is a two-column dotted list. The design renders it + // as a full-width two-column grid where each item stacks the dot above the + // heading with no dividers, unlike the single-column lists. + .ct-list__rows:has([class*='col-m-6']) { + max-width: none; + } +} + +.ct-list__rows:has(.ct-snippet--dotted) { + counter-reset: do-dotted; +} + +.ct-snippet--dotted { + display: grid; + grid-template-columns: 40px 1fr; + gap: var(--space-y-3); + align-items: start; + padding: var(--space-y-5) 0; + border-bottom: 1px solid var(--border-subtle-color); + + &:first-child { + border-top: 1px solid var(--border-subtle-color); + } + + &::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-secondary-6); + margin-top: 10px; + } + + .ct-snippet__title { + font-size: var(--heading-h4-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: -0.01em; + color: var(--text-color); + margin-bottom: var(--space-y-1); + } + + .ct-snippet__summary { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + color: var(--text-subtle-color); + } +} + +// Two-column dotted list (services "approach"): each item stacks the dot above +// the heading with no dividers. +[class*='col-m-6'] .ct-snippet--dotted { + display: block; + padding: var(--space-y-4) 0; + border: 0; + + &::before { + display: block; + margin: 0 0 var(--space-y-3); + } +} + +// ── CTA callout (closing "Let's talk" section) ────────────────────────────── +// The design's CTA is a flat, full-width centred section with a soft central +// glow - not CivicTheme's inner panel with a left highlight stripe. +.ct-callout { + position: relative; + overflow: hidden; + background: var(--cta-bg); + padding: calc(var(--space-y-10) * 2) 0; + text-align: center; + + // Soft central glow behind the heading. + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 700px; + height: 500px; + transform: translate(-50%, -50%); + background: radial-gradient(ellipse at center, color-mix(in srgb, var(--color-secondary-6) 10%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 3%, transparent) 50%, transparent 70%); + pointer-events: none; + } + + // Drop CivicTheme's inner panel background and its left highlight stripe so + // the section reads as one flat surface. + &.ct-theme-dark .ct-callout__wrapper { + position: relative; + z-index: 1; + background-color: transparent; + } + + .ct-callout__wrapper::before { + display: none; + } + + .ct-callout__title { + font-family: var(--font-family-primary); + font-size: var(--display-1-font-size, clamp(56px, 8vw, 92px)); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-xs, 1.1); + letter-spacing: -0.02em; + color: var(--text-color); + margin-bottom: var(--space-y-6); + } + + .ct-callout__content { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + color: var(--text-subtle-color); + max-width: 560px; + margin: 0 auto var(--space-y-7); + + // The "Talk to us" button (the same secondary button as the hero) sits below + // the copy; keep its button styling intact and just space it away. + .ct-button { + margin-top: var(--space-y-5); + } + + // A plain link in the copy (e.g. an email) renders as a large clean link, + // not a button. + a:not(.ct-button) { + display: inline-block; + margin-top: var(--space-y-5); + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.04em; + color: var(--link-color); + text-decoration: none; + + &:hover { + color: var(--link-hover-color); + text-decoration: underline; + text-underline-offset: 0.2em; + } + } + } +} diff --git a/web/themes/custom/drevops/assets/sass/brand/_tokens.scss b/web/themes/custom/drevops/assets/sass/brand/_tokens.scss new file mode 100644 index 00000000..c53303bf --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/brand/_tokens.scss @@ -0,0 +1,313 @@ +// +// DrevOps — Brand Token System +// +// Ported from the static design's variables.css. +// +// The site is ALWAYS dark and does NOT use data-color-scheme. The +// scheme-aware semantic tokens below therefore take their DARK values +// (from the design's [data-color-scheme="dark"] block) directly at +// :root. The Google Fonts @import is intentionally omitted (fonts are +// already loaded by the theme). +// + +// ══════════════════════════════════════════════════════════════ +// PALETTE +// Eleven derived steps per brand colour. Step 1 = lightest, +// step 11 = near-black. The brand base colour sits near step 9. +// ══════════════════════════════════════════════════════════════ +:root { + /* primary */ + --color-primary-1: #EFF6FF; + --color-primary-2: #C8D9F3; + --color-primary-3: #ABBCD5; + --color-primary-4: #8FA0B8; + --color-primary-5: #75849C; + --color-primary-6: #5B6A80; + --color-primary-7: #425166; + --color-primary-8: #2B394D; + --color-primary-9: #152235; + --color-primary-10: #030D1E; + --color-primary-11: #000108; + + /* secondary */ + --color-secondary-1: #E1FBFF; + --color-secondary-2: #96E7F4; + --color-secondary-3: #79C9D7; + --color-secondary-4: #5CACBA; + --color-secondary-5: #3F919D; + --color-secondary-6: #1E7582; + --color-secondary-7: #045A65; + --color-secondary-8: #044048; + --color-secondary-9: #00272D; + --color-secondary-10: #001013; + --color-secondary-11: #000202; + + /* tertiary */ + --color-tertiary-1: #FFF2EF; + --color-tertiary-2: #FFC9BC; + --color-tertiary-3: #FF9C86; + --color-tertiary-4: #ED775E; + --color-tertiary-5: #CD5B43; + --color-tertiary-6: #AE3F28; + --color-tertiary-7: #902107; + --color-tertiary-8: #691301; + --color-tertiary-9: #430800; + --color-tertiary-10: #210200; + --color-tertiary-11: #050000; + + /* neutral */ + --color-neutral-1: #FFFFFF; + --color-neutral-2: #F0F0F0; + --color-neutral-3: #D4D4D4; + --color-neutral-4: #B0B0B0; + --color-neutral-5: #8C8C8C; + --color-neutral-6: #6B6B6B; + --color-neutral-7: #4D4D4D; + --color-neutral-8: #333333; + --color-neutral-9: #1F1F1F; + --color-neutral-10: #0F0F0F; + --color-neutral-11: #000000; + + /* pass */ + --color-pass-1: #EFFBF1; + --color-pass-2: #D6F5DD; + --color-pass-3: #B5EDC2; + --color-pass-4: #8CE3A0; + --color-pass-5: #63DA7E; + --color-pass-6: #3AD05C; + --color-pass-7: #29AD47; + --color-pass-8: #208436; + --color-pass-9: #165B25; + --color-pass-10: #0E3A18; + --color-pass-11: #08210E; + + /* fail */ + --color-fail-1: #FCEEEF; + --color-fail-2: #F7D4D7; + --color-fail-3: #F1B1B7; + --color-fail-4: #EA858F; + --color-fail-5: #E25A67; + --color-fail-6: #DB2E3F; + --color-fail-7: #B7202E; + --color-fail-8: #8B1823; + --color-fail-9: #601118; + --color-fail-10: #3D0B0F; + --color-fail-11: #230609; + + /* warn */ + --color-warn-1: #FFFAEB; + --color-warn-2: #FFF2CC; + --color-warn-3: #FFE8A3; + --color-warn-4: #FFDB70; + --color-warn-5: #FFCF3D; + --color-warn-6: #FFC20A; + --color-warn-7: #D6A100; + --color-warn-8: #A37A00; + --color-warn-9: #705400; + --color-warn-10: #473600; + --color-warn-11: #291F00; + + /* info */ + --color-info-1: #F1F8F9; + --color-info-2: #DCEDEF; + --color-info-3: #BFDEE3; + --color-info-4: #9CCCD3; + --color-info-5: #78B9C4; + --color-info-6: #55A7B4; + --color-info-7: #418995; + --color-info-8: #326971; + --color-info-9: #22484E; + --color-info-10: #162E32; + --color-info-11: #0C1A1C; +} + +// ══════════════════════════════════════════════════════════════ +// PRIMITIVE TOKENS +// Flat values on a scale — never change by theme context. +// ══════════════════════════════════════════════════════════════ +:root { + /* Font Family */ + --font-family-primary: 'Plus Jakarta Sans', sans-serif; + --font-family-secondary: 'Outfit', sans-serif; + --font-family-mono: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, monospace; + + /* Font Size */ + --font-size-2xs: 0.6875rem; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-md: 1rem; + --font-size-lg: 1.25rem; + --font-size-xl: 1.5rem; + --font-size-2xl: 1.875rem; + --font-size-3xl: 2.25rem; + --font-size-4xl: 3rem; + --font-size-5xl: 4rem; + + /* Font Weight */ + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Line Height */ + --line-height-xs: 1.1; + --line-height-sm: 1.2; + --line-height-md: 1.5; + --line-height-lg: 1.75; + --line-height-xl: 2; + + /* Space */ + --space-x-1: 0.5rem; + --space-x-2: 1rem; + --space-x-3: 1.5rem; + --space-x-4: 2rem; + --space-x-5: 2.5rem; + --space-x-6: 3rem; + --space-x-7: 3.5rem; + --space-x-8: 4rem; + --space-x-9: 4.5rem; + --space-x-10: 5rem; + --space-y-1: 0.5rem; + --space-y-2: 1rem; + --space-y-3: 1.5rem; + --space-y-4: 2rem; + --space-y-5: 2.5rem; + --space-y-6: 3rem; + --space-y-7: 3.5rem; + --space-y-8: 4rem; + --space-y-9: 4.5rem; + --space-y-10: 5rem; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 1rem; +} + +// ══════════════════════════════════════════════════════════════ +// SEMANTIC TOKENS — Typography +// Reusable UI decisions that reference primitives. +// ══════════════════════════════════════════════════════════════ +:root { + /* Heading */ + --heading-font-family: var(--font-family-primary); + --heading-font-weight: var(--font-weight-bold); + --heading-line-height: var(--line-height-sm); + --heading-h1-font-size: var(--font-size-3xl); + --heading-h2-font-size: var(--font-size-2xl); + --heading-h3-font-size: var(--font-size-xl); + --heading-h4-font-size: var(--font-size-lg); + --heading-h5-font-size: var(--font-size-md); + --heading-h6-font-size: var(--font-size-sm); + + /* Display */ + --display-font-family: var(--font-family-primary); + --display-font-weight: var(--font-weight-bold); + --display-line-height: var(--line-height-xs); + --display-1-font-size: var(--font-size-5xl); + --display-2-font-size: var(--font-size-4xl); + + /* Body */ + --body-font-family: var(--font-family-secondary); + --body-font-size: var(--font-size-md); + --body-font-weight: var(--font-weight-extralight); + --body-line-height: var(--line-height-md); + + /* Caption */ + --caption-font-size: var(--font-size-xs); + --caption-line-height: var(--line-height-md); + + /* Label */ + --label-font-size: var(--font-size-2xs); + --label-font-weight: var(--font-weight-normal); + --label-letter-spacing: 0.2em; + --label-text-transform: uppercase; + + /* Link (non-scheme-aware) */ + --link-decoration: underline; + --link-hover-decoration: none; + --link-underline-offset: 0.15em; +} + +// ══════════════════════════════════════════════════════════════ +// SEMANTIC TOKENS — Colour (DARK values, emitted at :root) +// The site is always dark, so the dark scheme values are applied +// directly at :root with no data-color-scheme selector. +// ══════════════════════════════════════════════════════════════ +:root { + /* Text */ + --text-color: #FFFFFF; + --text-subtle-color: var(--color-primary-1); + --text-muted-color: var(--color-primary-3); + --text-accent-color: var(--color-tertiary-3); + + /* Links */ + --link-color: var(--color-secondary-2); + --link-hover-color: var(--color-secondary-1); + + /* Surfaces */ + --page-color: #FFFFFF; + --page-bg: var(--color-primary-9); + --surface-color: var(--color-primary-8); + --surface-subtle-color: color-mix(in srgb, white 6%, var(--color-primary-9)); + --surface-muted-color: color-mix(in srgb, white 3%, var(--color-primary-9)); + + /* Borders */ + --border-color: color-mix(in srgb, var(--color-secondary-6) 18%, transparent); + --border-subtle-color: color-mix(in srgb, white 6%, transparent); + --border-muted-color: color-mix(in srgb, white 3%, transparent); + + /* Buttons */ + --btn-primary-bg: transparent; + --btn-primary-color: var(--color-primary-1); + --btn-primary-border: color-mix(in srgb, var(--color-primary-1) 40%, transparent); + --btn-primary-hover-bg: color-mix(in srgb, var(--color-primary-1) 8%, transparent); + --btn-primary-hover-color: #FFFFFF; + --btn-secondary-bg: transparent; + --btn-secondary-color: var(--color-secondary-2); + --btn-secondary-border: color-mix(in srgb, var(--color-secondary-2) 40%, transparent); + --btn-secondary-hover-bg: color-mix(in srgb, var(--color-secondary-6) 8%, transparent); + --btn-secondary-hover-color: #FFFFFF; + --btn-tertiary-bg: transparent; + --btn-tertiary-color: var(--color-tertiary-3); + --btn-tertiary-border: color-mix(in srgb, var(--color-tertiary-3) 40%, transparent); + --btn-tertiary-hover-bg: color-mix(in srgb, var(--color-tertiary-6) 8%, transparent); + --btn-tertiary-hover-color: #FFFFFF; + + /* Code */ + --code-bg: var(--color-primary-8); + --code-color: var(--color-primary-1); + --code-border: var(--border-color); + --code-inline-bg: var(--surface-color); + --code-inline-color: var(--color-tertiary-3); + + /* Tags */ + --tag-bg: var(--surface-color); + --tag-color: var(--color-secondary-2); + + /* Tables (article context) */ + --table-header-bg: var(--surface-color); + --table-header-color: #FFFFFF; + --table-stripe-bg: var(--surface-subtle-color); + + /* CTA */ + --cta-bg: var(--color-primary-8); +} + +// ══════════════════════════════════════════════════════════════ +// TOKEN OVERRIDES — Website context (from website.css) +// ══════════════════════════════════════════════════════════════ +:root { + --display-1-font-size: clamp(56px, 8vw, 92px); + --display-2-font-size: clamp(40px, 5vw, 56px); + + // CivicTheme derives a saturated red focus colour that reads as an error + // state on inputs and buttons. Use the brand coral for the focus ring + // instead. This `:root` rule outranks the generated `html { ... }` colour + // variables, so it applies regardless of stylesheet load order. + --ct-color-light-interaction-focus: var(--color-tertiary-3); + --ct-color-dark-interaction-focus: var(--color-tertiary-3); +} diff --git a/web/themes/custom/drevops/assets/sass/theme.scss b/web/themes/custom/drevops/assets/sass/theme.scss index 66ed0ed3..691f4623 100644 --- a/web/themes/custom/drevops/assets/sass/theme.scss +++ b/web/themes/custom/drevops/assets/sass/theme.scss @@ -8,3 +8,15 @@ // @todo Refactor build to use direct import from the CivicTheme theme. @import 'page/page'; @import 'block/local-tasks'; +@import 'brand/tokens'; + +// These enhance CivicTheme's component markup site-wide, so they load globally +// rather than scoped to a content wrapper. +@import 'brand/extra'; +@import 'brand/layout'; +@import 'brand/buttons'; +@import 'brand/content'; +@import 'brand/lists'; +@import 'brand/blog'; +@import 'brand/contact'; +@import 'brand/animations'; diff --git a/web/themes/custom/drevops/components/02-molecules/fact-card/fact-card.component.yml b/web/themes/custom/drevops/components/02-molecules/fact-card/fact-card.component.yml new file mode 100644 index 00000000..72bc85d0 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/fact-card/fact-card.component.yml @@ -0,0 +1,34 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Fact card +status: stable +description: 'A single statistic - a large figure with a label.' +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + content: + type: string + title: Content + description: The figure, e.g. "0" or "40". + suffix: + type: string + title: Suffix + description: A small unit after the figure, e.g. "day", "yrs" or "+". + label: + type: string + title: Label + description: The label beneath the figure. + modifier_class: + type: string + title: Modifier Class + description: Additional CSS classes. + attributes: + type: Drupal\Core\Template\Attribute + title: Attributes + description: Additional HTML attributes. diff --git a/web/themes/custom/drevops/components/02-molecules/fact-card/fact-card.twig b/web/themes/custom/drevops/components/02-molecules/fact-card/fact-card.twig new file mode 100644 index 00000000..4af01f49 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/fact-card/fact-card.twig @@ -0,0 +1,27 @@ +{# +/** + * @file + * Fact card component. + * + * A single statistic: a large figure with a label beneath it. + * + * Props: + * - theme: [string] Theme variation (light or dark). + * - content: [string] The figure, e.g. "0" or "40". + * - suffix: [string] A small unit after the figure, e.g. "day", "yrs" or "+". + * - label: [string] The label beneath the figure. + * - modifier_class: [string] Additional CSS classes. + * - attributes: [Drupal\Core\Template\Attribute] Additional HTML attributes. + */ +#} +{% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %} +{% set modifier_class = '%s %s'|format(theme_class, modifier_class|default('')) %} + +{% if content is not empty %} +
+
{{ content }}{% if suffix is not empty %}{{ suffix }}{% endif %}
+ {% if label is not empty %} +
{{ label }}
+ {% endif %} +
+{% endif %} diff --git a/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.component.yml b/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.component.yml index d9021927..6ea969e4 100644 --- a/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.component.yml +++ b/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.component.yml @@ -54,6 +54,10 @@ props: type: string title: End Date description: Formatted end time. + reading_time: + type: string + title: Reading time + description: Estimated reading time shown alongside the date. link: type: object title: Link diff --git a/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.scss b/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.scss index f20d64fc..5ca0c46a 100644 --- a/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.scss +++ b/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.scss @@ -66,6 +66,24 @@ @include ct-typography('heading-4'); } + // Custom: Article meta line (date / reading time) - the design's small, + // uppercase, muted caption shown above the card title. + #{$root}__meta { + display: flex; + align-items: center; + gap: var(--space-y-1); + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted-color); + } + + #{$root}__meta-sep { + color: var(--color-secondary-6); + opacity: 40%; + } + #{$root}__title-link { @include ct-font-size-inherit(); diff --git a/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.twig b/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.twig index a1626a8a..c58c043e 100644 --- a/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.twig +++ b/web/themes/custom/drevops/components/02-molecules/promo-card/promo-card.twig @@ -106,24 +106,36 @@ {% block date_block %} {% if date is not empty %} - {% set date_content %} - {% include 'civictheme:datetime' with { - start: date, - start_iso: date_iso|default(null), - end: date_end|default(null), - end_iso: date_end_iso|default(null), - } only %} - {% endset %} + {% if date_end %} + {# Event date range: keep CivicTheme's pill with a calendar icon. #} + {% set date_content %} + {% include 'civictheme:datetime' with { + start: date, + start_iso: date_iso|default(null), + end: date_end, + end_iso: date_end_iso|default(null), + } only %} + {% endset %} -
- {% include 'civictheme:tag' with { - theme: theme, - type: date_end ? 'secondary' : 'tertiary', - content: date_content, - icon: date_end ? 'calendar-2' : null, - icon_placement: 'before', - } only %} -
+
+ {% include 'civictheme:tag' with { + theme: theme, + type: 'secondary', + content: date_content, + icon: 'calendar-2', + icon_placement: 'before', + } only %} +
+ {% else %} + {# Article meta line: the design's "date / N min read". #} +
+ + {% if reading_time is not empty %} + + {{ reading_time }} + {% endif %} +
+ {% endif %} {% endif %} {% endblock %} diff --git a/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.component.yml b/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.component.yml new file mode 100644 index 00000000..bab27d58 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.component.yml @@ -0,0 +1,56 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Service detail +status: stable +description: 'A detailed service card: a number, title and tagline, a description, an inclusions list, a price and a call-to-action button.' +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + title: + type: string + title: Title + description: The service name. + tagline: + type: string + title: Tagline + description: A one-line summary shown beside the title. + includes: + type: array + title: Inclusions + description: The "what's included" list items. + items: + type: string + price_label: + type: string + title: Price label + description: The label above the price, e.g. "From" or "Typical engagement". + price_value: + type: string + title: Price value + description: The price, e.g. "$40K - $180K". + link_url: + type: string + title: Link URL + description: The call-to-action destination. + link_title: + type: string + title: Link title + description: The call-to-action label. + modifier_class: + type: string + title: Modifier Class + description: Additional CSS classes. + attributes: + type: Drupal\Core\Template\Attribute + title: Attributes + description: Additional HTML attributes. +slots: + description: + title: Description + description: The service description (rich text). diff --git a/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.scss b/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.scss new file mode 100644 index 00000000..a00f2018 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.scss @@ -0,0 +1,192 @@ +// +// Service detail component styles. +// +// The 01/02/03 number is a CSS counter, incremented per card and reset once per +// page in brand/_extra.scss. +// + +.ct-service-detail { + counter-increment: do-service; + + // The design seats the service cards on a full-width muted section band, with + // the cards themselves in a centred column. Consecutive cards' bands are + // contiguous, so together they read as one panel. + background: var(--surface-muted-color); + padding-top: var(--space-y-6); + padding-inline: var(--space-x-8); + + &:first-of-type { + padding-top: var(--layout-rhythm-md, 8.75rem); + } + + &:last-of-type { + padding-bottom: var(--layout-rhythm-md, 8.75rem); + } + + &__inner { + box-sizing: border-box; + max-width: calc(1100px - 2 * var(--space-x-8)); + margin-inline: auto; + background: var(--page-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-y-7); + } + + // Header: the number beside the title and tagline, with a divider below. + &__header { + display: flex; + align-items: flex-start; + gap: var(--space-y-4); + margin-bottom: var(--space-y-5); + padding-bottom: var(--space-y-4); + border-bottom: 1px solid var(--border-subtle-color); + } + + &__number { + flex-shrink: 0; + font-family: var(--font-family-primary); + font-size: 64px; + font-weight: var(--font-weight-bold); + line-height: 1; + letter-spacing: -0.04em; + color: var(--color-secondary-6); + opacity: 20%; + + &::before { + content: counter(do-service, decimal-leading-zero); + } + } + + &__title { + font-family: var(--font-family-primary); + font-size: var(--heading-h2-font-size); + font-weight: var(--font-weight-normal); + line-height: 1.2; + letter-spacing: -0.01em; + color: var(--text-color); + + // CivicTheme's heading rule adds a top margin that drops the title below the + // number; the design top-aligns them in the flex header. + margin-top: 0; + margin-bottom: var(--space-y-1); + } + + &__tagline { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + color: var(--text-muted-color); + margin-top: 0; + } + + // Body: description and inclusions side by side. + &__body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-y-6); + margin-bottom: var(--space-y-5); + } + + &__desc p { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + margin-bottom: var(--space-y-2); + } + + &__includes-label { + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-lg); + letter-spacing: var(--label-letter-spacing); + text-transform: uppercase; + color: var(--text-accent-color); + margin-bottom: var(--space-y-2); + } + + &__includes ul { + list-style: none; + margin: 0; + padding: 0; + } + + &__includes li { + position: relative; + padding: var(--space-y-1) 0 var(--space-x-1) var(--space-y-3); + font-size: var(--font-size-sm); + line-height: var(--line-height-md); + color: var(--text-subtle-color); + border-bottom: 1px solid var(--border-muted-color); + + &:last-child { + border-bottom: none; + } + + &::before { + content: ''; + position: absolute; + left: 0; + top: 16px; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-secondary-6); + } + } + + // Footer: price on the left, CTA button on the right. + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-y-3); + padding-top: var(--space-y-4); + border-top: 1px solid var(--border-subtle-color); + } + + &__price-label { + display: block; + margin-bottom: 4px; + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-lg); + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted-color); + } + + &__price-value { + font-family: var(--font-family-primary); + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-lg); + letter-spacing: -0.01em; + color: var(--color-secondary-6); + } + + @media (max-width: 767.98px) { + padding-inline: var(--space-y-3); + + &__inner { + padding: var(--space-y-4) var(--space-x-3); + } + + &__header { + flex-direction: column; + gap: var(--space-y-2); + } + + &__body { + grid-template-columns: 1fr; + gap: var(--space-y-4); + } + + &__footer { + flex-direction: column; + align-items: flex-start; + gap: var(--space-y-3); + } + } +} diff --git a/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.twig b/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.twig new file mode 100644 index 00000000..98182632 --- /dev/null +++ b/web/themes/custom/drevops/components/02-molecules/service-detail/service-detail.twig @@ -0,0 +1,69 @@ +{# +/** + * @file + * Service detail component. + * + * A structured service card: a header (number, title, tagline), a two-column + * body (description and the inclusions list) and a footer (price and CTA). + * + * Props: + * - theme: [string] Theme variation (light or dark). + * - title: [string] The service name. + * - tagline: [string] A one-line summary shown beside the title. + * - includes: [array] The "what's included" list items. + * - price_label: [string] The label above the price. + * - price_value: [string] The price. + * - link_url: [string] The call-to-action destination. + * - link_title: [string] The call-to-action label. + * - modifier_class: [string] Additional CSS classes. + * - attributes: [Drupal\Core\Template\Attribute] Additional HTML attributes. + * + * Slots: + * - description: The service description (rich text). + */ +#} +{% set theme_class = 'ct-theme-%s'|format(theme|default('dark')) %} +{% set modifier_class = '%s %s'|format(theme_class, modifier_class|default('')) %} +
+
+
+ +
+ {% if title is not empty %} +

{{ title }}

+ {% endif %} + {% if tagline is not empty %} +

{{ tagline }}

+ {% endif %} +
+
+ +
+
{% block description %}{% endblock %}
+ {% if includes is not empty %} +
+

What's included

+
    + {% for item in includes %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endif %} +
+ + {% if price_value is not empty or link_url is not empty %} + + {% endif %} +
+
diff --git a/web/themes/custom/drevops/components/03-organisms/banner/banner.scss b/web/themes/custom/drevops/components/03-organisms/banner/banner.scss index 6d4bd2ce..6a6158fc 100644 --- a/web/themes/custom/drevops/components/03-organisms/banner/banner.scss +++ b/web/themes/custom/drevops/components/03-organisms/banner/banner.scss @@ -168,4 +168,210 @@ @include ct-typography('display-large'); } } + + // Custom: Homepage hero treatment for the intro banner - full-viewport dark + // canvas with an atmospheric glow, eyebrow, oversized display headline, + // subtitle and a scroll cue, reproducing the design hero. + &.ct-banner-type--intro.ct-banner--hero { + #{$root}__inner { + position: relative; + + // Include the padding in the min-height so the hero is exactly its target + // height and never overflows the first screen (CivicTheme's banner inner + // is content-box, which would otherwise add the padding on top of 100vh). + box-sizing: border-box; + min-height: 62vh; + flex-direction: column; + justify-content: center; + overflow: hidden; + padding-top: ct-spacing(16); + padding-bottom: ct-spacing(10); + + // The design hero is the deep-navy page canvas with an atmospheric glow, + // not CivicTheme's lighter banner surface. Let the page background show + // through so the hero is not washed out. + background-color: transparent; + } + + // Full-viewport variant for the front page. The fixed header overlaps the + // top, so the top padding clears it while the hero stays within one screen. + &.ct-banner--hero-home #{$root}__inner { + min-height: 100vh; + padding-top: ct-spacing(16); + padding-bottom: ct-spacing(10); + } + + // Atmospheric glow. + #{$root}__inner::before { + content: ''; + position: absolute; + top: 45%; + left: 50%; + width: min(800px, 90vw); + height: 600px; + transform: translate(-50%, -50%); + background: radial-gradient(ellipse at center, color-mix(in srgb, #{ct-color-dark('interaction-background')} 14%, transparent) 0%, color-mix(in srgb, #{ct-color-dark('interaction-background')} 4%, transparent) 40%, transparent 70%); + pointer-events: none; + } + + // Vignette. + #{$root}__inner::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 40%, color-mix(in srgb, #{ct-color-dark('background')} 60%, transparent) 100%); + pointer-events: none; + } + + .container { + position: relative; + z-index: 1; + max-width: ct-particle(115); + } + + #{$root}__site-section { + @include ct-typography('label-regular'); + + margin-bottom: ct-spacing(3); + color: ct-color-dark('interaction-background'); + text-transform: uppercase; + letter-spacing: 0.2em; + } + + #{$root}__title { + margin-bottom: ct-spacing(3); + font-size: clamp(ct-particle(7), 8vw, ct-particle(11.5)); + line-height: 1.05; + } + + #{$root}__content { + max-width: ct-particle(90); + margin: 0 auto; + color: color-mix(in srgb, #{ct-color-dark('body')} 75%, transparent); + + @include ct-typography('text-large'); + } + } + + // Custom: Page hero - a compact centred hero for listing/landing pages. A + // faint, static glow sits behind a centred headline whose block shrinks to + // the text width, with a coral eyebrow line pinned to its top-left edge. + &.ct-banner-type--intro.ct-banner--page { + #{$root}__inner { + position: relative; + box-sizing: border-box; + min-height: 0; + flex-direction: column; + justify-content: center; + overflow: hidden; + padding-top: ct-spacing(25); + padding-bottom: ct-spacing(10); + background-color: transparent; + } + + #{$root}__inner::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: min(900px, 95vw); + height: 600px; + transform: translate(-50%, -50%); + background: radial-gradient(ellipse at center, color-mix(in srgb, #{ct-color-dark('interaction-background')} 8%, transparent) 0%, transparent 65%); + pointer-events: none; + } + + .container { + position: relative; + z-index: 1; + } + + #{$root}__title { + max-width: ct-particle(93); + margin-inline: auto; + font-size: var(--display-2-font-size); + font-weight: var(--font-weight-normal); + line-height: var(--display-line-height); + letter-spacing: -0.02em; + color: var(--text-color); + } + + #{$root}__eyebrow { + display: flex; + align-items: center; + gap: var(--space-x-2); + margin-bottom: var(--space-y-4); + font-size: var(--label-font-size); + letter-spacing: var(--label-letter-spacing); + color: var(--text-accent-color); + + &::before { + content: ''; + display: block; + width: var(--space-x-6); + height: 1px; + background: currentcolor; + opacity: 60%; + } + } + } +} + +// Accent word inside the hero headline - the design's coral accent. +.ct-banner__title .do-word-accent { + color: var(--text-accent-color); +} + +// Eyebrow label above the hero headline. The design uses a dark teal, lifted +// here to the lighter teal so the small text clears the contrast minimum. +.ct-banner__title .ct-banner__eyebrow { + display: block; + font-family: var(--body-font-family); + font-size: var(--caption-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.2em; + line-height: var(--line-height-md); + text-transform: uppercase; + color: var(--color-secondary-4); + margin-bottom: var(--space-y-4); +} + +// Fade hero (blog posts): the featured image fills the banner behind a dark +// page-coloured gradient, with the title sitting over the bottom of the image. +.ct-banner-type--fade { + .ct-banner__inner { + position: relative; + overflow: hidden; + min-height: 520px; + display: flex; + align-items: flex-end; + background-size: cover; + background-position: center; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to bottom, color-mix(in srgb, var(--page-bg) 40%, transparent) 0%, color-mix(in srgb, var(--page-bg) 70%, transparent) 40%, var(--page-bg) 100%); + } + + .container { + position: relative; + z-index: 2; + padding-bottom: var(--space-y-8); + } + + @media (max-width: 767.98px) { + min-height: 400px; + } + } + + .ct-banner__title { + max-width: 800px; + font-size: var(--display-2-font-size); + font-weight: var(--font-weight-bold); + line-height: var(--display-line-height); + letter-spacing: -0.02em; + color: var(--text-color); + } } diff --git a/web/themes/custom/drevops/components/03-organisms/banner/banner.twig b/web/themes/custom/drevops/components/03-organisms/banner/banner.twig index b1a8c2ae..35d8b2b0 100644 --- a/web/themes/custom/drevops/components/03-organisms/banner/banner.twig +++ b/web/themes/custom/drevops/components/03-organisms/banner/banner.twig @@ -53,7 +53,7 @@ {% set is_decorative_class = is_decorative ? 'ct-banner--decorative' : '' %} {% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %} {# Custom: Add type and with_offset support. #} -{% set type = type in ['default', 'large', 'intro'] ? type : 'default' %} +{% set type = type in ['default', 'large', 'intro', 'fade', 'page'] ? type : 'default' %} {% set type_class = 'ct-banner-type--%s'|format(type) %} {% set with_offset_class = with_offset ? 'ct-banner--with-offset' : '' %} {% set modifier_class = '%s %s %s %s %s'|format(theme_class, is_decorative_class, type_class, with_offset_class, modifier_class|default('')) %} @@ -92,7 +92,7 @@
{% block breadcrumb_block %} {# Custom: Hide breadcrumb for intro type and use include() instead of {% include only %}. #} - {% if breadcrumb is not empty and type != 'intro' %} + {% if breadcrumb is not empty and type not in ['intro', 'fade'] %}