From 68b9781c1e383bf416c0dddba008fbd53911c068 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 14:27:40 +1000 Subject: [PATCH 01/68] Added dark theme foundations: CivicTheme tokens, fonts, JS behaviours and paragraph theme-flip hook. --- config/default/core.extension.yml | 3 + web/modules/custom/do_base/do_base.deploy.php | 27 +++ web/modules/custom/do_base/do_base.info.yml | 2 + web/themes/custom/drevops/assets/js/reveal.js | 40 ++++ .../custom/drevops/assets/js/stats-counter.js | 64 ++++++ .../drevops/components/variables.base.scss | 201 ++++++++++-------- .../components/variables.components.scss | 5 +- web/themes/custom/drevops/drevops.info.yml | 4 + .../custom/drevops/drevops.libraries.yml | 10 + web/themes/custom/drevops/includes/page.inc | 7 + 10 files changed, 266 insertions(+), 97 deletions(-) create mode 100644 web/themes/custom/drevops/assets/js/reveal.js create mode 100644 web/themes/custom/drevops/assets/js/stats-counter.js diff --git a/config/default/core.extension.yml b/config/default/core.extension.yml index d747b3a3..2b606112 100644 --- a/config/default/core.extension.yml +++ b/config/default/core.extension.yml @@ -33,6 +33,8 @@ module: diff: 0 do_base: 0 do_feed: 0 + do_generated_content: 0 + drupal_helpers: 0 dynamic_entity_reference: 0 dynamic_page_cache: 0 editor: 0 @@ -46,6 +48,7 @@ module: file: 0 filter: 0 focal_point: 0 + generated_content: 0 gin_toolbar: 0 google_tag: 0 help: 0 diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 6e95f025..7e107ef5 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -8,3 +8,30 @@ */ declare(strict_types=1); + +use Drupal\drupal_helpers\Helper; + +/** + * Flip every component paragraph to the dark theme. + * + * The redesign renders the whole site dark. CivicTheme themes each component + * individually through the shared `field_c_p_theme` field, so this switches all + * existing paragraphs - 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 { + return Helper::entity($sandbox)->batchEntity('paragraph', NULL, static function ($paragraph): void { + if (!$paragraph->hasField('field_c_p_theme')) { + return; + } + + if ($paragraph->get('field_c_p_theme')->value === 'dark') { + return; + } + + $paragraph->set('field_c_p_theme', 'dark'); + $paragraph->setNewRevision(FALSE); + $paragraph->save(); + }); +} diff --git a/web/modules/custom/do_base/do_base.info.yml b/web/modules/custom/do_base/do_base.info.yml index 0c8cebcf..6120db07 100644 --- a/web/modules/custom/do_base/do_base.info.yml +++ b/web/modules/custom/do_base/do_base.info.yml @@ -3,3 +3,5 @@ type: module description: Base feature for DrevOps Website site. core_version_requirement: ^11 package: website +dependencies: + - drupal_helpers:drupal_helpers diff --git a/web/themes/custom/drevops/assets/js/reveal.js b/web/themes/custom/drevops/assets/js/reveal.js new file mode 100644 index 00000000..4bf5f40d --- /dev/null +++ b/web/themes/custom/drevops/assets/js/reveal.js @@ -0,0 +1,40 @@ +/** + * @file + * Reveal-on-scroll behaviour. + * + * Adds an `is-visible` class to any `.dr-reveal` element when it enters the + * viewport. Stagger delays are handled in CSS via `.dr-reveal--d1` ... + * `.dr-reveal--d6`. Elements are only hidden when the `js` class is present on + * the document (added by Drupal), so content remains visible without + * JavaScript. + */ +((Drupal, once) => { + Drupal.behaviors.drevopsReveal = { + attach(context) { + const elements = once('dr-reveal', '.dr-reveal', context); + + if (!elements.length) { + return; + } + + if (!('IntersectionObserver' in window)) { + elements.forEach((el) => el.classList.add('is-visible')); + return; + } + + const observer = new IntersectionObserver( + (entries, obs) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + obs.unobserve(entry.target); + } + }); + }, + { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }, + ); + + elements.forEach((el) => observer.observe(el)); + }, + }; +})(Drupal, once); diff --git a/web/themes/custom/drevops/assets/js/stats-counter.js b/web/themes/custom/drevops/assets/js/stats-counter.js new file mode 100644 index 00000000..45fa9171 --- /dev/null +++ b/web/themes/custom/drevops/assets/js/stats-counter.js @@ -0,0 +1,64 @@ +/** + * @file + * Stats count-up behaviour. + * + * Animates `.dr-stat-count` elements from 0 to their `data-target` value when + * they scroll into view, using an easeOut curve. Without JavaScript or an + * IntersectionObserver the element keeps its server-rendered target value. + * + * Expects: 42 + */ +((Drupal, once) => { + const easeOut = (t) => 1 - (1 - t) ** 3; + + const countUp = (el, target, duration) => { + let start = null; + + const step = (timestamp) => { + if (start === null) { + start = timestamp; + } + + const progress = Math.min((timestamp - start) / duration, 1); + el.textContent = String(Math.round(easeOut(progress) * target)); + + if (progress < 1) { + window.requestAnimationFrame(step); + } else { + el.textContent = String(target); + } + }; + + window.requestAnimationFrame(step); + }; + + Drupal.behaviors.drevopsStatsCounter = { + attach(context) { + const elements = once('dr-stat-count', '.dr-stat-count', context); + + if (!elements.length || !('IntersectionObserver' in window)) { + return; + } + + const observer = new IntersectionObserver( + (entries, obs) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + + const el = entry.target; + const target = parseInt(el.dataset.target, 10) || 0; + const duration = target === 0 ? 600 : target < 20 ? 1200 : 2000; + + countUp(el, target, duration); + obs.unobserve(el); + }); + }, + { threshold: 0.4 }, + ); + + elements.forEach((el) => observer.observe(el)); + }, + }; +})(Drupal, once); diff --git a/web/themes/custom/drevops/components/variables.base.scss b/web/themes/custom/drevops/components/variables.base.scss index 222bd98c..33fce3e5 100644 --- a/web/themes/custom/drevops/components/variables.base.scss +++ b/web/themes/custom/drevops/components/variables.base.scss @@ -7,145 +7,158 @@ // Variables values in this file cannot be based on any variables // in _variables.base.scss // +// These values encode the DrevOps dark design system: a deep navy canvas, +// cyan interactive accent, and coral highlight, with Plus Jakarta Sans for +// display/headings and Outfit for body text. The site renders dark by default; +// the light theme is kept consistent for any light-themed surfaces. +// // stylelint-disable scss/dollar-variable-pattern -// Example to override existing CivicTheme's palette color and define -// custom colors, which will automatically appear in Storybook. - +// Brand colours per theme. CivicTheme derives the semantic palette (headings, +// backgrounds, borders, interactions, highlight) from these three. +// brand1 - interactive accent (links, buttons) and text tints. +// brand2 - canvas / surface backgrounds and borders. +// brand3 - highlight / accent. $ct-colors-brands: ( 'light': ( - 'brand1': #00698f, - 'brand2': #e6e9eb, - 'brand3': #121313, + 'brand1': #1e7582, + 'brand2': #e9eef6, + 'brand3': #cd5b43, ), 'dark': ( - 'brand1': #61daff, - 'brand2': #003a4f, - 'brand3': #00698f, + 'brand1': #96e7f4, + 'brand2': #152235, + 'brand3': #ff9c86, ) ); -// Example to override existing CivicTheme's palette color variants. +// Exact semantic overrides to match the design tokens. Unspecified keys fall +// back to values derived from the brand colours above. $ct-colors: ( - // Override palette color variant. 'light': ( - 'success': green, - 'custom1': brown, + 'heading': #152235, + 'body': #2b394d, + 'background-light': #f4f7fc, + 'background': #ffffff, + 'background-dark': #e9eef6, + 'highlight': #cd5b43, ), 'dark': ( - 'custom1': red, - 'custom2': orange, - 'custom3': blue, + 'heading': #ffffff, + 'body': #ffffff, + 'background-light': #2b394d, + 'background': #152235, + 'background-dark': #0f1a29, + 'border-light': #3a4a60, + 'border': #2b394d, + 'border-dark': #0f1a29, + 'interaction-text': #152235, + 'interaction-background': #96e7f4, + 'interaction-hover-text': #152235, + 'interaction-hover-background': #e1fbff, + 'highlight': #ff9c86, ), ); -// Example to define 2 custom local fonts which will automatically appear -// in Storybook. +// Display/headings use Plus Jakarta Sans (primary); body uses Outfit +// (secondary). Weights map to the keys defined in $ct-font-weights-default. $ct-fonts: ( - 'secondary': ( - 'family': 'Rubik, sans-serif', + 'primary': ( + 'family': '"Plus Jakarta Sans", sans-serif', 'types': ( ( - 'uri': 'https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap', + 'uri': 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap', ), ), ), - 'tertiary': ( - 'family': 'Roboto, sans-serif', - 'types': ( - ( - 'uri': ( - '#{$ct-assets-directory}fonts/Roboto/Roboto-Regular.ttf', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Regular.woff', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Regular.eot', - ), - ), - ( - 'italic': true, - 'uri': ( - '#{$ct-assets-directory}fonts/Roboto/Roboto-Italic.ttf', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Italic.woff', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Italic.eot', - ), - ), - ( - 'weight': 'bold', - 'uri': ( - '#{$ct-assets-directory}fonts/Roboto/Roboto-Bold.ttf', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Bold.woff', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Bold.eot', - ), - ), - ( - 'italic': true, - 'weight': 'bold', - 'uri': ( - '#{$ct-assets-directory}fonts/Roboto/Roboto-BoldItalic.ttf', - '#{$ct-assets-directory}fonts/Roboto/Roboto-BoldItalic.woff', - '#{$ct-assets-directory}fonts/Roboto/Roboto-BoldItalic.eot', - ), - ), - ( - 'weight': 300, - 'uri': ( - '#{$ct-assets-directory}fonts/Roboto/Roboto-Thin.ttf', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Thin.woff', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Thin.eot', - ), - ), - ( - 'weight': 700, - 'uri': ( - '#{$ct-assets-directory}fonts/Roboto/Roboto-Black.ttf', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Black.woff', - '#{$ct-assets-directory}fonts/Roboto/Roboto-Black.eot', - ), - ), - ), - ), - 'quartary': ( - 'family': '"Dancing Script", serif', + 'secondary': ( + 'family': '"Outfit", sans-serif', 'types': ( ( - 'uri': ( - '#{$ct-assets-directory}fonts/DancingScript/DancingScript-Regular.ttf', - '#{$ct-assets-directory}fonts/DancingScript/DancingScript-Regular.woff', - '#{$ct-assets-directory}fonts/DancingScript/DancingScript-Regular.woff2', - ), + 'uri': 'https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400;500;600&display=swap', ), ), ), ); + $ct-particle: 8px; // Base font sizes defined in CivicTheme base variables. $ct-font-base-size: $ct-particle * 2; $ct-font-base-line-height: $ct-font-base-size; + +// Typography scale. Display and headings use the primary font (Plus Jakarta +// Sans, bold); body and labels use the secondary font (Outfit) at the thin +// weights the design calls for. $ct-typography: ( - 'display-large': ( - 'xxs': ($ct-font-base-size * 3, $ct-font-base-line-height * 3.5, 'bold', 'primary', -0.6px), - 'm': ($ct-font-base-size * 4, $ct-font-base-line-height * 4.75, 'bold', 'primary', -1px) + // Display - hero headlines (further enlarged per-component in the banner). + 'display': ( + 'xxs': ($ct-font-base-size * 2.5, $ct-font-base-line-height * 2.75, 'bold', 'primary', -1px), + 'm': ($ct-font-base-size * 4, $ct-font-base-line-height * 4.25, 'bold', 'primary', -1.5px) + ), + // Headings. + 'heading-1': ( + 'xxs': ($ct-font-base-size * 2, $ct-font-base-line-height * 2.375, 'bold', 'primary', -0.6px), + 'm': ($ct-font-base-size * 3, $ct-font-base-line-height * 3.375, 'bold', 'primary', -1px) + ), + 'heading-2': ( + 'xxs': ($ct-font-base-size * 1.75, $ct-font-base-line-height * 2.125, 'bold', 'primary', -0.5px), + 'm': ($ct-font-base-size * 2.5, $ct-font-base-line-height * 2.875, 'bold', 'primary', -0.8px) + ), + 'heading-3': ( + 'xxs': ($ct-font-base-size * 1.5, $ct-font-base-line-height * 2, 'bold', 'primary', -0.35px), + 'm': ($ct-font-base-size * 2, $ct-font-base-line-height * 2.5, 'bold', 'primary', -0.6px) ), - // Text. + 'heading-4': ( + 'xxs': ($ct-font-base-size * 1.25, $ct-font-base-line-height * 1.5, 'bold', 'primary', -0.25px), + 'm': ($ct-font-base-size * 1.5, $ct-font-base-line-height * 2, 'bold', 'primary', -0.4px) + ), + 'heading-5': ( + 'xxs': ($ct-font-base-size, $ct-font-base-line-height * 1.375, 'bold', 'primary', 0), + 'm': ($ct-font-base-size * 1.25, $ct-font-base-line-height * 1.5, 'bold', 'primary', -0.2px) + ), + 'heading-6': ($ct-font-base-size, $ct-font-base-line-height * 1.625, 'bold', 'primary', 0), + // Body text - Outfit, thin weights. 'text-extra-large': ( - 'xxs': ($ct-font-base-size * 1.25, $ct-font-base-line-height * 1.5, 'regular', 'secondary', -0.1px), - 'm': ($ct-font-base-size * 1.5, $ct-font-base-line-height * 1.5, 'regular', 'secondary', 0) + 'xxs': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.625, 'extralight', 'secondary', -0.1px), + 'm': ($ct-font-base-size * 1.25, $ct-font-base-line-height * 1.75, 'extralight', 'secondary', 0) ), 'text-large': ( - 'xxs': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.75, 'regular', 'secondary', 0), - 'm': ($ct-font-base-size * 1.25, $ct-font-base-line-height * 2.125, 'regular', 'secondary', 0) + 'xxs': ($ct-font-base-size * 1.0625, $ct-font-base-line-height * 1.625, 'extralight', 'secondary', 0), + 'm': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.75, 'extralight', 'secondary', 0) ), 'text-regular': ( - 'xxs': ($ct-font-base-size, $ct-font-base-line-height * 1.75, 'regular', 'secondary', 0), - 'm': ($ct-font-base-size, $ct-font-base-line-height * 1.75, 'regular', 'secondary', 0) + 'xxs': ($ct-font-base-size, $ct-font-base-line-height * 1.5, 'extralight', 'secondary', 0), + 'm': ($ct-font-base-size, $ct-font-base-line-height * 1.625, 'extralight', 'secondary', 0) ), 'text-small': ( - 'xxs': ($ct-font-base-size * 0.75, $ct-font-base-line-height * 1.25, 'regular', 'secondary', 0), + 'xxs': ($ct-font-base-size * 0.875, $ct-font-base-line-height * 1.25, 'regular', 'secondary', 0), 'm': ($ct-font-base-size * 0.875, $ct-font-base-line-height * 1.5, 'regular', 'secondary', 0), ), + // Labels - eyebrow/section labels, uppercase with wide tracking. + 'label-extra-large': ( + 'xxs': ($ct-font-base-size * 1.25, $ct-font-base-line-height * 1.5, 'semibold', 'primary', 0), + 'm': ($ct-font-base-size * 1.5, $ct-font-base-line-height * 1.5, 'semibold', 'primary', 0) + ), + 'label-large': ( + 'xxs': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.5, 'semibold', 'primary', 0), + 'm': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.5, 'semibold', 'primary', 0) + ), + 'label-regular': ( + 'xxs': ($ct-font-base-size * 0.875, $ct-font-base-line-height * 1.25, 'semibold', 'primary', 0.5px), + 'm': ($ct-font-base-size * 0.875, $ct-font-base-line-height * 1.25, 'semibold', 'primary', 0.5px) + ), + 'label-small': ( + 'xxs': ($ct-font-base-size * 0.6875, $ct-font-base-line-height * 1.25, 'semibold', 'primary', 1.5px), + 'm': ($ct-font-base-size * 0.6875, $ct-font-base-line-height * 1.25, 'semibold', 'primary', 1.5px) + ), + 'label-extra-small': ( + 'xxs': ($ct-font-base-size * 0.6875, $ct-font-base-line-height * 1.125, 'semibold', 'primary', 1.5px), + 'm': ($ct-font-base-size * 0.6875, $ct-font-base-line-height * 1.125, 'semibold', 'primary', 1.5px) + ), // Special elements. 'quote': ( - 'xxs': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.815, 'regular', 'secondary', -0.15px), - 'm': ($ct-font-base-size * 1.5, $ct-font-base-size * 2.5, 'regular', 'secondary', -0.35px) + 'xxs': ($ct-font-base-size * 1.125, $ct-font-base-line-height * 1.815, 'extralight', 'secondary', -0.15px), + 'm': ($ct-font-base-size * 1.5, $ct-font-base-size * 2.5, 'extralight', 'secondary', -0.35px) ), ); diff --git a/web/themes/custom/drevops/components/variables.components.scss b/web/themes/custom/drevops/components/variables.components.scss index 9c2b06e8..73eb0890 100644 --- a/web/themes/custom/drevops/components/variables.components.scss +++ b/web/themes/custom/drevops/components/variables.components.scss @@ -66,9 +66,8 @@ $ct-icon-sizes: ( ), ); -// Example to override component's variable - Back to Top should have a -// 'custom1' colour background. -$ct-back-to-top-light-background-color: ct-color-light('custom1'); +// Back to Top button background uses the highlight (accent) colour. +$ct-back-to-top-light-background-color: ct-color-light('highlight'); // // Banner. diff --git a/web/themes/custom/drevops/drevops.info.yml b/web/themes/custom/drevops/drevops.info.yml index a65573f1..fbd9598c 100644 --- a/web/themes/custom/drevops/drevops.info.yml +++ b/web/themes/custom/drevops/drevops.info.yml @@ -34,6 +34,10 @@ regions: features: - favicon +# Attach redesign behaviours (reveal-on-scroll, stats count-up) on every page. +libraries: + - drevops/redesign + # Override libraries to point to the sub-theme assets. # @note It is advised to preserve civictheme/* libraries overrides in sub-themes # to allow CivicTheme to additionally process libraries on behalf of sub-themes. diff --git a/web/themes/custom/drevops/drevops.libraries.yml b/web/themes/custom/drevops/drevops.libraries.yml index b35160c7..3bb4c9c5 100644 --- a/web/themes/custom/drevops/drevops.libraries.yml +++ b/web/themes/custom/drevops/drevops.libraries.yml @@ -15,3 +15,13 @@ css-variables: css: theme: dist/styles.variables.css: { preprocess: false, weight: 10 } + +# Redesign behaviours: reveal-on-scroll and stats count-up. Authored JS (not +# part of the SCSS/dist build), attached globally via the theme info file. +redesign: + js: + assets/js/reveal.js: {} + assets/js/stats-counter.js: {} + dependencies: + - core/drupal + - core/once diff --git a/web/themes/custom/drevops/includes/page.inc b/web/themes/custom/drevops/includes/page.inc index 6a3c8e9f..9b8551a6 100644 --- a/web/themes/custom/drevops/includes/page.inc +++ b/web/themes/custom/drevops/includes/page.inc @@ -12,4 +12,11 @@ declare(strict_types=1); */ function _drevops_preprocess_page(array &$variables): void { $variables['header_is_sticky'] = civictheme_get_theme_config_manager()->load('components.header.is_sticky', FALSE); + + // The redesign renders the whole site dark: force the page, header and + // footer themes so the canvas, chrome and every region read dark regardless + // of per-node banner settings. + $variables['theme'] = 'dark'; + $variables['header_theme'] = 'dark'; + $variables['footer_theme'] = 'dark'; } From fe9b64019b3d239f92be032f82540397e73babd1 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 14:42:04 +1000 Subject: [PATCH 02/68] Added minimal dark footer override with copyright and contact email. --- .../03-organisms/footer/footer.component.yml | 50 +++++++++++++++++++ .../03-organisms/footer/footer.scss | 48 ++++++++++++++++++ .../03-organisms/footer/footer.twig | 27 ++++++++++ .../components/04-templates/page/page.twig | 2 + web/themes/custom/drevops/includes/page.inc | 4 ++ 5 files changed, 131 insertions(+) create mode 100644 web/themes/custom/drevops/components/03-organisms/footer/footer.component.yml create mode 100644 web/themes/custom/drevops/components/03-organisms/footer/footer.scss create mode 100644 web/themes/custom/drevops/components/03-organisms/footer/footer.twig diff --git a/web/themes/custom/drevops/components/03-organisms/footer/footer.component.yml b/web/themes/custom/drevops/components/03-organisms/footer/footer.component.yml new file mode 100644 index 00000000..f9a7adf6 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/footer/footer.component.yml @@ -0,0 +1,50 @@ +$schema: https://git.drupalcode.org/project/drupal/-/raw/HEAD/core/assets/schemas/v1/metadata.schema.json +name: Footer +status: stable +replaces: civictheme:footer +description: Minimal site footer - copyright and a single contact link. +props: + type: object + properties: + theme: + type: string + title: Theme + description: Theme variation (light or dark). + enum: + - light + - dark + site_name: + type: string + title: Site name + description: Site name shown in the copyright line. + email: + type: string + title: Contact email + description: Contact email rendered as a mailto link. + attributes: + type: Drupal\Core\Template\Attribute + title: Attributes + description: Additional HTML attributes. + modifier_class: + type: string + title: Modifier Class + description: Additional CSS classes. +slots: + content_top1: + title: Content Top 1 + content_top2: + title: Content Top 2 + content_middle1: + title: Content Middle 1 + content_middle2: + title: Content Middle 2 + content_middle3: + title: Content Middle 3 + content_middle4: + title: Content Middle 4 + content_middle5: + title: Content Middle 5 + content_bottom1: + title: Content Bottom 1 + content_bottom2: + title: Content Bottom 2 diff --git a/web/themes/custom/drevops/components/03-organisms/footer/footer.scss b/web/themes/custom/drevops/components/03-organisms/footer/footer.scss new file mode 100644 index 00000000..d27d76b1 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/footer/footer.scss @@ -0,0 +1,48 @@ +// +// Minimal footer override styles. +// +// Single-line bar: copyright left, contact email right. +// + +.ct-footer { + $root: &; + + background-color: ct-color-dark('background-light'); + border-top: ct-particle(0.125) solid color-mix(in srgb, #{ct-color-dark('interaction-background')} 18%, transparent); + + &__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: ct-spacing(2); + padding-top: ct-spacing(3); + padding-bottom: ct-spacing(3); + } + + &__copy { + @include ct-typography('text-small'); + + color: color-mix(in srgb, #{ct-color-dark('body')} 60%, transparent); + letter-spacing: 0.08em; + } + + &__contact { + @include ct-typography('text-small'); + + color: ct-color-dark('interaction-background'); + letter-spacing: 0.08em; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + @media (max-width: 767px) { + &__bar { + flex-direction: column; + gap: ct-spacing(1); + text-align: center; + } + } +} diff --git a/web/themes/custom/drevops/components/03-organisms/footer/footer.twig b/web/themes/custom/drevops/components/03-organisms/footer/footer.twig new file mode 100644 index 00000000..e0b36e62 --- /dev/null +++ b/web/themes/custom/drevops/components/03-organisms/footer/footer.twig @@ -0,0 +1,27 @@ +{# +/** + * @file + * Minimal footer override. + * + * The redesign uses a single-line footer: copyright on the left, a contact + * email on the right. The CivicTheme footer region slots are intentionally not + * rendered. + * + * Props: + * - theme: [string] Theme variation (light or dark). + * - site_name: [string] Site name for the copyright line. + * - email: [string] Contact email rendered as a mailto link. + * - modifier_class: [string] Additional CSS classes. + */ +#} +{% set theme_class = 'ct-theme-%s'|format(theme|default('dark')) %} +{% set modifier_class = '%s %s'|format(theme_class, modifier_class|default('')) %} + +
+ +
diff --git a/web/themes/custom/drevops/components/04-templates/page/page.twig b/web/themes/custom/drevops/components/04-templates/page/page.twig index ea261ace..df0ad9d5 100644 --- a/web/themes/custom/drevops/components/04-templates/page/page.twig +++ b/web/themes/custom/drevops/components/04-templates/page/page.twig @@ -124,6 +124,8 @@ {# Custom: Use include() instead of {% include only %}. #} {{ include('civictheme:footer', { theme: footer_theme, + site_name: site_name, + email: contact_email, background_image: footer_background_image, content_top1: footer_top_1, content_top2: footer_top_2, diff --git a/web/themes/custom/drevops/includes/page.inc b/web/themes/custom/drevops/includes/page.inc index 9b8551a6..f5bba3e1 100644 --- a/web/themes/custom/drevops/includes/page.inc +++ b/web/themes/custom/drevops/includes/page.inc @@ -19,4 +19,8 @@ function _drevops_preprocess_page(array &$variables): void { $variables['theme'] = 'dark'; $variables['header_theme'] = 'dark'; $variables['footer_theme'] = 'dark'; + + // Minimal footer: copyright (site name) and a single public contact email. + $variables['site_name'] = \Drupal::config('system.site')->get('name'); + $variables['contact_email'] = 'info@drevops.com'; } From 235b7c796a501403624b35ffabc85132a384a626 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 14:48:36 +1000 Subject: [PATCH 03/68] Rendered the front-page banner as a full-viewport intro hero. --- .../03-organisms/banner/banner.scss | 71 +++++++++++++++++++ web/themes/custom/drevops/includes/banner.inc | 6 ++ 2 files changed, 77 insertions(+) diff --git a/web/themes/custom/drevops/components/03-organisms/banner/banner.scss b/web/themes/custom/drevops/components/03-organisms/banner/banner.scss index 6d4bd2ce..8ee21903 100644 --- a/web/themes/custom/drevops/components/03-organisms/banner/banner.scss +++ b/web/themes/custom/drevops/components/03-organisms/banner/banner.scss @@ -168,4 +168,75 @@ @include ct-typography('display-large'); } } + + // Custom: Homepage hero treatment for the intro banner - full-viewport dark + // canvas with an atmospheric glow, eyebrow, oversized display headline, + // subtitle and a scroll cue, reproducing the redesign hero. + &.ct-banner-type--intro.ct-banner--hero { + #{$root}__inner { + position: relative; + min-height: 100vh; + flex-direction: column; + justify-content: center; + overflow: hidden; + padding-top: ct-spacing(20); + padding-bottom: ct-spacing(12); + } + + // Atmospheric glow. + #{$root}__inner::before { + content: ''; + position: absolute; + top: 45%; + left: 50%; + width: min(800px, 90vw); + height: 600px; + transform: translate(-50%, -50%); + background: radial-gradient(ellipse at center, color-mix(in srgb, #{ct-color-dark('interaction-background')} 14%, transparent) 0%, color-mix(in srgb, #{ct-color-dark('interaction-background')} 4%, transparent) 40%, transparent 70%); + pointer-events: none; + } + + // Vignette. + #{$root}__inner::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 40%, color-mix(in srgb, #{ct-color-dark('background')} 60%, transparent) 100%); + pointer-events: none; + } + + .container { + position: relative; + z-index: 1; + max-width: ct-particle(115); + } + + #{$root}__site-section { + @include ct-typography('label-regular'); + + margin-bottom: ct-spacing(3); + color: ct-color-dark('interaction-background'); + text-transform: uppercase; + letter-spacing: 0.2em; + } + + #{$root}__title { + margin-bottom: ct-spacing(3); + font-size: clamp(ct-particle(7), 8vw, ct-particle(11.5)); + line-height: 1.05; + } + + #{$root}__content { + max-width: ct-particle(90); + margin: 0 auto; + color: color-mix(in srgb, #{ct-color-dark('body')} 75%, transparent); + + @include ct-typography('text-large'); + } + } +} + +// Accent word inside the hero headline. +.ct-banner__title .dr-word-accent { + color: ct-color-dark('highlight'); } diff --git a/web/themes/custom/drevops/includes/banner.inc b/web/themes/custom/drevops/includes/banner.inc index f64c02c6..a03aa71e 100644 --- a/web/themes/custom/drevops/includes/banner.inc +++ b/web/themes/custom/drevops/includes/banner.inc @@ -37,6 +37,12 @@ function _drevops_preprocess_block__civictheme_banner(array &$variables): void { $variables['type'] = $type; + // Homepage hero: render the front-page banner as a full-viewport intro hero. + if (\Drupal::service('path.matcher')->isFrontPage()) { + $variables['type'] = 'intro'; + $variables['modifier_class'] = trim(($variables['modifier_class'] ?? '') . ' ct-banner--hero'); + } + // Adds extra top padding for the banner when additional spacing is needed. $variables['with_offset'] = civictheme_get_theme_config_manager()->load('components.header.is_sticky', FALSE); From e8cae797ae5dab96cd880549bf244ee2b27aff69 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 15:21:17 +1000 Subject: [PATCH 04/68] Built homepage sections via deploy hook with the redesign markup. --- config/default/filter.format.full_html.yml | 2 +- .../do_base/content/blog/demo-article.html | 216 +++ .../custom/do_base/content/contact/info.html | 41 + .../do_base/content/homepage/01-services.html | 36 + .../do_base/content/homepage/02-stats.html | 31 + .../do_base/content/homepage/03-trust.html | 30 + .../do_base/content/homepage/04-why.html | 37 + .../do_base/content/homepage/05-process.html | 33 + .../do_base/content/homepage/06-contact.html | 11 + .../do_base/content/services/01-detail.html | 107 ++ .../do_base/content/services/02-approach.html | 27 + .../do_base/content/services/03-cta.html | 9 + web/modules/custom/do_base/do_base.deploy.php | 92 + web/themes/custom/drevops/assets/js/reveal.js | 15 +- .../custom/drevops/assets/js/stats-counter.js | 6 +- .../assets/sass/redesign/_buttons.scss | 75 + .../assets/sass/redesign/_components.scss | 1712 +++++++++++++++++ .../drevops/assets/sass/redesign/_extra.scss | 21 + .../drevops/assets/sass/redesign/_tokens.scss | 306 +++ .../custom/drevops/assets/sass/theme.scss | 4 + 20 files changed, 2799 insertions(+), 12 deletions(-) create mode 100644 web/modules/custom/do_base/content/blog/demo-article.html create mode 100644 web/modules/custom/do_base/content/contact/info.html create mode 100644 web/modules/custom/do_base/content/homepage/01-services.html create mode 100644 web/modules/custom/do_base/content/homepage/02-stats.html create mode 100644 web/modules/custom/do_base/content/homepage/03-trust.html create mode 100644 web/modules/custom/do_base/content/homepage/04-why.html create mode 100644 web/modules/custom/do_base/content/homepage/05-process.html create mode 100644 web/modules/custom/do_base/content/homepage/06-contact.html create mode 100644 web/modules/custom/do_base/content/services/01-detail.html create mode 100644 web/modules/custom/do_base/content/services/02-approach.html create mode 100644 web/modules/custom/do_base/content/services/03-cta.html create mode 100644 web/themes/custom/drevops/assets/sass/redesign/_buttons.scss create mode 100644 web/themes/custom/drevops/assets/sass/redesign/_components.scss create mode 100644 web/themes/custom/drevops/assets/sass/redesign/_extra.scss create mode 100644 web/themes/custom/drevops/assets/sass/redesign/_tokens.scss diff --git a/config/default/filter.format.full_html.yml b/config/default/filter.format.full_html.yml index 3ab6576b..6fd6838a 100644 --- a/config/default/filter.format.full_html.yml +++ b/config/default/filter.format.full_html.yml @@ -1,6 +1,6 @@ uuid: bdc967bd-9946-4d42-9190-b40e0b26dea9 langcode: en -status: false +status: true dependencies: module: - editor diff --git a/web/modules/custom/do_base/content/blog/demo-article.html b/web/modules/custom/do_base/content/blog/demo-article.html new file mode 100644 index 00000000..136e1e8f --- /dev/null +++ b/web/modules/custom/do_base/content/blog/demo-article.html @@ -0,0 +1,216 @@ +

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

+ +

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

+ +

The usual suspects

+ +

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

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

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

+ +

1. Stop pulling full Docker images on every build

+ +

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

+ +

The fix: pre-built CI images

+ +

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

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

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

+ +

2. Cache Composer dependencies properly

+ +

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

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

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

+
+ +

3. Sanitise and slim your test database

+ +

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

+ +

What you actually need for CI:

+ +
    +
  • Schema and configuration (usually under 5MB)
  • +
  • A small set of representative content nodes
  • +
  • User accounts for test roles (admin, editor, anonymous)
  • +
  • Taxonomy terms and menu structures
  • +
+ +

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

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

4. Parallelise your test suite

+ +

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

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

5. Integrate S3 for asset storage

+ +

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

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

The compound effect

+ +

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

+ +
+

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

+
+ +

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

+ +
+ Get a free CI review +
diff --git a/web/modules/custom/do_base/content/contact/info.html b/web/modules/custom/do_base/content/contact/info.html new file mode 100644 index 00000000..efe73c92 --- /dev/null +++ b/web/modules/custom/do_base/content/contact/info.html @@ -0,0 +1,41 @@ +
+ +
+

Email us directly

+ info@drevops.com +

We typically respond within one business day.

+
+ +
+

Call us

+ 04 3009 3538 +

Available weekdays, Melbourne time (AEST).

+
+ +
+

Based in

+

Melbourne, Australia

+

We work with organisations across Australia and New Zealand.

+
+ +
+ +
+

What to expect

+
+
+ 1 +

We'll review your message and respond within 24 hours.

+
+
+ 2 +

A 30-minute call to understand your platform and goals.

+
+
+ 3 +

A clear proposal with flat-rate pricing — no surprises.

+
+
+
+ +
diff --git a/web/modules/custom/do_base/content/homepage/01-services.html b/web/modules/custom/do_base/content/homepage/01-services.html new file mode 100644 index 00000000..d065bab5 --- /dev/null +++ b/web/modules/custom/do_base/content/homepage/01-services.html @@ -0,0 +1,36 @@ +
+
+ +
+ +
+ 01 +
+

Website Delivery

+

Full Drupal website builds delivered with automated testing, CI/CD pipelines, and production-ready infrastructure. Your team gets a solid platform, not a prototype that needs fixing after launch.

+
+
+ +
+ +
+ 02 +
+

Ongoing Support

+

Proactive platform maintenance from the same senior engineers who built it. Security updates, monitoring, continuous improvement, and direct communication with no layers in between.

+
+
+ +
+ +
+ 03 +
+

Upgrades & Migrations

+

Drupal 7 and 9 are end-of-life. We handle the full migration with test coverage and zero-downtime deployments, so your organisation stays compliant and your users stay unaffected.

+
+
+ +
+
+
diff --git a/web/modules/custom/do_base/content/homepage/02-stats.html b/web/modules/custom/do_base/content/homepage/02-stats.html new file mode 100644 index 00000000..c1b969ef --- /dev/null +++ b/web/modules/custom/do_base/content/homepage/02-stats.html @@ -0,0 +1,31 @@ +
+
+ +
+
+
+ 0 +
+

Juniors on your project

+
+
+
+ 0 +
+

Excuses when something breaks

+
+
+
+ 0 day +
+

To set up CI/CD on a new project

+
+
+
+ 0 +
+

Shortcuts in how we deliver

+
+
+
+
diff --git a/web/modules/custom/do_base/content/homepage/03-trust.html b/web/modules/custom/do_base/content/homepage/03-trust.html new file mode 100644 index 00000000..38c1b72b --- /dev/null +++ b/web/modules/custom/do_base/content/homepage/03-trust.html @@ -0,0 +1,30 @@ +
+
+ +
+

Trusted on projects where
failure isn't an option.

+
+
+
+
+

Victorian Government

+

Delivered Australia's first Docker-based government Drupal platform.

+
+
+
+

Australian Defence

+

Multiple classified platforms with complex security and compliance requirements.

+
+
+
+

GovCMS

+

Drupal platform delivery on Australia's government hosting infrastructure.

+
+
+
+

Education

+

University platforms with ongoing support, leading to internal referrals across departments.

+
+
+
+
diff --git a/web/modules/custom/do_base/content/homepage/04-why.html b/web/modules/custom/do_base/content/homepage/04-why.html new file mode 100644 index 00000000..83f7c7f2 --- /dev/null +++ b/web/modules/custom/do_base/content/homepage/04-why.html @@ -0,0 +1,37 @@ +
+
+
+

No filler. No overhead. Just good engineering.

+
+
+
+
+
+

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.

+
+
+
+
+
+

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.

+
+
+
+
+
+

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.

+
+
+
+
+
+

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.

+
+
+
+
+
diff --git a/web/modules/custom/do_base/content/homepage/05-process.html b/web/modules/custom/do_base/content/homepage/05-process.html new file mode 100644 index 00000000..2f2cb1cc --- /dev/null +++ b/web/modules/custom/do_base/content/homepage/05-process.html @@ -0,0 +1,33 @@ +
+
+ +
+

A clear path from kickoff
to ongoing support.

+
+
+
+ 01 +
+

Discovery

+

We review your website, understand your requirements and constraints, and scope the work. You get a clear proposal with flat-rate pricing before any work begins.

+
+
+
+
+ 02 +
+

Delivery

+

Your site is built with automated testing and CI/CD from the first commit. Regular check-ins, transparent progress reporting, and no surprises at the end.

+
+
+
+
+ 03 +
+

Ongoing support

+

The same senior team that built your site maintains it. Security updates, continuous improvement, and proactive monitoring on a prepaid support arrangement.

+
+
+
+
+
diff --git a/web/modules/custom/do_base/content/homepage/06-contact.html b/web/modules/custom/do_base/content/homepage/06-contact.html new file mode 100644 index 00000000..a5324bdc --- /dev/null +++ b/web/modules/custom/do_base/content/homepage/06-contact.html @@ -0,0 +1,11 @@ +
+
+
+

+ 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.

+ info@drevops.com +
+
+
diff --git a/web/modules/custom/do_base/content/services/01-detail.html b/web/modules/custom/do_base/content/services/01-detail.html new file mode 100644 index 00000000..790b370a --- /dev/null +++ b/web/modules/custom/do_base/content/services/01-detail.html @@ -0,0 +1,107 @@ +
+
+ +
+
+ 01 +
+

Website Delivery

+

From requirements to production in one engagement.

+
+
+
+
+

Full Drupal website builds delivered with automated testing, CI/CD pipelines, and production-ready infrastructure. We handle the architecture, development, theming, and deployment — your team gets a solid platform, not a prototype that needs fixing after launch.

+

Every project ships with a complete test suite, documentation, and a handover that actually works.

+
+
+

What's included

+
    +
  • Technical architecture and planning
  • +
  • Custom module and theme development
  • +
  • Automated testing (PHPUnit, Behat, Cypress)
  • +
  • CI/CD pipeline setup (GitHub Actions, CircleCI)
  • +
  • Content migration and data import
  • +
  • Hosting setup and go-live support
  • +
+
+
+ +
+ +
+
+ 02 +
+

Ongoing Support

+

The same senior team that built it, maintaining it.

+
+
+
+
+

Proactive platform maintenance from the engineers who built your site. Security updates, Drupal core and module patches, performance monitoring, and continuous improvement — all on a predictable prepaid arrangement.

+

No ticket queues, no outsourced support desks. You talk directly to the people who know your codebase.

+
+
+

What's included

+
    +
  • Security patches and Drupal updates
  • +
  • Uptime and performance monitoring
  • +
  • Bug fixes and minor enhancements
  • +
  • Monthly reporting and recommendations
  • +
  • Direct Slack/email access to engineers
  • +
  • Priority response for critical issues
  • +
+
+
+ +
+ +
+
+ 03 +
+

Upgrades & Migrations

+

Move off end-of-life Drupal without breaking anything.

+
+
+
+
+

Drupal 7 and 9 are end-of-life. Drupal 10 follows in December 2026. We handle the full migration with test coverage and zero-downtime deployments, so your organisation stays compliant and your users stay unaffected.

+

We assess your current platform, map out module compatibility, migrate custom code, and deliver an upgraded site with full test coverage.

+
+
+

What's included

+
    +
  • Platform audit and risk assessment
  • +
  • Module compatibility analysis
  • +
  • Custom code migration and refactoring
  • +
  • Data migration and content integrity checks
  • +
  • Automated test suite for the upgraded site
  • +
  • Zero-downtime deployment and rollback plan
  • +
+
+
+ +
+ +
+
diff --git a/web/modules/custom/do_base/content/services/02-approach.html b/web/modules/custom/do_base/content/services/02-approach.html new file mode 100644 index 00000000..17977afe --- /dev/null +++ b/web/modules/custom/do_base/content/services/02-approach.html @@ -0,0 +1,27 @@ +
+
+ +
+
+
+

Senior engineers only

+

No juniors on your project. Every person who touches your code has 10+ years of Drupal experience.

+
+
+
+

Flat-rate pricing

+

We quote a fixed price upfront. No hourly billing surprises, no retainer games, no scope creep charges.

+
+
+
+

Tested by default

+

Every platform ships with automated tests. If it's not tested, it doesn't deploy. No exceptions.

+
+
+
+

Direct communication

+

You talk to the engineers building your site. No project managers relaying messages, no layers in between.

+
+
+
+
diff --git a/web/modules/custom/do_base/content/services/03-cta.html b/web/modules/custom/do_base/content/services/03-cta.html new file mode 100644 index 00000000..a4e10215 --- /dev/null +++ b/web/modules/custom/do_base/content/services/03-cta.html @@ -0,0 +1,9 @@ +
+
+
+

Ready to talk about
your platform?

+

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

+ Get in touch +
+
+
diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 7e107ef5..e9c6ed83 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -10,6 +10,8 @@ declare(strict_types=1); use Drupal\drupal_helpers\Helper; +use Drupal\node\Entity\Node; +use Drupal\paragraphs\Entity\Paragraph; /** * Flip every component paragraph to the dark theme. @@ -35,3 +37,93 @@ function do_base_deploy_components_dark(array &$sandbox): ?string { $paragraph->save(); }); } + +/** + * Rebuild the homepage (node 1) to the redesign. + * + * Sets the hero (banner) copy and replaces the page components with the + * redesign sections, each rendered as a full-width dark content paragraph from + * the markup in this module's content/homepage directory. + */ +function do_base_deploy_homepage(): string { + $node = Node::load(1); + + if (!$node instanceof Node) { + return 'Homepage node (1) not found - skipped.'; + } + + if ($node->hasField('field_c_n_banner_title')) { + $node->set('field_c_n_banner_title', "Your website can't afford to wait."); + } + + if ($node->hasField('field_c_n_summary')) { + $node->set('field_c_n_summary', 'We build and support Drupal websites for government, enterprise, and education. One senior team, predictable costs, tested code, and one point of accountability across your entire platform lifecycle.'); + } + + _do_base_set_components($node, 'homepage'); + $node->save(); + + return 'Homepage rebuilt.'; +} + +/** + * Replace a node's components with full-width content paragraphs from markup. + * + * Existing components are deleted first so the hook is idempotent and does not + * orphan paragraphs on re-run. + * + * @param \Drupal\node\Entity\Node $node + * The node to rebuild. + * @param string $dir + * The content sub-directory under this module's content directory. + */ +function _do_base_set_components(Node $node, string $dir): void { + if (!$node->hasField('field_c_n_components')) { + return; + } + + foreach ($node->get('field_c_n_components')->referencedEntities() as $existing) { + $existing->delete(); + } + + $node->set('field_c_n_components', _do_base_html_paragraphs($dir)); +} + +/** + * Build full-width dark content paragraphs from a content directory's markup. + * + * @param string $dir + * The content sub-directory under this module's content directory. + * + * @return \Drupal\paragraphs\Entity\Paragraph[] + * The created, saved paragraphs in filename order. + */ +function _do_base_html_paragraphs(string $dir): array { + $path = \Drupal::service('extension.list.module')->getPath('do_base') . '/content/' . $dir; + $files = glob($path . '/*.html') ?: []; + sort($files); + + $paragraphs = []; + + foreach ($files as $file) { + $html = file_get_contents($file); + + if ($html === FALSE) { + continue; + } + + $paragraph = Paragraph::create([ + 'type' => 'civictheme_content', + 'field_c_p_theme' => 'dark', + 'field_c_p_content' => [ + 'value' => trim($html), + 'format' => 'full_html', + ], + ]); + $paragraph->save(); + + $paragraphs[] = $paragraph; + } + + return $paragraphs; +} diff --git a/web/themes/custom/drevops/assets/js/reveal.js b/web/themes/custom/drevops/assets/js/reveal.js index 4bf5f40d..8f8c35f4 100644 --- a/web/themes/custom/drevops/assets/js/reveal.js +++ b/web/themes/custom/drevops/assets/js/reveal.js @@ -2,23 +2,22 @@ * @file * Reveal-on-scroll behaviour. * - * Adds an `is-visible` class to any `.dr-reveal` element when it enters the - * viewport. Stagger delays are handled in CSS via `.dr-reveal--d1` ... - * `.dr-reveal--d6`. Elements are only hidden when the `js` class is present on - * the document (added by Drupal), so content remains visible without - * JavaScript. + * Adds a `visible` class to any `.component-reveal` element when it enters the + * viewport. Stagger delays are handled in CSS via `.component-reveal-d1` ... + * `.component-reveal-d6`. Elements are only hidden when the `js` class is on + * the document (added by Drupal), so content stays visible without JavaScript. */ ((Drupal, once) => { Drupal.behaviors.drevopsReveal = { attach(context) { - const elements = once('dr-reveal', '.dr-reveal', context); + const elements = once('dr-reveal', '.component-reveal', context); if (!elements.length) { return; } if (!('IntersectionObserver' in window)) { - elements.forEach((el) => el.classList.add('is-visible')); + elements.forEach((el) => el.classList.add('visible')); return; } @@ -26,7 +25,7 @@ (entries, obs) => { entries.forEach((entry) => { if (entry.isIntersecting) { - entry.target.classList.add('is-visible'); + entry.target.classList.add('visible'); obs.unobserve(entry.target); } }); diff --git a/web/themes/custom/drevops/assets/js/stats-counter.js b/web/themes/custom/drevops/assets/js/stats-counter.js index 45fa9171..28c1bf79 100644 --- a/web/themes/custom/drevops/assets/js/stats-counter.js +++ b/web/themes/custom/drevops/assets/js/stats-counter.js @@ -2,11 +2,11 @@ * @file * Stats count-up behaviour. * - * Animates `.dr-stat-count` elements from 0 to their `data-target` value when + * Animates `.stat-count` elements from 0 to their `data-target` value when * they scroll into view, using an easeOut curve. Without JavaScript or an * IntersectionObserver the element keeps its server-rendered target value. * - * Expects: 42 + * Expects: 42 */ ((Drupal, once) => { const easeOut = (t) => 1 - (1 - t) ** 3; @@ -34,7 +34,7 @@ Drupal.behaviors.drevopsStatsCounter = { attach(context) { - const elements = once('dr-stat-count', '.dr-stat-count', context); + const elements = once('dr-stat-count', '.stat-count', context); if (!elements.length || !('IntersectionObserver' in window)) { return; diff --git a/web/themes/custom/drevops/assets/sass/redesign/_buttons.scss b/web/themes/custom/drevops/assets/sass/redesign/_buttons.scss new file mode 100644 index 00000000..b70bfb2e --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/redesign/_buttons.scss @@ -0,0 +1,75 @@ +// +// DrevOps — Redesign Buttons +// +// Ported from the static design's framework.css. Only the .btn and +// .btn-{primary,secondary,tertiary} rules are included here; bare +// element selectors, resets, and document/print styles are +// intentionally excluded so the rest of the CivicTheme site is +// unaffected. +// + +/* ── Buttons ── */ +.btn { + 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 transparent; + cursor: pointer; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.12em; + position: relative; + overflow: hidden; + transition: color 0.4s ease, border-color 0.4s ease; +} +.btn:hover { text-decoration: none; background: none; } +.btn::before { + content: ''; + position: absolute; + inset: 0; + transform: scaleX(0); + transform-origin: left; + transition: transform 0.4s ease; +} +.btn:hover::before { transform: scaleX(1); } +.btn:disabled, .btn[disabled] { + opacity: 0.4; + cursor: not-allowed; + pointer-events: none; +} + +.btn-primary { + background: var(--btn-primary-bg); + color: var(--btn-primary-color); + border-color: var(--btn-primary-border); +} +.btn-primary::before { background: var(--btn-primary-hover-bg); } +.btn-primary:hover { + color: var(--btn-primary-hover-color); + border-color: var(--btn-primary-color); +} + +.btn-secondary { + background: var(--btn-secondary-bg); + color: var(--btn-secondary-color); + border-color: var(--btn-secondary-border); +} +.btn-secondary::before { background: var(--btn-secondary-hover-bg); } +.btn-secondary:hover { + color: var(--btn-secondary-hover-color); + border-color: var(--btn-secondary-color); +} + +.btn-tertiary { + background: var(--btn-tertiary-bg); + color: var(--btn-tertiary-color); + border-color: var(--btn-tertiary-border); +} +.btn-tertiary::before { background: var(--btn-tertiary-hover-bg); } +.btn-tertiary:hover { + color: var(--btn-tertiary-hover-color); + border-color: var(--btn-tertiary-color); +} diff --git a/web/themes/custom/drevops/assets/sass/redesign/_components.scss b/web/themes/custom/drevops/assets/sass/redesign/_components.scss new file mode 100644 index 00000000..d57d0c48 --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/redesign/_components.scss @@ -0,0 +1,1712 @@ +// +// DrevOps — Redesign Components +// +// Every component from the static design's components/*.css, ported +// verbatim. All selectors are scoped to .component-* classes, so they +// are safe to include globally. Selectors, media queries, keyframes, +// color-mix(), clamp(), etc. are preserved exactly as authored. +// + +/* + * DrevOps — Hero Component + */ + +/* Base hero — full viewport height with glow */ +.component-hero { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + background: var(--page-bg); + overflow: hidden; + padding: calc(var(--space-y-10) * 1.75) var(--space-x-8) var(--space-y-10); +} + +/* Atmospheric teal glow */ +.component-hero::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -55%); + width: 800px; + height: 600px; + background: radial-gradient(ellipse at center, color-mix(in srgb, var(--color-secondary-6) 12%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 4%, transparent) 40%, transparent 70%); + pointer-events: none; + animation: heroGlow 8s ease-in-out infinite; +} + +/* Deep vignette (homepage only — applied via modifier) */ +.component-hero--home::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 40%, color-mix(in srgb, var(--page-bg) 60%, transparent) 100%); + pointer-events: none; +} + +/* Shorter hero for inner pages */ +.component-hero--inner { + min-height: 70vh; + padding: calc(var(--space-y-10) * 2) var(--space-x-8) var(--space-y-10); +} + +.component-hero--inner::before { + background: radial-gradient(ellipse at center, color-mix(in srgb, var(--color-secondary-6) 10%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 3%, transparent) 40%, transparent 70%); +} + +/* Blog/contact hero variant — centered text, no min-height */ +.component-hero--page { + min-height: auto; + padding: 200px 0 var(--space-y-10); + text-align: center; +} + +.component-hero--page::before { + width: 900px; + background: radial-gradient(ellipse at center, color-mix(in srgb, var(--color-secondary-6) 8%, transparent) 0%, transparent 65%); + animation: none; +} + +/* Contact hero with darker bg */ +.component-hero--contact { + background: var(--color-primary-8); +} + +.component-hero--contact::before { + background: radial-gradient(ellipse at center, color-mix(in srgb, var(--color-secondary-6) 12%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 3%, transparent) 40%, transparent 70%); + animation: none; +} + +.component-hero-inner { + position: relative; + z-index: 2; + text-align: center; + max-width: 900px; +} + +.component-hero-inner--narrow { + max-width: 700px; + margin: 0 auto; +} + +.component-hero-eyebrow { + font-size: var(--caption-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--color-secondary-6); + margin-bottom: var(--space-y-4); + opacity: 0; + transform: translateY(20px); + animation: fadeUp 0.8s ease forwards 0.2s; +} + +.component-hero-subtitle { + font-size: var(--heading-h4-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + letter-spacing: 0.02em; + margin-bottom: var(--space-y-7); + opacity: 0; + transform: translateY(20px); + animation: fadeUp 0.8s ease forwards 1.2s; +} + +.component-hero-cta { + margin-top: 2em; + opacity: 0; + transform: translateY(20px); + animation: fadeUp 0.8s ease forwards 1.5s; +} + +.component-hero-rule { + position: absolute; + bottom: calc(var(--space-y-10) * 1.75); + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-secondary-6) 40%, transparent) 20%, color-mix(in srgb, var(--color-secondary-6) 60%, transparent) 50%, color-mix(in srgb, var(--color-secondary-6) 40%, transparent) 80%, transparent 100%); + opacity: 0; + animation: fadeIn 1s ease forwards 2s; +} + +.component-scroll-indicator { + position: absolute; + bottom: var(--space-y-5); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-y-1); + opacity: 0; + animation: fadeIn 1s ease forwards 2.2s; +} + +.component-scroll-line { + width: 1px; + height: var(--space-y-6); + background: linear-gradient(to bottom, var(--color-secondary-6), transparent); + animation: scrollPulse 2s ease-in-out infinite; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-hero { + padding: calc(var(--space-y-10) * 1.25) var(--space-y-3) var(--space-x-10); + } + + .component-hero--inner { + padding: calc(var(--space-y-10) * 1.75) var(--space-y-3) var(--space-x-8); + min-height: 50vh; + } + + .component-hero--page { + padding: calc(var(--space-y-10) * 1.75) 0 var(--space-y-5); + } + + .component-hero--contact { + padding: calc(var(--space-y-10) * 1.75) 0 var(--space-x-8); + } +} + +/* + * DrevOps — Display Headings & Text Decorations + */ + +/* Display 1 — largest heading (hero, CTA sections) */ +.component-display-1 { + font-family: var(--font-family-primary); + font-size: var(--display-1-font-size); + font-weight: var(--font-weight-bold); + line-height: var(--display-line-height); + letter-spacing: -0.02em; + color: var(--text-color); + opacity: 0; + transform: translateY(30px); + animation: fadeUp 0.8s ease forwards 0.4s; +} + +/* Display 1 without animation (for CTA sections) */ +.component-display-1--static { + opacity: 1; + transform: none; + animation: none; + font-weight: var(--font-weight-normal); + line-height: var(--display-line-height); + margin-bottom: var(--space-y-6); +} + +/* Display 2 — section headings */ +.component-display-2 { + font-family: var(--font-family-primary); + font-size: var(--display-2-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + letter-spacing: -0.02em; + line-height: var(--display-line-height); +} + +/* Accent-coloured word (coral) */ +.component-word-accent { + color: var(--text-accent-color); +} + +/* Gradient underline decoration */ +.component-underlined { + position: relative; + display: inline-block; +} + +.component-underlined::after { + content: ''; + position: absolute; + bottom: 2px; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, var(--color-secondary-6), var(--link-hover-color)); +} + +/* + * DrevOps — Label Component + * + * Unified small-caps label pattern used across the site. + */ + +/* Base label — muted colour */ +.component-label { + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + letter-spacing: var(--label-letter-spacing); + text-transform: var(--label-text-transform); + color: var(--text-muted-color); +} + +/* Accent variant */ +.component-label--accent { + color: var(--text-accent-color); +} + +/* Subtle variant */ +.component-label--subtle { + color: var(--text-subtle-color); +} + +/* Section label with line prefix */ +.component-section-label { + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + letter-spacing: var(--label-letter-spacing); + text-transform: var(--label-text-transform); + color: var(--text-accent-color); + display: flex; + align-items: center; + gap: var(--space-x-2); + margin-bottom: var(--space-y-8); +} + +.component-section-label::before { + content: ''; + display: block; + width: var(--space-x-6); + height: 1px; + background: currentColor; + opacity: 0.6; +} + +.component-section-label--compact { + margin-bottom: var(--space-y-4); +} + +/* + * DrevOps — Stat Grid Component + */ + +.component-stat { + background: var(--surface-subtle-color); + padding: calc(var(--space-y-10) * 1.75) 0; + position: relative; + overflow: hidden; +} + +.component-stat::before { + content: ''; + position: absolute; + bottom: -200px; + right: -200px; + width: 600px; + height: 600px; + background: radial-gradient(ellipse, color-mix(in srgb, var(--color-secondary-6) 7%, transparent) 0%, transparent 65%); + pointer-events: none; +} + +.component-stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--border-color); + position: relative; + z-index: 2; +} + +.component-stat-cell { + padding: var(--space-y-9) var(--space-x-7); + position: relative; +} + +.component-stat-cell:nth-child(1) { border-right: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color); } +.component-stat-cell:nth-child(2) { border-bottom: 1px solid var(--border-color); } +.component-stat-cell:nth-child(3) { border-right: 1px solid var(--border-color); } + +.component-stat-number { + font-family: var(--font-family-primary); + font-size: clamp(72px, 8vw, 108px); + font-weight: var(--font-weight-bold); + color: var(--color-secondary-6); + line-height: 1; + letter-spacing: -0.03em; + margin-bottom: var(--space-y-2); + display: flex; + align-items: baseline; + gap: 4px; +} + +.component-stat-suffix { + font-size: 0.45em; + font-weight: var(--font-weight-normal); + opacity: 0.8; + letter-spacing: 0; +} + +.component-stat-label { + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-subtle-color); +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-stat { + padding: calc(var(--space-y-10) * 1.25) 0; + } + + .component-stat-grid { + grid-template-columns: 1fr; + } + + .component-stat-cell:nth-child(1), + .component-stat-cell:nth-child(2), + .component-stat-cell:nth-child(3) { + border-right: none; + border-bottom: 1px solid var(--border-color); + } + + .component-stat-cell { + padding: var(--space-y-6) var(--space-x-4); + } +} + +/* + * DrevOps — Trust Grid Component + */ + +.component-trust { + background: var(--page-bg); + padding: calc(var(--space-y-10) * 1.75) 0; + position: relative; +} + +.component-trust-header { + text-align: center; + margin-bottom: var(--space-y-10); +} + +.component-trust-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-y-4); +} + +.component-trust-item { + text-align: center; + padding: var(--space-y-5) var(--space-x-3); + border: 1px solid var(--border-subtle-color); + border-radius: var(--radius-sm); +} + +.component-trust-icon { + color: var(--color-secondary-6); + margin-bottom: var(--space-y-3); +} + +.component-trust-label { + font-size: var(--body-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + margin-bottom: var(--space-y-2); +} + +.component-trust-desc { + font-size: var(--font-size-sm); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-md); +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-trust { + padding: calc(var(--space-y-10) * 1.25) 0; + } + + .component-trust-grid { + grid-template-columns: 1fr 1fr; + gap: var(--space-y-2); + } +} + +/* + * DrevOps — List Component + * + * Unified vertical list used for "Why us", "How we work", + * and any dot+title+description pattern. + */ + +.component-list { + max-width: 680px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 0; +} + +.component-list-item { + display: grid; + grid-template-columns: 40px 1fr; + gap: var(--space-y-3); + align-items: start; + padding: var(--space-y-5) 0; + border-bottom: 1px solid var(--border-subtle-color); +} + +.component-list-item:first-child { + border-top: 1px solid var(--border-subtle-color); +} + +.component-list-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-secondary-6); + margin-top: 10px; + flex-shrink: 0; +} + +.component-list-title { + font-size: var(--heading-h4-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + margin-bottom: var(--space-y-1); + letter-spacing: -0.01em; +} + +.component-list-desc { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); +} + +/* + * DrevOps — Service Component + * + * Compact service list used on homepage and process sections. + */ + +.component-service-list { + display: flex; + flex-direction: column; +} + +.component-service-item { + padding: var(--space-y-8) 0; + display: grid; + grid-template-columns: 120px 1fr; + gap: 0 var(--space-y-6); + align-items: start; + position: relative; +} + +.component-service-divider { + height: 1px; + background: linear-gradient(90deg, color-mix(in srgb, var(--color-secondary-6) 50%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 15%, transparent) 50%, transparent 100%); + margin: 0; +} + +.component-service-number { + font-family: var(--font-family-primary); + font-size: 80px; + font-weight: var(--font-weight-bold); + color: var(--color-secondary-6); + opacity: 0.18; + line-height: 1; + letter-spacing: -0.04em; + user-select: none; +} + +.component-service-body { + /* Semantic wrapper for service item content (title + desc). No additional styles needed. */ +} + +.component-service-title { + font-family: var(--font-family-primary); + font-size: 32px; + font-weight: var(--font-weight-normal); + color: var(--text-color); + letter-spacing: -0.01em; + margin-bottom: var(--space-y-3); + line-height: var(--line-height-sm); +} + +.component-service-desc { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + max-width: 560px; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-service-item { + grid-template-columns: 1fr; + gap: var(--space-y-2); + padding: var(--space-y-6) 0; + } + + .component-service-number { + font-size: 48px; + } +} + +/* + * DrevOps — Service Detail Component + * + * Expanded service detail panels used on the services page. + */ + +.component-service-detail { + background: var(--page-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-y-7); + margin-bottom: var(--space-y-6); +} + +.component-service-detail:last-child { + margin-bottom: 0; +} + +.component-service-detail-header { + display: flex; + align-items: flex-start; + gap: var(--space-y-4); + margin-bottom: var(--space-y-5); + padding-bottom: var(--space-y-4); + border-bottom: 1px solid var(--border-subtle-color); +} + +.component-service-detail-header .component-service-number { + font-size: 64px; + opacity: 0.2; + flex-shrink: 0; +} + +.component-service-detail-title { + font-family: var(--font-family-primary); + font-size: var(--heading-h2-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + letter-spacing: -0.01em; + margin-bottom: var(--space-y-1); +} + +.component-service-detail-tagline { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-muted-color); +} + +.component-service-detail-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-y-6); + margin-bottom: var(--space-y-5); +} + +.component-service-detail-desc p { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + margin-bottom: var(--space-y-2); +} + +.component-service-detail-includes ul { + list-style: none; + padding: 0; +} + +.component-service-detail-includes li { + font-size: var(--font-size-sm); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-md); + padding: var(--space-y-1) 0 var(--space-x-1) var(--space-y-3); + position: relative; + border-bottom: 1px solid var(--border-muted-color); +} + +.component-service-detail-includes li:last-child { + border-bottom: none; +} + +.component-service-detail-includes li::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-secondary-6); + position: absolute; + left: 0; + top: 16px; +} + +.component-service-detail-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: var(--space-y-4); + border-top: 1px solid var(--border-subtle-color); +} + +.component-pricing-label { + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--text-muted-color); + display: block; + margin-bottom: 4px; +} + +.component-pricing-value { + font-family: var(--font-family-primary); + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + color: var(--color-secondary-6); + letter-spacing: -0.01em; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-service-detail { + padding: var(--space-y-4) var(--space-x-3); + } + + .component-service-detail-header { + flex-direction: column; + gap: var(--space-y-2); + } + + .component-service-detail-header .component-service-number { + font-size: 48px; + } + + .component-service-detail-body { + grid-template-columns: 1fr; + gap: var(--space-y-4); + } + + .component-service-detail-footer { + flex-direction: column; + gap: var(--space-y-3); + align-items: flex-start; + } +} + +/* + * DrevOps — Grid Component + * + * Reusable grid layouts for content sections. + */ + +/* Two-column content grid (approach section) */ +.component-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-y-6) var(--space-y-8); +} + +.component-grid-2-item { + padding: var(--space-y-4) 0; +} + +.component-grid-2-item .component-list-dot { + margin-top: 0; + margin-bottom: var(--space-y-3); +} + +.component-grid-2-item h3 { + font-family: var(--font-family-primary); + font-size: var(--heading-h4-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + margin-bottom: var(--space-y-2); + letter-spacing: -0.01em; +} + +.component-grid-2-item p { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); +} + +/* Two-column contact grid */ +.component-grid-contact { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-y-10); + align-items: start; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-grid-2 { + grid-template-columns: 1fr; + gap: var(--space-y-4); + } + + .component-grid-contact { + grid-template-columns: 1fr; + gap: var(--space-x-8); + } +} + +/* + * DrevOps — CTA Section Component + */ + +.component-cta { + background: var(--cta-bg); + padding: calc(var(--space-y-10) * 2) 0; + text-align: center; + position: relative; + overflow: hidden; +} + +.component-cta::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 700px; + height: 500px; + background: radial-gradient(ellipse at center, color-mix(in srgb, var(--color-secondary-6) 10%, transparent) 0%, color-mix(in srgb, var(--color-secondary-6) 3%, transparent) 50%, transparent 70%); + pointer-events: none; +} + +.component-cta-inner { + position: relative; + z-index: 2; +} + +.component-cta-sub { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + margin-bottom: var(--space-y-7); + letter-spacing: 0.02em; +} + +.component-cta-email { + display: inline-block; + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + color: var(--link-color); + text-decoration: none; + letter-spacing: 0.04em; + position: relative; +} + +.component-cta-email:hover { + background: none; + text-decoration: none; +} + +.component-cta-email::after { + content: ''; + position: absolute; + bottom: -3px; + left: 0; + width: 0; + height: 1px; + background: var(--link-hover-color); + transition: width 0.4s ease; +} + +.component-cta-email:hover::after { + width: 100%; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-cta { + padding: calc(var(--space-y-10) * 1.75) 0; + } +} + +/* + * DrevOps — Tag Component + */ + +.component-tag-list { + display: flex; + flex-wrap: wrap; + gap: var(--space-x-1); +} + +.component-tag { + display: inline-block; + font-size: var(--label-font-size); + font-weight: var(--label-font-weight); + letter-spacing: var(--label-letter-spacing); + text-transform: var(--label-text-transform); + color: var(--tag-color); + background: var(--tag-bg); + padding: var(--space-y-1) var(--space-x-2); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle-color); +} + +.component-tag--sm { + font-size: var(--font-size-2xs); + padding: var(--space-y-1) var(--space-x-1); +} + +/* + * DrevOps — Meta Component + * + * Shared meta line (date, read time) used in blog cards and posts. + */ + +.component-meta { + font-size: var(--label-font-size); + font-weight: var(--font-weight-normal); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted-color); + margin-bottom: var(--space-y-2); + display: flex; + align-items: center; + gap: var(--space-y-1); +} + +.component-meta--lg { + font-size: var(--caption-font-size); + letter-spacing: 0.15em; + margin-bottom: var(--space-y-3); + gap: var(--space-y-2); +} + +.component-meta-sep { + color: var(--color-secondary-6); + opacity: 0.4; +} + +.component-meta--lg .component-meta-sep { + opacity: 0.5; +} + +/* + * DrevOps — Blog Card Component + */ + +/* ── Featured post ── */ +.component-blog-featured { + background: var(--page-bg); + padding: 0 0 var(--space-y-8); +} + +.component-blog-feature { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + text-decoration: none; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + transition: border-color 0.3s ease; +} + +.component-blog-feature:hover { + border-color: var(--color-secondary-6); + background: none; + text-decoration: none; +} + +.component-blog-feature-image { + position: relative; + overflow: hidden; +} + +.component-blog-feature-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.5s ease; +} + +.component-blog-feature:hover .component-blog-feature-image img { + transform: scale(1.03); +} + +.component-blog-feature-content { + padding: var(--space-y-6); + display: flex; + flex-direction: column; + justify-content: center; +} + +.component-blog-feature-content h2 { + font-family: var(--font-family-primary); + font-size: var(--heading-h2-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + letter-spacing: -0.01em; + line-height: var(--line-height-sm); + margin-bottom: var(--space-y-2); +} + +.component-blog-feature-content p { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + margin-bottom: var(--space-y-3); +} + +/* ── Card grid section ── */ +.component-blog-grid-section { + background: var(--surface-muted-color); + padding: var(--space-y-10) 0 calc(var(--space-y-10) * 1.75); +} + +.component-blog-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-y-4); +} + +/* ── Individual card ── */ +.component-blog-card { + display: flex; + flex-direction: column; + text-decoration: none; + background: var(--page-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + transition: border-color 0.3s ease, transform 0.3s ease; +} + +.component-blog-card:hover { + border-color: var(--color-secondary-6); + transform: translateY(-4px); + background: var(--page-bg); + text-decoration: none; +} + +.component-blog-card-image { + position: relative; + overflow: hidden; + aspect-ratio: 16 / 9; +} + +.component-blog-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.5s ease; +} + +.component-blog-card:hover .component-blog-card-image img { + transform: scale(1.05); +} + +.component-blog-card-body { + padding: var(--space-y-3); + flex: 1; + display: flex; + flex-direction: column; +} + +.component-blog-card-body h3 { + font-family: var(--font-family-primary); + font-size: var(--heading-h4-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + line-height: var(--line-height-sm); + margin-bottom: var(--space-y-2); + letter-spacing: -0.01em; +} + +.component-blog-card-body p { + font-size: var(--font-size-sm); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-md); + margin-bottom: var(--space-y-2); + flex: 1; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-blog-feature { + grid-template-columns: 1fr; + } + + .component-blog-feature-content { + padding: var(--space-y-3); + } + + .component-blog-grid { + grid-template-columns: 1fr; + gap: var(--space-y-3); + } + + .component-blog-grid-section { + padding: var(--space-y-6) 0 var(--space-x-10); + } +} + +/* + * DrevOps — Blog Hero Component + * + * Hero banner with featured image and gradient overlay, + * used on blog post pages. + */ + +.component-blog-hero { + position: relative; + min-height: 520px; + display: flex; + align-items: flex-end; + overflow: hidden; +} + +.component-blog-hero-image { + position: absolute; + inset: 0; +} + +.component-blog-hero-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.component-blog-hero-overlay { + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--page-bg) 40%, transparent) 0%, + color-mix(in srgb, var(--page-bg) 70%, transparent) 40%, + var(--page-bg) 100% + ); +} + +.component-blog-hero-content { + position: relative; + z-index: 2; + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--space-x-8) var(--space-y-8); + width: 100%; +} + +.component-blog-hero-content h1 { + font-family: var(--font-family-primary); + font-size: var(--display-2-font-size); + font-weight: var(--font-weight-bold); + line-height: var(--display-line-height); + letter-spacing: -0.02em; + color: var(--text-color); + margin-bottom: var(--space-y-3); + max-width: 800px; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-blog-hero { + min-height: 400px; + } + + .component-blog-hero-content { + padding: 0 var(--space-y-3) var(--space-x-5); + } +} + +/* + * DrevOps — Article Component + * + * Prose styling for blog posts and long-form content. + * Includes code blocks (highlight.js compatible), tables, + * blockquotes, lists, and inline elements. + */ + +.component-article { + background: var(--page-bg); + padding: var(--space-y-10) 0 calc(var(--space-y-10) * 1.75); +} + +.component-article-inner { + max-width: 740px; + margin: 0 auto; + padding: 0 var(--space-x-8); +} + +/* ── Lead paragraph ── */ +.component-article-lead { + font-size: var(--heading-h4-font-size); + font-weight: var(--body-font-weight); + line-height: var(--line-height-lg); + color: var(--text-subtle-color); + margin-bottom: var(--space-y-5); + padding-bottom: var(--space-y-5); + border-bottom: 1px solid var(--border-subtle-color); +} + +/* ── Body text ── */ +.component-article p { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + margin-bottom: var(--space-y-3); +} + +.component-article p strong { + font-weight: var(--font-weight-normal); + color: var(--text-color); +} + +/* ── Headings ── */ +.component-article h2 { + font-family: var(--font-family-primary); + font-size: var(--heading-h2-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + letter-spacing: -0.01em; + margin-top: var(--space-y-8); + margin-bottom: var(--space-y-3); +} + +.component-article h3 { + font-family: var(--font-family-primary); + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + margin-top: var(--space-y-5); + margin-bottom: var(--space-y-2); +} + +/* ── Links ── */ +.component-article a { + color: var(--link-color); + text-decoration: underline; + text-underline-offset: 0.15em; +} + +.component-article a:hover { + color: var(--link-hover-color); +} + +/* ── Lists ── */ +.component-article ul, +.component-article ol { + margin: 0 0 var(--space-y-3); + padding-left: var(--space-x-3); +} + +.component-article li { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + margin-bottom: var(--space-y-1); +} + +.component-article li::marker { + color: var(--color-secondary-6); +} + +/* ── Inline code ── */ +.component-article code { + font-family: var(--font-family-mono); + font-size: 0.875em; + background: var(--code-inline-bg); + color: var(--code-inline-color); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +/* ── Block code (highlight.js) ── */ +.component-article pre { + margin: var(--space-y-3) 0 var(--space-x-4); + border-radius: var(--radius-md); + border: 1px solid var(--code-border); + overflow: hidden; +} + +.component-article pre code { + display: block; + padding: var(--space-y-3); + font-size: var(--caption-font-size); + line-height: var(--line-height-md); + background: var(--code-bg); + color: var(--code-color); + overflow-x: auto; + border-radius: 0; + border: none; +} + +.component-article pre code.hljs { + background: var(--code-bg); +} + +/* ── Tables ── */ +.component-article table { + width: 100%; + border-collapse: collapse; + margin: var(--space-y-3) 0 var(--space-x-4); + font-size: var(--font-size-sm); +} + +.component-article thead th { + background: var(--table-header-bg); + color: var(--table-header-color); + font-family: var(--font-family-primary); + font-size: var(--caption-font-size); + font-weight: var(--font-weight-bold); + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: left; + padding: var(--space-y-2) var(--space-x-2); + border-bottom: 2px solid var(--border-color); +} + +.component-article td { + padding: var(--space-y-1) var(--space-x-2); + border-bottom: 1px solid var(--border-subtle-color); + color: var(--text-subtle-color); + line-height: var(--line-height-md); +} + +.component-article tbody tr:nth-child(even) td { + background: var(--table-stripe-bg); +} + +.component-article td strong { + color: var(--text-color); + font-weight: var(--font-weight-normal); +} + +/* ── Blockquotes ── */ +.component-article blockquote { + margin: var(--space-y-4) 0; + padding: var(--space-y-3) var(--space-x-3); + border-left: 3px solid var(--color-secondary-6); + background: var(--surface-subtle-color); + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} + +.component-article blockquote p { + font-size: var(--body-font-size); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-lg); + margin-bottom: 0; +} + +.component-article blockquote p + p { + margin-top: var(--space-y-2); +} + +.component-article blockquote strong { + color: var(--color-secondary-6); + font-weight: var(--font-weight-normal); +} + +/* ── Article CTA ── */ +.component-article-cta { + margin-top: var(--space-y-8); + padding-top: var(--space-y-6); + border-top: 1px solid var(--border-subtle-color); + text-align: center; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-article { + padding: var(--space-y-6) 0 var(--space-x-10); + } + + .component-article-inner { + padding: 0 var(--space-y-3); + } + + .component-article pre code { + font-size: var(--caption-font-size); + padding: var(--space-y-2); + } + + .component-article table { + font-size: var(--caption-font-size); + } + + .component-article thead th, + .component-article td { + padding: var(--space-y-1) var(--space-x-1); + } +} + +/* + * DrevOps — Contact Info Component + * + * Contact information blocks, steps list, and dividers. + * Used on the contact page info column. + */ + +.component-contact-col { + padding-top: var(--space-y-1); +} + +.component-contact-block { + margin-bottom: var(--space-y-5); +} + +.component-contact-value { + font-family: var(--font-family-primary); + font-size: var(--heading-h3-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-color); + text-decoration: none; + display: block; + margin-bottom: var(--space-y-1); +} + +a.component-contact-value { + transition: color 0.3s ease; +} + +a.component-contact-value:hover { + color: var(--color-secondary-6); + background: none; + text-decoration: none; +} + +.component-contact-email-link { + position: relative; + display: inline-block; +} + +.component-contact-email-link::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: var(--color-secondary-6); + transition: width 0.4s ease; +} + +.component-contact-email-link:hover::after { + width: 100%; +} + +.component-contact-note { + font-size: var(--font-size-sm); + font-weight: var(--body-font-weight); + color: var(--text-muted-color); + line-height: var(--line-height-md); +} + +.component-contact-divider { + height: 1px; + background: linear-gradient(90deg, color-mix(in srgb, var(--color-secondary-6) 30%, transparent) 0%, transparent 100%); + margin: var(--space-y-6) 0; +} + +/* ── Contact content section ── */ +.component-contact-content { + background: var(--page-bg); + padding: calc(var(--space-y-10) * 1.25) 0 calc(var(--space-y-10) * 1.75); +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-contact-content { + padding: var(--space-x-8) 0 var(--space-y-10); + } +} + +/* + * DrevOps — Step Component + */ + +.component-step-list { + display: flex; + flex-direction: column; + gap: var(--space-y-3); + margin-top: var(--space-y-2); +} + +.component-step { + display: flex; + align-items: flex-start; + gap: var(--space-y-2); +} + +.component-step-number { + font-family: var(--font-family-primary); + font-size: 28px; + font-weight: var(--font-weight-bold); + color: var(--color-secondary-6); + opacity: 0.3; + line-height: 1; + flex-shrink: 0; + width: 28px; + text-align: center; +} + +.component-step p { + font-size: var(--font-size-sm); + font-weight: var(--body-font-weight); + color: var(--text-subtle-color); + line-height: var(--line-height-md); + padding-top: 4px; +} + +/* + * DrevOps — Form Layout Component + * + * Form layout patterns. Base input/select/textarea styling + * lives in framework.css; this file handles composition. + */ + +.component-form { + display: flex; + flex-direction: column; + gap: var(--space-y-3); +} + +.component-form-group { + display: flex; + flex-direction: column; + gap: var(--space-y-1); +} + +.component-form .btn { + align-self: flex-start; + margin-top: var(--space-y-1); +} + +/* + * DrevOps — Nav Component + * + * Fixed top navigation bar with glassmorphism backdrop, + * logo, and underline-on-hover links. + */ + +.component-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 900; + padding: var(--space-y-3) var(--space-x-8); + display: flex; + align-items: center; + justify-content: space-between; + background: color-mix(in srgb, var(--page-bg) 70%, transparent); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid color-mix(in srgb, var(--color-secondary-6) 8%, transparent); +} + +.component-nav-logo { + display: inline-flex; + align-items: center; + text-decoration: none; +} + +.component-nav-links { + display: flex; + gap: var(--space-y-6); + list-style: none; +} + +.component-nav-links a { + font-family: var(--font-family-secondary); + font-size: var(--caption-font-size); + font-weight: var(--font-weight-normal); + color: var(--text-muted-color); + text-decoration: none; + letter-spacing: 0.08em; + text-transform: uppercase; + transition: color 0.3s ease; + position: relative; +} + +.component-nav-links a::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: var(--color-secondary-6); + transition: width 0.3s ease; +} + +.component-nav-links a:hover { + color: var(--link-hover-color); + background: none; + text-decoration: none; +} + +.component-nav-links a:hover::after { + width: 100%; +} + +.component-nav-active { + color: var(--color-secondary-6) !important; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-nav { + padding: var(--space-y-2) var(--space-x-3); + } + + .component-nav-links { + display: none; + } +} + +/* + * DrevOps — Footer Component + * + * Site footer bar with copyright and contact link. + */ + +.component-footer { + background: var(--color-primary-8); + padding: var(--space-y-4) var(--space-x-8); + border-top: 1px solid color-mix(in srgb, var(--color-secondary-6) 20%, transparent); + display: flex; + align-items: center; + justify-content: space-between; +} + +.component-footer-copy { + font-size: var(--caption-font-size); + font-weight: var(--body-font-weight); + color: var(--text-muted-color); + letter-spacing: 0.08em; +} + +.component-footer-link { + font-size: var(--caption-font-size); + font-weight: var(--font-weight-normal); + color: var(--color-secondary-6); + text-decoration: none; + letter-spacing: 0.08em; + position: relative; +} + +.component-footer-link:hover { + background: none; + text-decoration: none; +} + +.component-footer-link::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: var(--link-hover-color); + transition: width 0.3s ease; +} + +.component-footer-link:hover::after { + width: 100%; +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-footer { + flex-direction: column; + gap: var(--space-y-2); + text-align: center; + padding: var(--space-y-3); + } +} + +/* + * DrevOps — Section Component + * + * Max-width container with gutter padding for page sections. + */ + +.component-section { + position: relative; +} + +.component-section-inner { + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--space-x-8); +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .component-section-inner { + padding: 0 var(--space-y-3); + } +} + +/* + * DrevOps — Reveal Component + * + * Scroll-triggered fade-up animation with staggered delay utilities. + * Requires JavaScript to add .visible class on scroll. + */ + +.component-reveal { + opacity: 0; + transform: translateY(40px); + transition: opacity 0.9s ease, transform 0.9s ease; +} + +.component-reveal.visible { + opacity: 1; + transform: translateY(0); +} + +.component-reveal-d1 { transition-delay: 0.1s; } +.component-reveal-d2 { transition-delay: 0.2s; } +.component-reveal-d3 { transition-delay: 0.3s; } +.component-reveal-d4 { transition-delay: 0.4s; } +.component-reveal-d5 { transition-delay: 0.5s; } +.component-reveal-d6 { transition-delay: 0.6s; } + +/* + * DrevOps — Animations + * + * Shared keyframe definitions for hero glow, scroll indicator, + * and entrance transitions. + */ + +@keyframes heroGlow { + 0%, 100% { opacity: 0.7; transform: translate(-50%, -55%) scale(1); } + 50% { opacity: 1; transform: translate(-50%, -55%) scale(1.08); } +} + +@keyframes scrollPulse { + 0%, 100% { opacity: 0.3; transform: scaleY(1); } + 50% { opacity: 0.9; transform: scaleY(1.15); } +} + +@keyframes fadeUp { + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeIn { + to { opacity: 1; } +} diff --git a/web/themes/custom/drevops/assets/sass/redesign/_extra.scss b/web/themes/custom/drevops/assets/sass/redesign/_extra.scss new file mode 100644 index 00000000..aa5cb357 --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/redesign/_extra.scss @@ -0,0 +1,21 @@ +// +// Redesign supplements. +// + +// Section-rhythm and inset tokens. The page markup references these (the +// design's token migration introduced them) but they are absent from the +// source variables.css, so define them here. +:root { + --layout-rhythm-sm: 6.25rem; + --layout-rhythm-md: 8.75rem; + --layout-rhythm-lg: 10rem; + --layout-inset: 3.75rem; +} + +// Reveal-on-scroll keeps content hidden until the behaviour adds `.visible`. +// Guard on the `js` class (added by Drupal) so content stays visible when +// JavaScript is unavailable. +html:not(.js) .component-reveal { + opacity: 1; + transform: none; +} diff --git a/web/themes/custom/drevops/assets/sass/redesign/_tokens.scss b/web/themes/custom/drevops/assets/sass/redesign/_tokens.scss new file mode 100644 index 00000000..6688d04d --- /dev/null +++ b/web/themes/custom/drevops/assets/sass/redesign/_tokens.scss @@ -0,0 +1,306 @@ +// +// DrevOps — Redesign Token System +// +// Ported from the static design's variables.css. +// +// The site is ALWAYS dark and does NOT use data-color-scheme. The +// scheme-aware semantic tokens below therefore take their DARK values +// (from the design's [data-color-scheme="dark"] block) directly at +// :root. The Google Fonts @import is intentionally omitted (fonts are +// already loaded by the theme). +// + +// ══════════════════════════════════════════════════════════════ +// PALETTE +// Eleven derived steps per brand colour. Step 1 = lightest, +// step 11 = near-black. The brand base colour sits near step 9. +// ══════════════════════════════════════════════════════════════ +:root { + /* primary */ + --color-primary-1: #EFF6FF; + --color-primary-2: #C8D9F3; + --color-primary-3: #ABBCD5; + --color-primary-4: #8FA0B8; + --color-primary-5: #75849C; + --color-primary-6: #5B6A80; + --color-primary-7: #425166; + --color-primary-8: #2B394D; + --color-primary-9: #152235; + --color-primary-10: #030D1E; + --color-primary-11: #000108; + + /* secondary */ + --color-secondary-1: #E1FBFF; + --color-secondary-2: #96E7F4; + --color-secondary-3: #79C9D7; + --color-secondary-4: #5CACBA; + --color-secondary-5: #3F919D; + --color-secondary-6: #1E7582; + --color-secondary-7: #045A65; + --color-secondary-8: #044048; + --color-secondary-9: #00272D; + --color-secondary-10: #001013; + --color-secondary-11: #000202; + + /* tertiary */ + --color-tertiary-1: #FFF2EF; + --color-tertiary-2: #FFC9BC; + --color-tertiary-3: #FF9C86; + --color-tertiary-4: #ED775E; + --color-tertiary-5: #CD5B43; + --color-tertiary-6: #AE3F28; + --color-tertiary-7: #902107; + --color-tertiary-8: #691301; + --color-tertiary-9: #430800; + --color-tertiary-10: #210200; + --color-tertiary-11: #050000; + + /* neutral */ + --color-neutral-1: #FFFFFF; + --color-neutral-2: #F0F0F0; + --color-neutral-3: #D4D4D4; + --color-neutral-4: #B0B0B0; + --color-neutral-5: #8C8C8C; + --color-neutral-6: #6B6B6B; + --color-neutral-7: #4D4D4D; + --color-neutral-8: #333333; + --color-neutral-9: #1F1F1F; + --color-neutral-10: #0F0F0F; + --color-neutral-11: #000000; + + /* pass */ + --color-pass-1: #EFFBF1; + --color-pass-2: #D6F5DD; + --color-pass-3: #B5EDC2; + --color-pass-4: #8CE3A0; + --color-pass-5: #63DA7E; + --color-pass-6: #3AD05C; + --color-pass-7: #29AD47; + --color-pass-8: #208436; + --color-pass-9: #165B25; + --color-pass-10: #0E3A18; + --color-pass-11: #08210E; + + /* fail */ + --color-fail-1: #FCEEEF; + --color-fail-2: #F7D4D7; + --color-fail-3: #F1B1B7; + --color-fail-4: #EA858F; + --color-fail-5: #E25A67; + --color-fail-6: #DB2E3F; + --color-fail-7: #B7202E; + --color-fail-8: #8B1823; + --color-fail-9: #601118; + --color-fail-10: #3D0B0F; + --color-fail-11: #230609; + + /* warn */ + --color-warn-1: #FFFAEB; + --color-warn-2: #FFF2CC; + --color-warn-3: #FFE8A3; + --color-warn-4: #FFDB70; + --color-warn-5: #FFCF3D; + --color-warn-6: #FFC20A; + --color-warn-7: #D6A100; + --color-warn-8: #A37A00; + --color-warn-9: #705400; + --color-warn-10: #473600; + --color-warn-11: #291F00; + + /* info */ + --color-info-1: #F1F8F9; + --color-info-2: #DCEDEF; + --color-info-3: #BFDEE3; + --color-info-4: #9CCCD3; + --color-info-5: #78B9C4; + --color-info-6: #55A7B4; + --color-info-7: #418995; + --color-info-8: #326971; + --color-info-9: #22484E; + --color-info-10: #162E32; + --color-info-11: #0C1A1C; +} + +// ══════════════════════════════════════════════════════════════ +// PRIMITIVE TOKENS +// Flat values on a scale — never change by theme context. +// ══════════════════════════════════════════════════════════════ +:root { + /* Font Family */ + --font-family-primary: 'Plus Jakarta Sans', sans-serif; + --font-family-secondary: 'Outfit', sans-serif; + --font-family-mono: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, monospace; + + /* Font Size */ + --font-size-2xs: 0.6875rem; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-md: 1rem; + --font-size-lg: 1.25rem; + --font-size-xl: 1.5rem; + --font-size-2xl: 1.875rem; + --font-size-3xl: 2.25rem; + --font-size-4xl: 3rem; + --font-size-5xl: 4rem; + + /* Font Weight */ + --font-weight-thin: 100; + --font-weight-extralight: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Line Height */ + --line-height-xs: 1.1; + --line-height-sm: 1.2; + --line-height-md: 1.5; + --line-height-lg: 1.75; + --line-height-xl: 2; + + /* Space */ + --space-x-1: 0.5rem; + --space-x-2: 1rem; + --space-x-3: 1.5rem; + --space-x-4: 2rem; + --space-x-5: 2.5rem; + --space-x-6: 3rem; + --space-x-7: 3.5rem; + --space-x-8: 4rem; + --space-x-9: 4.5rem; + --space-x-10: 5rem; + --space-y-1: 0.5rem; + --space-y-2: 1rem; + --space-y-3: 1.5rem; + --space-y-4: 2rem; + --space-y-5: 2.5rem; + --space-y-6: 3rem; + --space-y-7: 3.5rem; + --space-y-8: 4rem; + --space-y-9: 4.5rem; + --space-y-10: 5rem; + + /* Border Radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 1rem; +} + +// ══════════════════════════════════════════════════════════════ +// SEMANTIC TOKENS — Typography +// Reusable UI decisions that reference primitives. +// ══════════════════════════════════════════════════════════════ +:root { + /* Heading */ + --heading-font-family: var(--font-family-primary); + --heading-font-weight: var(--font-weight-bold); + --heading-line-height: var(--line-height-sm); + --heading-h1-font-size: var(--font-size-3xl); + --heading-h2-font-size: var(--font-size-2xl); + --heading-h3-font-size: var(--font-size-xl); + --heading-h4-font-size: var(--font-size-lg); + --heading-h5-font-size: var(--font-size-md); + --heading-h6-font-size: var(--font-size-sm); + + /* Display */ + --display-font-family: var(--font-family-primary); + --display-font-weight: var(--font-weight-bold); + --display-line-height: var(--line-height-xs); + --display-1-font-size: var(--font-size-5xl); + --display-2-font-size: var(--font-size-4xl); + + /* Body */ + --body-font-family: var(--font-family-secondary); + --body-font-size: var(--font-size-md); + --body-font-weight: var(--font-weight-extralight); + --body-line-height: var(--line-height-md); + + /* Caption */ + --caption-font-size: var(--font-size-xs); + --caption-line-height: var(--line-height-md); + + /* Label */ + --label-font-size: var(--font-size-2xs); + --label-font-weight: var(--font-weight-normal); + --label-letter-spacing: 0.2em; + --label-text-transform: uppercase; + + /* Link (non-scheme-aware) */ + --link-decoration: underline; + --link-hover-decoration: none; + --link-underline-offset: 0.15em; +} + +// ══════════════════════════════════════════════════════════════ +// SEMANTIC TOKENS — Colour (DARK values, emitted at :root) +// The site is always dark, so the dark scheme values are applied +// directly at :root with no data-color-scheme selector. +// ══════════════════════════════════════════════════════════════ +:root { + /* Text */ + --text-color: #FFFFFF; + --text-subtle-color: var(--color-primary-1); + --text-muted-color: var(--color-primary-3); + --text-accent-color: var(--color-tertiary-3); + + /* Links */ + --link-color: var(--color-secondary-2); + --link-hover-color: var(--color-secondary-1); + + /* Surfaces */ + --page-color: #FFFFFF; + --page-bg: var(--color-primary-9); + --surface-color: var(--color-primary-8); + --surface-subtle-color: color-mix(in srgb, white 6%, var(--color-primary-9)); + --surface-muted-color: color-mix(in srgb, white 3%, var(--color-primary-9)); + + /* Borders */ + --border-color: color-mix(in srgb, var(--color-secondary-6) 18%, transparent); + --border-subtle-color: color-mix(in srgb, white 6%, transparent); + --border-muted-color: color-mix(in srgb, white 3%, transparent); + + /* Buttons */ + --btn-primary-bg: transparent; + --btn-primary-color: var(--color-primary-1); + --btn-primary-border: color-mix(in srgb, var(--color-primary-1) 40%, transparent); + --btn-primary-hover-bg: color-mix(in srgb, var(--color-primary-1) 8%, transparent); + --btn-primary-hover-color: #FFFFFF; + --btn-secondary-bg: transparent; + --btn-secondary-color: var(--color-secondary-2); + --btn-secondary-border: color-mix(in srgb, var(--color-secondary-2) 40%, transparent); + --btn-secondary-hover-bg: color-mix(in srgb, var(--color-secondary-6) 8%, transparent); + --btn-secondary-hover-color: #FFFFFF; + --btn-tertiary-bg: transparent; + --btn-tertiary-color: var(--color-tertiary-3); + --btn-tertiary-border: color-mix(in srgb, var(--color-tertiary-3) 40%, transparent); + --btn-tertiary-hover-bg: color-mix(in srgb, var(--color-tertiary-6) 8%, transparent); + --btn-tertiary-hover-color: #FFFFFF; + + /* Code */ + --code-bg: var(--color-primary-8); + --code-color: var(--color-primary-1); + --code-border: var(--border-color); + --code-inline-bg: var(--surface-color); + --code-inline-color: var(--color-tertiary-3); + + /* Tags */ + --tag-bg: var(--surface-color); + --tag-color: var(--color-secondary-2); + + /* Tables (article context) */ + --table-header-bg: var(--surface-color); + --table-header-color: #FFFFFF; + --table-stripe-bg: var(--surface-subtle-color); + + /* CTA */ + --cta-bg: var(--color-primary-8); +} + +// ══════════════════════════════════════════════════════════════ +// TOKEN OVERRIDES — Website context (from website.css) +// ══════════════════════════════════════════════════════════════ +:root { + --display-1-font-size: clamp(56px, 8vw, 92px); + --display-2-font-size: clamp(40px, 5vw, 56px); +} diff --git a/web/themes/custom/drevops/assets/sass/theme.scss b/web/themes/custom/drevops/assets/sass/theme.scss index 66ed0ed3..d32a0a0f 100644 --- a/web/themes/custom/drevops/assets/sass/theme.scss +++ b/web/themes/custom/drevops/assets/sass/theme.scss @@ -8,3 +8,7 @@ // @todo Refactor build to use direct import from the CivicTheme theme. @import 'page/page'; @import 'block/local-tasks'; +@import 'redesign/tokens'; +@import 'redesign/buttons'; +@import 'redesign/components'; +@import 'redesign/extra'; From c986a0f4b436cebd73c0fb8d6b9cf866a9678968 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 15:30:04 +1000 Subject: [PATCH 05/68] Built services, contact and blog pages via deploy hooks and removed the site-wide signup CTA. --- config/default/block.block.drevops_signup.yml | 2 +- web/modules/custom/do_base/do_base.deploy.php | 103 ++++++++++++++++++ .../03-organisms/banner/banner.scss | 9 +- web/themes/custom/drevops/includes/banner.inc | 10 +- 4 files changed, 119 insertions(+), 5 deletions(-) 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/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index e9c6ed83..5afebccd 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -66,6 +66,109 @@ function do_base_deploy_homepage(): string { return 'Homepage rebuilt.'; } +/** + * Rebuild the Services page to the redesign. + */ +function do_base_deploy_services(): string { + $nodes = \Drupal::entityTypeManager()->getStorage('node')->loadByProperties([ + 'uuid' => 'b78dd34e-e1b2-480a-9056-80902d410008', + ]); + $node = reset($nodes); + + if (!$node instanceof Node) { + return 'Services node not found - skipped.'; + } + + if ($node->hasField('field_c_n_banner_title')) { + $node->set('field_c_n_banner_title', 'Engineering that keeps your platform running.'); + } + + if ($node->hasField('field_c_n_summary')) { + $node->set('field_c_n_summary', "We deliver, support, and upgrade Drupal websites for organisations where downtime, security gaps, and slow development aren't acceptable."); + } + + _do_base_set_components($node, 'services'); + $node->save(); + + return 'Services page rebuilt.'; +} + +/** + * Rebuild the Contact page to the redesign. + * + * Keeps the existing contact webform and adds the redesign contact details + * column. The webform is rendered through a CivicTheme webform paragraph. + */ +function do_base_deploy_contact(): string { + $path = \Drupal::service('path_alias.manager')->getPathByAlias('/contact'); + + if (!preg_match('#^/node/(\d+)$#', $path, $matches)) { + return 'Contact node not found - skipped.'; + } + + $node = Node::load((int) $matches[1]); + + if (!$node instanceof Node || !$node->hasField('field_c_n_components')) { + return 'Contact node not usable - skipped.'; + } + + if ($node->hasField('field_c_n_banner_title')) { + $node->set('field_c_n_banner_title', 'Let\'s talk about your platform.'); + } + + if ($node->hasField('field_c_n_summary')) { + $node->set('field_c_n_summary', 'Whether you need a new Drupal build, help upgrading from an end-of-life version, or ongoing support from a senior team, we are happy to have an honest conversation about where things stand.'); + } + + foreach ($node->get('field_c_n_components')->referencedEntities() as $existing) { + $existing->delete(); + } + + $webform = Paragraph::create([ + 'type' => 'civictheme_webform', + 'field_c_p_theme' => 'dark', + 'field_c_p_webform' => 'contact', + ]); + $webform->save(); + + $components = [$webform, ..._do_base_html_paragraphs('contact')]; + $node->set('field_c_n_components', $components); + $node->save(); + + return 'Contact page rebuilt.'; +} + +/** + * Seed the redesign demo blog article. + * + * Idempotent: matches the existing article by title, otherwise creates it. + */ +function do_base_deploy_blog_demo(): string { + $title = 'Why Your Drupal CI Pipeline Is Slower Than It Should Be'; + $storage = \Drupal::entityTypeManager()->getStorage('node'); + + $existing = $storage->loadByProperties([ + 'type' => 'civictheme_page', + 'title' => $title, + ]); + + $node = $existing ? reset($existing) : Node::create([ + 'type' => 'civictheme_page', + 'title' => $title, + ]); + + $node->set('status', 1); + + if ($node->hasField('field_c_n_banner_title')) { + $node->set('field_c_n_banner_title', $title); + } + + _do_base_set_components($node, 'blog'); + $node->save(); + + return 'Blog demo article seeded.'; +} + /** * Replace a node's components with full-width content paragraphs from markup. * diff --git a/web/themes/custom/drevops/components/03-organisms/banner/banner.scss b/web/themes/custom/drevops/components/03-organisms/banner/banner.scss index 8ee21903..5be5edcd 100644 --- a/web/themes/custom/drevops/components/03-organisms/banner/banner.scss +++ b/web/themes/custom/drevops/components/03-organisms/banner/banner.scss @@ -175,10 +175,17 @@ &.ct-banner-type--intro.ct-banner--hero { #{$root}__inner { position: relative; - min-height: 100vh; + min-height: 62vh; flex-direction: column; justify-content: center; overflow: hidden; + padding-top: ct-spacing(16); + padding-bottom: ct-spacing(10); + } + + // Full-viewport variant for the front page. + &.ct-banner--hero-home #{$root}__inner { + min-height: 100vh; padding-top: ct-spacing(20); padding-bottom: ct-spacing(12); } diff --git a/web/themes/custom/drevops/includes/banner.inc b/web/themes/custom/drevops/includes/banner.inc index a03aa71e..115db13b 100644 --- a/web/themes/custom/drevops/includes/banner.inc +++ b/web/themes/custom/drevops/includes/banner.inc @@ -37,10 +37,14 @@ function _drevops_preprocess_block__civictheme_banner(array &$variables): void { $variables['type'] = $type; - // Homepage hero: render the front-page banner as a full-viewport intro hero. - if (\Drupal::service('path.matcher')->isFrontPage()) { + // Redesign hero: render page banners as a centered intro hero with the + // atmospheric glow. The front page gets the full-viewport variant; other + // pages get a shorter inner variant. + $is_front = \Drupal::service('path.matcher')->isFrontPage(); + if ($is_front || (!empty($node) && $node->bundle() === 'civictheme_page')) { $variables['type'] = 'intro'; - $variables['modifier_class'] = trim(($variables['modifier_class'] ?? '') . ' ct-banner--hero'); + $hero_class = $is_front ? 'ct-banner--hero ct-banner--hero-home' : 'ct-banner--hero'; + $variables['modifier_class'] = trim(($variables['modifier_class'] ?? '') . ' ' . $hero_class); } // Adds extra top padding for the banner when additional spacing is needed. From d7b1152f0536e9152b726dc7d4f713b9fb5f7d98 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 15:41:34 +1000 Subject: [PATCH 06/68] Fixed lint issues in deploy hook and theme variables. --- web/modules/custom/do_base/do_base.deploy.php | 4 ++-- web/themes/custom/drevops/components/variables.base.scss | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index 5afebccd..aca275c2 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -22,7 +22,7 @@ * 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 { +function do_base_deploy_components_dark(?array &$sandbox): ?string { return Helper::entity($sandbox)->batchEntity('paragraph', NULL, static function ($paragraph): void { if (!$paragraph->hasField('field_c_p_theme')) { return; @@ -113,7 +113,7 @@ function do_base_deploy_contact(): string { } if ($node->hasField('field_c_n_banner_title')) { - $node->set('field_c_n_banner_title', 'Let\'s talk about your platform.'); + $node->set('field_c_n_banner_title', "Let's talk about your platform."); } if ($node->hasField('field_c_n_summary')) { diff --git a/web/themes/custom/drevops/components/variables.base.scss b/web/themes/custom/drevops/components/variables.base.scss index 33fce3e5..9e739a6e 100644 --- a/web/themes/custom/drevops/components/variables.base.scss +++ b/web/themes/custom/drevops/components/variables.base.scss @@ -39,13 +39,13 @@ $ct-colors: ( 'heading': #152235, 'body': #2b394d, 'background-light': #f4f7fc, - 'background': #ffffff, + 'background': #fff, 'background-dark': #e9eef6, 'highlight': #cd5b43, ), 'dark': ( - 'heading': #ffffff, - 'body': #ffffff, + 'heading': #fff, + 'body': #fff, 'background-light': #2b394d, 'background': #152235, 'background-dark': #0f1a29, @@ -80,7 +80,6 @@ $ct-fonts: ( ), ), ); - $ct-particle: 8px; // Base font sizes defined in CivicTheme base variables. From 7f5824440cd8a02895408f8a7ec705f6c4e6c8a8 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 16:00:35 +1000 Subject: [PATCH 07/68] Fixed footer copyright contrast and updated region test for the minimal footer. --- tests/behat/features/behat.feature | 10 ++-------- .../drevops/components/03-organisms/footer/footer.scss | 3 ++- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/behat/features/behat.feature b/tests/behat/features/behat.feature index ec02c8dc..f71ac43d 100644 --- a/tests/behat/features/behat.feature +++ b/tests/behat/features/behat.feature @@ -41,14 +41,8 @@ Feature: Behat configuration And I should see the ".demo-block" element in the "sidebar_bottom_left" region And I should see the ".demo-block" element in the "sidebar_top_right" region And I should see the ".demo-block" element in the "sidebar_bottom_right" region - And I should see the ".demo-block" element in the "footer_top_1" region - And I should see the ".demo-block" element in the "footer_top_2" region - And I should see the ".demo-block" element in the "footer_middle_1" region - And I should see the ".demo-block" element in the "footer_middle_2" region - And I should see the ".demo-block" element in the "footer_middle_3" region - And I should see the ".demo-block" element in the "footer_middle_4" region - And I should see the ".demo-block" element in the "footer_bottom_1" region - And I should see the ".demo-block" element in the "footer_bottom_2" region + # The redesign uses a minimal footer (copyright and contact email only), so + # the footer regions are intentionally not rendered. @api Scenario: Messages and login selectors configured correctly diff --git a/web/themes/custom/drevops/components/03-organisms/footer/footer.scss b/web/themes/custom/drevops/components/03-organisms/footer/footer.scss index d27d76b1..ad6c22d8 100644 --- a/web/themes/custom/drevops/components/03-organisms/footer/footer.scss +++ b/web/themes/custom/drevops/components/03-organisms/footer/footer.scss @@ -22,7 +22,8 @@ &__copy { @include ct-typography('text-small'); - color: color-mix(in srgb, #{ct-color-dark('body')} 60%, transparent); + // Opaque light tint that clears WCAG AA contrast on the footer surface. + color: color-mix(in srgb, #{ct-color-dark('body')} 80%, #{ct-color-dark('background-light')}); letter-spacing: 0.08em; } From 8b7425d2d8c8c0f5bedef754d656640fb17552dd Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 16:11:30 +1000 Subject: [PATCH 08/68] Updated contact test heading assertion to the redesigned hero. --- tests/behat/features/contact.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/behat/features/contact.feature b/tests/behat/features/contact.feature index d25d2804..a9cce899 100644 --- a/tests/behat/features/contact.feature +++ b/tests/behat/features/contact.feature @@ -9,7 +9,7 @@ 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" @@ -21,7 +21,7 @@ Feature: Contact form 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" @@ -40,7 +40,7 @@ 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." From a434a3d0e17a7c529dd5c697f526377b104597a7 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Tue, 9 Jun 2026 16:25:08 +1000 Subject: [PATCH 09/68] Addressed code review: validated content before deletion, resolved front page from config, added reduced-motion and stats fallbacks. --- .../do_base/content/blog/demo-article.html | 2 +- web/modules/custom/do_base/do_base.deploy.php | 32 ++++++++++++++++--- .../custom/drevops/assets/js/stats-counter.js | 9 +++++- .../drevops/assets/sass/redesign/_extra.scss | 18 +++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/web/modules/custom/do_base/content/blog/demo-article.html b/web/modules/custom/do_base/content/blog/demo-article.html index 136e1e8f..2be477ab 100644 --- a/web/modules/custom/do_base/content/blog/demo-article.html +++ b/web/modules/custom/do_base/content/blog/demo-article.html @@ -155,7 +155,7 @@

3. Sanitise and slim your test database

4. Parallelise your test suite

-

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

+

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.

diff --git a/web/modules/custom/do_base/do_base.deploy.php b/web/modules/custom/do_base/do_base.deploy.php index aca275c2..2e5152a7 100644 --- a/web/modules/custom/do_base/do_base.deploy.php +++ b/web/modules/custom/do_base/do_base.deploy.php @@ -46,10 +46,12 @@ function do_base_deploy_components_dark(?array &$sandbox): ?string { * the markup in this module's content/homepage directory. */ function do_base_deploy_homepage(): string { - $node = Node::load(1); + // 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) { - return 'Homepage node (1) not found - skipped.'; + return 'Homepage node not found - skipped.'; } if ($node->hasField('field_c_n_banner_title')) { @@ -120,6 +122,10 @@ function do_base_deploy_contact(): string { $node->set('field_c_n_summary', 'Whether you need a new Drupal build, help upgrading from an end-of-life version, or ongoing support from a senior team, we are happy to have an honest conversation about where things stand.'); } + // Build the contact details first so a missing content directory aborts + // before any existing components are removed. + $details = _do_base_html_paragraphs('contact'); + foreach ($node->get('field_c_n_components')->referencedEntities() as $existing) { $existing->delete(); } @@ -131,7 +137,7 @@ function do_base_deploy_contact(): string { ]); $webform->save(); - $components = [$webform, ..._do_base_html_paragraphs('contact')]; + $components = [$webform, ...$details]; $node->set('field_c_n_components', $components); $node->save(); @@ -185,11 +191,15 @@ function _do_base_set_components(Node $node, string $dir): void { return; } + // Build the new components first; this throws if the content is missing, so + // existing components are never deleted without a replacement. + $components = _do_base_html_paragraphs($dir); + foreach ($node->get('field_c_n_components')->referencedEntities() as $existing) { $existing->delete(); } - $node->set('field_c_n_components', _do_base_html_paragraphs($dir)); + $node->set('field_c_n_components', $components); } /** @@ -203,18 +213,30 @@ function _do_base_set_components(Node $node, string $dir): void { */ function _do_base_html_paragraphs(string $dir): array { $path = \Drupal::service('extension.list.module')->getPath('do_base') . '/content/' . $dir; + + if (!is_dir($path)) { + throw new \RuntimeException(sprintf('Redesign content directory not found: %s', $path)); + } + $files = glob($path . '/*.html') ?: []; sort($files); + if (empty($files)) { + throw new \RuntimeException(sprintf('No redesign content files found in: %s', $path)); + } + $paragraphs = []; foreach ($files as $file) { $html = file_get_contents($file); if ($html === FALSE) { - continue; + throw new \RuntimeException(sprintf('Failed to read redesign content file: %s', $file)); } + // The content/ partials are trusted, version-controlled markup authored + // by developers - not user input. The `full_html` format stores them + // verbatim; Drupal core still strips