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.
'), + ]; + + foreach ($components as $component) { + $component->save(); + } + + $stale = array_merge( + ContentBuilder::stageBanner( + $node, + 'Your websiteTell us where things stand. We\'ll be straight with you about whether we\'re the right fit.
'), + ]; + + foreach ($components as $component) { + $component->save(); + } + + $stale = array_merge( + ContentBuilder::stageBanner( + $node, + 'Engineering that keeps' . $subtitle . '
'; + + if ($cta !== NULL) { + $html .= ''; + } + + 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' +We typically respond within one business day.
+Available weekdays, Melbourne time (AEST).
+Melbourne, Australia
+We work with organisations across Australia and New Zealand.
+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.
+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 Phase | Median Time | Worst Case | Optimised |
|---|---|---|---|
| Docker image pull | 2m 30s | 6m 15s | 15s |
| Composer install | 1m 45s | 4m 20s | 12s |
| Database import | 3m 10s | 8m 00s | 45s |
| PHPUnit tests | 4m 20s | 12m 00s | 1m 30s |
| Behat/Cypress | 6m 45s | 18m 00s | 2m 15s |
| Total | 18m 30s | 48m 35s | 4m 57s |
The gap between worst-case and optimised is significant. Let's walk through the fixes.
+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.
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.
+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.lockhash, notcomposer.json. The lock file captures exact versions, so the cache is only invalidated when dependencies actually change.
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);
+ }
+}
+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.
| Strategy | Effort | Time Saved |
|---|---|---|
| Paratest for PHPUnit | Low - drop-in replacement | 40-60% |
| Split Behat by tag | Medium - needs CI config | 50-70% |
| Parallel CI jobs | Medium - matrix builds | 60-80% |
| Test selection (changed files only) | High - needs test mapping | 70-90% |
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
+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.
+ +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{{ tagline }}
+ {% endif %} +What's included
+{{ eyebrow }}
+ {% endif %} + + {% block title_block %} + {% if title -%} + {% include 'civictheme:heading' with { + content: title, + level: 2, + theme: theme, + modifier_class: 'ct-list__title', + } only %} + {% endif %} + {% endblock %} +