Compare commits

...
185 Commits
Author SHA1 Message Date
Keith Solomon 06e09e8285 🐞 fix: Intercept summary click to animate both directions
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 15:50:59 -05:00
Keith Solomon 01072f6788 🐞 fix: Keep max-height at pixel value after open so close animates 2026-07-25 15:45:14 -05:00
Keith Solomon 842818ec7e 🐞 fix: Animate team-grid bio with JS measuring natural height 2026-07-25 15:41:46 -05:00
Keith Solomon 7c1c145846 🐞 fix: Use max-height for team-grid bio animation 2026-07-25 15:33:10 -05:00
Keith Solomon aabed2b437 🐞 fix: Use @starting-style for team-grid open transition 2026-07-25 15:23:43 -05:00
Keith Solomon e5301f34c4 🐞 fix: Animate team-grid bio on close and subsequent opens 2026-07-25 15:18:38 -05:00
Keith Solomon 18dde4c9f4 feat: Enhance team-grid block with single open mode and updated styles 2026-07-25 15:15:13 -05:00
Keith Solomon 1c5c727691 test: Add Playwright tests for team-grid block 2026-07-25 13:38:36 -05:00
Keith Solomon 3a07eb384f 🎨 build: Rebuild dist with team-grid block styles 2026-07-25 13:32:58 -05:00
Keith Solomon cb883ca869 📝 docs: Fix Task 6 Step 2 grep — Tailwind v4 preserves CSS-nesting 2026-07-25 13:32:58 -05:00
Keith Solomon 3c786ee9d9 🔧 chore: Import team-grid block CSS into theme 2026-07-25 13:27:16 -05:00
Keith Solomon c20510814e feat: Add team-grid block PHP template 2026-07-25 13:24:06 -05:00
Keith Solomon a816e56cfa feat: Add team-grid block CSS 2026-07-25 13:21:02 -05:00
Keith Solomon c1ac1d659b feat: Add team-grid block.json registration 2026-07-25 13:17:40 -05:00
Keith Solomon 379dee3ea6 feat: Add ACF field group for team-grid block 2026-07-25 13:13:53 -05:00
Keith Solomon 54d79cdcdf 📝 docs: Add implementation plan for team grid block 2026-07-25 12:48:10 -05:00
Keith Solomon 619409eabb 📝 docs: Add design spec for team grid block 2026-07-25 12:46:27 -05:00
Keith Solomon e7de88551d 🐞 fix: Adjust text alignment styles
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 11:21:47 -05:00
Keith Solomon f8247ff7af 🐞 fix: Adjust line thickness
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 11:16:39 -05:00
Keith Solomon 2441cef539 rollback: Revert file move, change url for svg
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-25 11:13:26 -05:00
Keith Solomon 75055e6f6e 🐞 fix: Move line image into block folder
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 11:07:06 -05:00
Keith Solomon 63df8e9a88 🐞 fix: Update service heading font size
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-25 10:54:30 -05:00
Keith Solomon 704b0dc266 🐞 fix: Unregister core pullquote block 2026-07-25 10:54:07 -05:00
Keith Solomon b7477306d8 🐞 fix: Adjust menu width
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-25 10:45:43 -05:00
Keith Solomon 02a8379de2 🐞 fix: Update styles and footer spacing
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-25 10:42:57 -05:00
Keith Solomon f0b5bf75a5 feature: Add services list block
Deploy to Dreamhost (dev) / build (push) Successful in 39s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-25 10:37:28 -05:00
Keith Solomon 60d764045f 🐞 fix: Remove dead $line_url variable from services-list template 2026-07-25 00:18:40 -05:00
Keith Solomon 9c4553f3d0 test: Add assertion for dynamic grid column class 2026-07-25 00:16:26 -05:00
Keith Solomon b0bad0d273 🐞 fix: Remove redundant inline style on services-list line div 2026-07-25 00:16:03 -05:00
Keith Solomon 9c3a645368 📝 docs: Sync spec and plan to use text-24px after the build fix 2026-07-25 00:15:47 -05:00
Keith Solomon d2b2a04b56 test: Add Playwright tests for services-list block 2026-07-24 23:59:28 -05:00
Keith Solomon 7663ca63f6 📝 docs: Fix Task 7 Step 2 grep to match CSS-nesting unwrap 2026-07-24 23:57:25 -05:00
Keith Solomon 415934e0bf 📝 docs: Fix Task 7 verification greps for Windows Git-Bash 2026-07-24 23:54:15 -05:00
Keith SolomonandClaude dae3e7fd68 🎨 build: Rebuild dist after services-list heading token fix
Rebuild static/dist/theme.css to reflect the text-25px → text-24px
fix in views/blocks/services-list/services-list.css. The
.services-list__heading rule now resolves cleanly in Tailwind v4.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 23:52:33 -05:00
Keith SolomonandClaude 2c917edf20 🐞 fix: Use registered text-24px token in services-list heading
The text-25px literal is not a registered token in the project's
typography scale (--text-*). Replace with the closest existing
token, text-24px, to satisfy Tailwind v4 @apply resolution.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 23:51:57 -05:00
Keith Solomon 0be6800d88 🔧 chore: Import services-list block CSS into theme 2026-07-24 23:43:24 -05:00
Keith Solomon 61f959e1a2 feat: Add services-list block PHP template 2026-07-24 23:39:51 -05:00
Keith Solomon a13a3adeef 📝 docs: Fix brief Task 4 verification grep for CSS nesting 2026-07-24 23:34:39 -05:00
Keith Solomon 8c52946340 feat: Add services-list block CSS 2026-07-24 23:32:06 -05:00
Keith Solomon a628973ed8 feat: Add services-list block.json registration 2026-07-24 23:28:25 -05:00
Keith Solomon b32abfb8f6 feat: Add services-list decorative line SVG 2026-07-24 23:24:13 -05:00
Keith Solomon 07fea9e57b feat: Add ACF field group for services-list block 2026-07-24 23:21:14 -05:00
Keith Solomon c8832f49b2 📝 docs: Add design spec for services content blocks 2026-07-24 23:11:28 -05:00
Keith Solomon bd035501b7 🐞 fix: Round corners of hero intro
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-14 22:32:01 -05:00
Keith Solomon ecccfe8c70 🐞 fix: Adjust font size and line height for page hero services intro, and update margin for CTAs container
Deploy to Dreamhost (dev) / build (push) Successful in 37s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-14 22:01:54 -05:00
Keith Solomon fbd3b7feb0 🐞 fix: Update Calls to Action field label and name, adjust layout settings, and enhance page hero services structure
Deploy to Dreamhost (dev) / build (push) Successful in 37s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-14 21:50:27 -05:00
Keith Solomon 8c3284399e 🐞 fix: Remove Chrome tint implementation
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-12 15:30:54 -05:00
Keith Solomon 5bffd36ff8 🐞 fix: Update chrome tint implementation to use opacity for visibility and adjust related tests
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-12 15:21:58 -05:00
Keith Solomon 7267f67d99 🐞 fix: Update chrome tint visibility handling to prevent dark bars over page content and enhance related tests
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-12 15:13:55 -05:00
Keith Solomon 2b0daaa699 🐞 fix: Update footer color for chrome tinting and adjust related tests for iOS compatibility
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-12 14:50:34 -05:00
Keith Solomon c82e6187af feature: Add independent chrome tint elements for iOS 26+ compatibility and enhance related tests
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-12 14:30:41 -05:00
Keith Solomon a41f6f3834 🐞 fix: Update Chrome tint implementation for iOS 26+ compatibility and enhance test coverage
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-12 14:12:18 -05:00
Keith Solomon ca367364f8 feature: Implement dynamic browser chrome tinting for header and footer visibility
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-12 13:57:20 -05:00
Keith Solomon e38511126d 🐞 fix: Adjust mobile layout for blog page and add regression tests
Deploy to Dreamhost (dev) / build (push) Successful in 36s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-12 13:19:53 -05:00
Keith Solomon ae8a80484f 🐞 fix: Update media query breakpoints for navigation styles and add responsive tests
Deploy to Dreamhost (dev) / build (push) Successful in 37s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-08 18:55:19 -05:00
Keith Solomon 53b87c20fd feature: Style single post template
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 17:04:30 -05:00
Keith Solomon b4fb3b051b 🐞 fix: Add switch to show back link on single post pages
Deploy to Dreamhost (dev) / build (push) Successful in 32s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 16:39:46 -05:00
Keith Solomon 6c78d23512 🐞 fix: Add parameters for icon sizing 2026-07-05 16:39:26 -05:00
Keith Solomon 5e08b78d62 🐞 fix: Adjust card background 2026-07-05 16:39:08 -05:00
Keith Solomon 43e3316a70 feature: Style pagination
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-05 16:13:21 -05:00
Keith Solomon 1544a7a04b 🐞 fix: Adjust line-height
Deploy to Dreamhost (dev) / build (push) Successful in 32s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 15:52:04 -05:00
Keith Solomon c2a1eed2cf feature: Sidebar styling
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-05 15:45:54 -05:00
Keith Solomon e5cc42f048 🐞 fix: Get rid of extra margin in admin menu 2026-07-05 14:47:44 -05:00
Keith Solomon 78360dfc89 feature: Add new Instagram icon
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 14:35:56 -05:00
Keith Solomon 21657173e0 feature: Post cards built
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-05 14:25:33 -05:00
Keith Solomon ef0b542e9d 🐞 fix: Use plain header everywhere except Contact page
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 14:18:26 -05:00
Keith Solomon 8f9fd8bf4c 🐞 fix: Add margin everywhere except Contact page 2026-07-05 14:17:56 -05:00
Keith Solomon d4a3e0a6c8 🐞 fix: Make Services pages show the correct hero
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 13:28:25 -05:00
Keith Solomon 36cf891659 fix(header): adjust hero section display logic for non-front pages
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
fix(nav-aux): update padding for auxiliary navigation container
2026-07-05 12:15:26 -05:00
Keith Solomon 19d4dfd89c Merge branch 'main' of ssh://git.keithsolomon.net:222/Solo-Web-Works/CWC
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-07-05 12:07:59 -05:00
Keith Solomon d56ec2ac96 build: regenerate dist with blog post card styles 2026-07-05 11:08:19 -05:00
Keith Solomon a15209e176 test(blog): assert Read more link is present in the first card 2026-07-05 11:04:09 -05:00
Keith Solomon aafea68342 feat(blog): style the post card body, title, byline, and Read more link 2026-07-05 11:01:06 -05:00
Keith Solomon 77f3a0dd3c feat(blog): add Read more link to post card 2026-07-05 10:54:34 -05:00
Keith Solomon 02f9999621 🐞 fix: Update header/hero to swap based on service page children
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-05 10:36:27 -05:00
Keith Solomon 8caa787ff6 feat(blog): add Read more link to post card 2026-07-05 10:35:13 -05:00
Keith Solomon 53270c694e docs(blog-card): add post card styling implementation plan 2026-07-05 10:32:35 -05:00
Keith Solomon 9b76d7bacf docs(blog-card): add post card styling design spec 2026-07-05 10:29:26 -05:00
Keith Solomon 2d3d93aa89 🐞 fix: Adjust editor font
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-04 21:30:28 -05:00
Keith Solomon 702a2e3b74 test(blog): add blog-page Playwright spec 2026-07-04 20:35:46 -05:00
Keith Solomon a7fc6c974c build: regenerate dist with blog post-list styles 2026-07-04 20:32:22 -05:00
Keith Solomon cb0c8864f7 feat(blog): style the post-list page title 2026-07-04 20:29:54 -05:00
Keith Solomon 66073c0846 feat(blog): add page title and trim post card to image + title + byline 2026-07-04 20:26:59 -05:00
Keith Solomon a26b5c66c1 docs(blog): add blog layout spec and plan 2026-07-04 20:25:20 -05:00
Keith Solomon 54608c2be4 docs(spec): record the 2026-07-04 hero-scope revision (Services-only) 2026-07-04 19:54:06 -05:00
Keith Solomon ee9c9ce992 test(hero): update blog-index test to assert no hero (Services-only gate) 2026-07-04 19:53:23 -05:00
Keith Solomon 2fb1d67bb0 fix(hero): remove non-Services bypasses so the hero only renders for Services descendants 2026-07-04 19:52:33 -05:00
Keith Solomon 0a19256310 🐞 fix: Adjust mobile styles for contact block
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-04 19:40:01 -05:00
Keith Solomon 001b8c07c3 🐞 fix: Adjust dropdown z-index so the menu shows up above the map
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-07-04 19:36:30 -05:00
Keith Solomon 01c6fb7831 feature: Set up contact info block and map
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-04 19:22:26 -05:00
Keith Solomon 23ef7388eb tooling(test): add pre-flight script for hero gate rollout
Lists every published page on the site that has hero_style = 'default' and
is not a Services descendant. Those are the pages whose visible hero
behavior changes when isServicesDescendant() is enabled.

Runs from the WordPress root via:

    php bin/check-affected-pages.php

The script bootstraps wp-load.php, queries WP_Query for all 'page'
posts, filters by the ACF 'hero_style' meta, then defers the ancestor
check to CWC\isServicesDescendant() (the same helper the gate uses) so
the output matches runtime behavior. Operator should run this before
enabling the gate on a real site.
2026-07-04 16:17:33 -05:00
Keith Solomon f320b3711f docs(test): document CWC_TEST_BYPASS_HERO_GATE constant for the dev site
isServicesDescendant() gates the inner-page hero on being a Services
descendant. The existing inner-page.spec.js fixtures (sample-page, etc.)
are not Services descendants, so without the bypass the hero would not
render in tests and the suite would fail.

The constant CWC_TEST_BYPASS_HERO_GATE was added in lib/extras.php for
exactly this case, but the dev site's wp-config.php is operator-managed
and cannot be modified by the theme. This note records the single line
the operator needs to add to wp-config.php, plus the rationale and a
production warning.
2026-07-04 15:52:51 -05:00
Keith Solomon 952b1731d9 fix(contact): hardcode CWC Blue 03 stroke on contact info SVGs
SVGs loaded via <img src> cannot inherit currentColor from the parent
document, so the .contact-info__icon { color: ... } rule had no effect and
the icons rendered in the SVG's own default color (black).

Bake the stroke color in directly: stroke="#8FC9E6" (CWC Blue 03). The
final user-provided SVGs will replace these placeholders later, so this is
the minimal change to make the icons visible in the brand color today.
2026-07-04 15:51:28 -05:00
Keith Solomon 9d1a5f8b81 fix(contact): restore aligned = spacing in contact-info block 2026-07-04 15:41:17 -05:00
Keith Solomon 634635f726 fix(contact): fix PHPCS OperatorSpacing warnings in contact-info block 2026-07-04 15:37:45 -05:00
Keith Solomon 0aeacc3c17 test(hero): mark grandchild test as test.fail with TODO for missing fixture 2026-07-04 15:00:00 -05:00
Keith Solomon ee27133250 test(hero): add ancestor-scope Playwright spec 2026-07-04 14:47:37 -05:00
Keith Solomon f77a78b856 fix(contact): use wp_kses_post for block wrapper and AA color for required marker
- contact-info.php was calling esc_attr() on the output of
  blockWrapperAttributes(), which returns a full attribute string.
  esc_attr() turned the wrapping quotes into &quot; so the class
  attribute rendered literally as class="contact-info ...".
  Switched to wp_kses_post() to match the pattern used by every
  other block template in the project.
- contact-info.css: .gfield_required used --color-cwc-orange-01
  (#e15747, 3.71:1 on white) which fails WCAG 2 AA. Swapped to the
  project's --color-secondary-accessible token (#c43c2b) so the
  required marker is brand-aligned and meets the 4.5:1 minimum.
- Regenerated static/dist/theme.css to match.
2026-07-04 14:14:50 -05:00
Keith Solomon 0f4cd48c60 test(contact): add contact-page Playwright spec 2026-07-04 14:12:34 -05:00
Keith Solomon 59a1356d96 feat(hero): gate inner-page hero on Services descendants 2026-07-04 13:57:22 -05:00
Keith Solomon a163b349e1 feat(hero): add isServicesDescendant() helper and bypass constant 2026-07-04 13:54:27 -05:00
Keith Solomon 6d62bd73ae build: regenerate dist with contact-info styles 2026-07-04 13:50:28 -05:00
Keith Solomon d207b2e0e8 fix(contact): use correct views-path for contact-info import 2026-07-04 13:50:28 -05:00
Keith Solomon af067fc5d0 feat(contact): style Gravity Forms fields and submit button 2026-07-04 13:45:05 -05:00
Keith Solomon bcb2486576 feat(contact): add two-column layout and icon styles 2026-07-04 13:40:57 -05:00
Keith Solomon 966fb2624c style(contact-info): use tab indentation to match project standard 2026-07-04 13:37:12 -05:00
Keith Solomon e3024a6cdc feat(contact): rewrite contact-info block to two-column layout 2026-07-04 13:31:06 -05:00
Keith Solomon 1593be1c70 feat(contact): add address, email, phone icon SVGs 2026-07-04 13:25:53 -05:00
Keith Solomon dddb4b8c54 feature: Build interior layout
Deploy to Dreamhost (dev) / build (push) Successful in 38s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-07-01 18:33:01 -05:00
Keith SolomonandClaude 6452f28932 Refine hero: vector full-width, image top padding, content flush left
- styles/blocks/page-hero.css:
  - .page-hero__vector: width is now 100% (was clamp(28rem, 60vw, 56rem))
    so the orange linework spans the full width of the hero. The SVG
    scales proportionally (1920x489 viewBox).
  - .page-hero__media: added top: clamp(2rem, 4vw, 4rem) so the image
    has visible top padding inside the hero (in addition to overflowing
    the bottom). Reduced right offset to clamp(0.5rem, 2vw, 2rem) so
    the image sits closer to the right edge. Width is 'auto' with
    max-width: clamp(20rem, 30vw, 28rem) so the image's natural aspect
    ratio (490x613 portrait) is preserved.
- views/partials/page-hero.php: .page-hero__content is now 'container
  mx-auto px-0!' so the breadcrumbs, H1, and intro sit flush left at
  x=0, matching the logo's left edge (which is also at x=0 thanks to
  the header's px-0! override on its container).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 16:39:29 -05:00
Keith Solomon 4aa6ff694d Update inner page layout spec for hero restructure (no hard-coded copy, image at natural size) 2026-07-01 16:12:48 -05:00
Keith SolomonandClaude 2bda7f21eb Restructure hero: image at natural size overflowing, intro as text, no hard-coded copy
- views/partials/page-hero.php: removed the page-intro partial call. The
  intro is now rendered inline as a single .page-hero__intro div
  containing the editor-supplied intro text only (no hard-coded
  heading). The H1 is rendered with a dedicated .page-hero__heading
  class for typography control. Breadcrumbs + H1 + intro are in a
  single .page-hero__content container.
- views/partials/page-intro.php: deleted (no longer used).
- styles/blocks/page-hero.css: rewritten layout. The hero has a min-height
  of clamp(31rem, 38vw, 36rem). .page-hero__media is a foreground
  absolutely-positioned img on the right (no mask, no cover, no
  object-fit), with bottom: -10% so the image's bottom hangs over
  the bottom edge of the hero. The vector is positioned at
  bottom: clamp(1.5rem, 4vw, 3rem) with some spacing from the bottom
  edge, full-width on the left. Both elements stay above the content
  layer (z-index 10) and below the breadcrumb/title text. The H1 uses
  Quincy serif at clamp(2.5rem, 6vw, 4.5rem). The intro text is
  capped at 36rem to sit alongside the H1 without spanning the full
  hero width.
- tests/inner-page.spec.js: removed the now-obsolete
  .page-hero__intro-heading / .page-hero__intro-text assertions (the
  intro is a single text block now).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 16:11:28 -05:00
Keith SolomonandClaude ec87cae6c5 Update inner page layout spec for hero+intro refactor
- Goal: hero is now a blue-to-teal gradient (not a flat dark color) and the
  intro block lives inside the hero composition (not as a separate page
  section).
- Architecture: reduced from 3-part (hero / intro / body) to 2-part
  (hero / body) since the intro is now embedded in the hero.
- Hero Partial: dropped the bg-dark utility (gradient is set in CSS), the
  hero image is anchored to the right with a left-to-right fade, and the
  vector is full-width anchored to the bottom at z-index 2.
- Hero Partial CSS: the file is at styles/blocks/page-hero.css (not
  views/partials/page-hero.css). Updated the rules to match the new
  gradient + image right-side + vector bottom-center + intro grid layout.
- Intro Partial: rewritten as a fragment for embedding inside the hero
  (two-column heading + body grid) rather than a centered standalone
  section. Partial is loaded by page-hero.php, not page.php.
- page.php Wiring: no longer loads the page-intro partial directly; the
  partial is loaded by page-hero.php.
- Error Handling, Verification, and Acceptance Criteria: updated to
  match the new structure (no empty .page-intro wrapper check; intro is
  a descendant of the hero; gradient + image-right + vector-bottom-center
  expectations).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 15:26:12 -05:00
Keith SolomonandClaude e221f62ef7 Refine hero: gradient backdrop, intro inside hero, image on right, vector visible
- styles/blocks/page-hero.css: add blue-to-teal gradient to .page-hero (matches
  homepage-hero). Fix image mask direction (right-to-left fade) so the image
  is visible on the right and fades to transparent on the left. Reposition
  the vector to span the full bottom of the hero at z-index 2 (above the
  media layer, below content). Add .page-hero__intro and .page-hero__intro-inner
  grid rules for the new two-column heading + body block.
- views/partials/page-hero.php: drop bg-dark / bg-cover / bg-no-repeat
  utilities (the gradient comes from CSS now). Simplify the heading render.
  Load the page-intro partial inside .page-hero__content, below the heading.
- views/partials/page-intro.php: rewrite as a fragment for embedding inside
  the hero - a two-column heading (hard-coded 'Our Work') + body grid.
  Returns early when the intro field is empty.
- page.php: remove the page-intro partial call (it's now loaded from
  page-hero.php).
- tests/inner-page.spec.js: switch the selector from .page-intro to
  .page-hero__intro. Update the order assertions to verify the intro is
  a descendant of the hero (not a sibling below it).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 15:25:39 -05:00
Keith Solomon 342ae015c7 Document the two production-template fixes in the inner-page plan 2026-07-01 12:17:14 -05:00
Keith Solomon 667cb2ec89 Document the two production-template fixes in the inner-page spec 2026-07-01 12:16:52 -05:00
Keith Solomon 8b7819e932 Add no-intro and no-asset fallback tests for inner page layout 2026-07-01 12:16:38 -05:00
Keith Solomon 998619fa3f Add Playwright coverage for inner page layout at all viewports 2026-07-01 11:13:34 -05:00
Keith Solomon cda3878169 Render hero_image and hero_vector in page-hero partial; drop in-hero intro 2026-07-01 10:29:26 -05:00
Keith Solomon edf3d6c5ce Add page-hero media layer CSS and import into blocks bundle 2026-07-01 10:22:29 -05:00
Keith Solomon d389fd7bc8 Fix Task 3 path (page.php is at theme root) and indentation (use tabs) 2026-07-01 10:19:21 -05:00
Keith Solomon a8affd474d Render page-intro partial between hero and body in page.php 2026-07-01 10:16:15 -05:00
Keith Solomon ffdf79ae01 Add page-intro partial for centered narrow intro section 2026-07-01 10:10:00 -05:00
Keith Solomon 145df12149 Add hero_image and hero_vector fields to Page Heading group 2026-07-01 10:03:23 -05:00
Keith SolomonandClaude 2e0b4225a1 Add inner page layout implementation plan
Seven tasks: ACF fields, page-intro partial, page.php wiring, scoped CSS,
page-hero partial update, Playwright tests, and a final quality gate.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 09:55:38 -05:00
Keith Solomon 028fcf28c1 Clarify hero background fallback in inner page layout spec 2026-07-01 08:37:28 -05:00
Keith SolomonandClaude 455a005e58 Add inner page layout design spec
Defines the inner page layout: dark hero with background image and decorative
vector (driven by two new fields on the existing Page Heading ACF group), a
centered narrow intro section underneath (driven by the existing Intro field),
and the standard the_content body.

The mockup's bottom dark block is the existing site footer, not a new CTA. No
new ACF blocks are introduced; the layout reuses the existing Page Heading meta
box and the page-hero partial that header.php already loads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 08:36:37 -05:00
Keith Solomon b53f7bb4ca 🐞 fix: Adjust gradient on mobile
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-06-30 20:42:13 -05:00
Keith Solomon 01101ff712 fix: update mobile navigation styles and comment out unused code
Deploy to Dreamhost (dev) / build (push) Successful in 35s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
2026-06-28 20:52:40 -05:00
Keith Solomon 3db0ceaf8d fix: lock body scroll while menu is open, scroll menu internally
Per the user's request:

- Page scroll is locked when the mobile menu is open: add an
  :has() selector that applies overflow: hidden + touch-action: none to
  the <body> when the .nav-main__toggle has aria-expanded=true. Verified
  with mouse wheel and PageDown: window.scrollY stays at 0.

- Menu scrolls internally when its content overflows: switch
  .menu-vdi's 'overflow-hidden' to 'overflow-y: auto' with
  'overscroll-behavior: contain' so scroll gestures feed the menu
  rather than chain-scroll to the body behind it.

- Cap the menu at viewport height instead of growing: change
  'min-height: calc(100vh - var(--hgtHeader))' to
  'max-height: calc(100vh - var(--hgtHeader))' so the menu fills the
  visible space below the header and any overflowing items become
  scrollable inside the menu.
2026-06-28 19:59:10 -05:00
Keith Solomon 380e2d9015 fix: address remaining mobile menu issues
Per the user's feedback, fix the issues still visible in the menu:

1. Hero rendered over the menu: bumped menu z-index from 10 to 100 so it
   always sits above the hero's stacked elements.

2. List markers on items: added explicit list-style: none / padding: 0 on
   the menu <ul> and items to override the base typography rule that sets
   list-style-type: disc and padding: 0 1rem on every <ul>.

3. Blue links: the base typography rule 'a, .link { color: var(--color-bodylinks); }'
   has specificity 0,1,1 and was beating the menu text-white rule. Override
   with color: var(--color-white) !important on menu links and child links
   inside the mobile media query.

4. Full borders with rounded corners on child items: change child item
   border from '2px solid var(--color-secondary); border-radius: 0.5rem'
   to 'border-bottom: 2px solid var(--color-secondary); border-radius: 0'
   so each child gets only a bottom border (matching the user's spec).

5. Menu items now use plain CSS (color/display/padding/font-weight) instead
   of @apply text-white/no-underline to give them explicit override
   specificity against the base styles.

6. Toggle button: changed the SVG width from 2rem to 1.5rem in
   styles/components/site-header.css so the hamburger and X icons render
   at a square 1.5rem x 1.5rem aspect ratio inside the (intentionally)
   rectangular orange button.

Nav-main template unchanged from the previous commit (Contact button +
social icons already in place). Header test still passes - the button box
itself (72x45) is unchanged, only the icon aspect changed.
2026-06-28 18:05:12 -05:00
Keith Solomon 09d42ba2a9 fix: style mobile menu per spec
Per the user's spec, the mobile menu is:
- Dark navy background, white links, 95% viewport width
- Parent items (those with child links) get an orange chevron
- Child items get an orange border; nothing else gets any border
- Bottom of menu has a Contact link styled as a button and social icons

Changes:

1. Menu CSS (styles/navigation/nav-mobile-sliding.css):
   - Switch the menu background from white to bg-cwc-blue-02 (dark navy)
   - Switch menu item text from black to white
   - Hide the existing .menu-vdi__submenu and show the children inline under their
     parent with a 2px orange border, rounded corners, and white text
   - Style the existing chevron SVG inside the parent <button> with the orange
     secondary color so it's visible against the navy background
   - Add a .menu-vdi__footer block (Contact button + social icons) anchored to
     the bottom of the menu panel, shown only when the toggle is aria-expanded
   - Hide .menu-vdi__footer entirely at desktop so it doesn't leak into the
     page DOM and trigger accessibility warnings

2. Menu template (views/components/nav-main.php):
   - Append a .menu-vdi__footer div after the menu items containing a Contact
     link to home_url('/contact/') and the existing social-media partial
   - The footer renders inside <nav> but is positioned via fixed CSS to align
     with the menu panel

Header/toggle button unchanged - the orange/coral rounded button design from
the original mobile-fidelity plan was already correct and is left alone.
2026-06-28 17:32:05 -05:00
Keith Solomon 2399023286 🐞 fix: Adjust social icon styling 2026-06-28 14:11:46 -05:00
Keith Solomon a761c16802 fix: anchor mobile hero vector to the viewport
Move the mobile hero <img> out of the .vector container (which is intentionally wider than the viewport to bleed the desktop SVG) so the mobile img anchors to .heroVector's full-width edges. Set width: 100vw, left: 0, bottom: 6.25rem so the 402-wide mobile SVG distributes the linework across the full hero at the 402 reference composition.
2026-06-28 13:35:35 -05:00
Keith Solomon 93b9ef68cd docs: record mobile vector replacement spec
Capture the per-block approach, what changed in each PHP/CSS file, and the test/geometry updates needed for the new mobile-*-vector.svg files. Mirror the structure used by the prior 2026-06-27 mobile-homepage-fidelity spec.
2026-06-28 13:10:49 -05:00
Keith Solomon 739cb6e6e2 test: wait for contact linework before measuring
The mobile contact linework is loading=lazy and only resolved when scrolled into view. Wait for it to finish loading before the geometry assertions read its bounding rect, matching the pattern already used for the Our Work desktop vector.
2026-06-28 13:09:40 -05:00
Keith Solomon daeb184dd1 fix: use new mobile vector for our work
Replace the clip-path composition of the desktop SVG with a real <img> that references the artist-supplied mobile-work-vector.svg. Drop the CSS-layer trick and the ::before/::after background pieces; the new mobile SVG already covers the composition. Update the linework composition test to assert against the new <img>.
2026-06-28 13:06:01 -05:00
Keith Solomon 8c239e15f6 fix: use new mobile vector for contact block
Render mobile-contact-vector.svg alongside the existing contact linework. CSS hides the desktop SVG and shows the mobile <img> at <=767px so the new 342x187 composition fills the card width. Update the geometry test to assert the new linework ratio (342/187) and the snapshot.
2026-06-28 13:04:55 -05:00
Keith Solomon 14de24351e fix: use new mobile vector for hero
Add hero-vector-mobile.php that renders mobile-hero-vector.svg alongside the existing desktop hero partial. The homepage-hero.css hides the desktop SVG and shows the mobile <img> below 768px while preserving every other mobile rule.
2026-06-28 13:03:58 -05:00
Keith Solomon dbfd5bfb43 fix: use new mobile vector for pull quote
Swap pullquote-mobile-linework.png for the artist-supplied mobile-pullquote-vector.svg at the existing pull-quote__vector--mobile slot. Update the visual snapshots to match the new artwork.
2026-06-28 13:03:18 -05:00
Keith Solomon 31b498115b test: wait for Our Work desktop vector before reading source 2026-06-28 10:03:58 -05:00
Keith Solomon 0a48a1938c fix: align mobile footer navigation 2026-06-27 22:54:17 -05:00
Keith Solomon 20d25e966d fix: match mobile footer design 2026-06-27 22:49:31 -05:00
Keith Solomon 948cc7e368 refactor: harden responsive Our Work artwork 2026-06-27 22:36:46 -05:00
Keith Solomon 79ff6a1408 fix: match mobile Our Work linework 2026-06-27 22:24:38 -05:00
Keith Solomon 6206761573 fix: match mobile Our Work design 2026-06-27 22:01:47 -05:00
Keith Solomon ca8f5c939d fix: preserve reusable contact linework ratio 2026-06-27 21:38:58 -05:00
Keith Solomon f0c5c47297 refactor: isolate mobile home contact styles 2026-06-27 21:34:44 -05:00
Keith Solomon 7c143721b8 fix: refine mobile contact photo crop 2026-06-27 21:22:30 -05:00
Keith Solomon e2901ce710 fix: correct mobile contact image treatment 2026-06-27 21:16:22 -05:00
Keith Solomon 443be4fb89 fix: match mobile contact card design 2026-06-27 20:58:36 -05:00
Keith Solomon d19924e22b fix: scope mobile media text behavior 2026-06-27 20:35:10 -05:00
Keith Solomon 6263a98988 fix: match mobile image text layout 2026-06-27 20:17:30 -05:00
Keith Solomon 4969e4c9ce test: isolate pull quote linework snapshot 2026-06-27 20:05:52 -05:00
Keith Solomon aaa9d28a79 fix: harden mobile pull quote layout 2026-06-27 20:01:02 -05:00
Keith Solomon 7c3c09855d fix: add mobile pull quote artwork 2026-06-27 19:47:06 -05:00
Keith Solomon 32bbf6750b fix: show full mobile pull quote artwork 2026-06-27 19:27:17 -05:00
Keith Solomon 2986cb3ee8 fix: refine mobile pull quote artwork 2026-06-27 19:20:33 -05:00
Keith Solomon 8a601d90cb fix: align mobile pull quote crop 2026-06-27 19:00:12 -05:00
Keith Solomon 445269d291 test: lock mobile pull quote visual fidelity 2026-06-27 18:48:11 -05:00
Keith Solomon 15e832645c fix: scale mobile pull quote proportionally 2026-06-27 18:37:18 -05:00
Keith Solomon 97ebffb70d fix: match mobile pull quote design 2026-06-27 18:18:15 -05:00
Keith Solomon 81785c4aec refactor: clarify mobile hero geometry 2026-06-27 18:06:34 -05:00
Keith Solomon a360b8192a fix: complete mobile carousel and social updates 2026-06-27 18:00:32 -05:00
Keith Solomon f8d60a63a4 fix: scale mobile hero vector responsively 2026-06-27 17:53:00 -05:00
Keith Solomon bdf78d58ac fix: align mobile hero vector crop 2026-06-27 17:46:58 -05:00
Keith Solomon 6d3bb76b17 fix: match mobile header and hero design 2026-06-27 17:38:49 -05:00
Keith Solomon dd063f1df0 docs: plan mobile homepage fidelity corrections 2026-06-27 17:22:01 -05:00
Keith Solomon 088bdd7cc9 docs: specify mobile homepage fidelity corrections 2026-06-27 17:13:25 -05:00
Keith Solomon 4f2116d77b 🔵 other: Remove non-related spec file 2026-06-27 13:28:22 -05:00
Keith Solomon 1ec5b01b3e docs: specify repo name fallback 2026-06-27 13:20:29 -05:00
Keith Solomon ac2d5dad98 🐞 fix: Adjust list styles
Deploy to Dreamhost (dev) / build (push) Successful in 31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-21 17:01:00 -05:00
Keith Solomon f3c97efcd7 🐞 feat: Add contact information section to footer with address, email, and phone icons
Deploy to Dreamhost (dev) / build (push) Successful in 30s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-21 15:12:03 -05:00
Keith Solomon f64f6e24a3 🐞 fix: Update footer navigation styles and improve accessibility
Deploy to Dreamhost (dev) / build (push) Successful in 30s
Sync TODOs with Issues / sync_todos (push) Successful in 5s
2026-06-21 13:24:04 -05:00
Keith Solomon 2aa7c8d050 🐞 fix: Adjust font weight
Deploy to Dreamhost (dev) / build (push) Successful in 33s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-21 12:43:33 -05:00
Keith Solomon be8c05a00c 🐞 fix: Update padding for recent posts and change hover color for read more link
Deploy to Dreamhost (dev) / build (push) Successful in 31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-21 12:33:54 -05:00
Keith Solomon 623ba4a2a5 🐞 fix: Adjust height of vector bg container
Deploy to Dreamhost (dev) / build (push) Successful in 2m31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-21 07:56:05 -05:00
Keith Solomon add96a2953 🐞 fix: Adjust styles, new vector
Deploy to Dreamhost (dev) / build (push) Successful in 31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 22:02:33 -05:00
Keith Solomon af7d0eae27 🐞 fix: Fix button class escaping issue
Deploy to Dreamhost (dev) / build (push) Successful in 31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 21:33:52 -05:00
Keith Solomon 0d3f2e8e03 🐞 fix: Adjust font-weight
Deploy to Dreamhost (dev) / build (push) Successful in 32s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 21:25:35 -05:00
Keith Solomon 0c1594a091 🐞 fix: Adjust main padding
Deploy to Dreamhost (dev) / build (push) Successful in 32s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 21:22:49 -05:00
Keith Solomon df6e8cd7ef 🐞 fix: Add gradient fade for logo scroller track
Deploy to Dreamhost (dev) / build (push) Successful in 2m34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 21:11:04 -05:00
Keith Solomon 23ed0d4096 🔵 other: Alignment fix
Deploy to Dreamhost (dev) / build (push) Successful in 30s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 20:49:15 -05:00
Keith Solomon 0d1ede3712 🐞 fix: Style tweaks
Deploy to Dreamhost (dev) / build (push) Successful in 31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 20:43:59 -05:00
Keith Solomon 032e7fe0a5 🐞 fix: Adjust button styling
Deploy to Dreamhost (dev) / build (push) Successful in 31s
Sync TODOs with Issues / sync_todos (push) Successful in 6s
2026-06-20 20:35:44 -05:00
124 changed files with 17560 additions and 675 deletions
+114 -8
View File
@@ -26,7 +26,9 @@
"ui": 0, "ui": 0,
"return_format": "value", "return_format": "value",
"ajax": 0, "ajax": 0,
"placeholder": "" "placeholder": "",
"create_options": 0,
"save_options": 0
}, },
{ {
"key": "field_6478f669004aa", "key": "field_6478f669004aa",
@@ -44,7 +46,10 @@
}, },
"default_value": "", "default_value": "",
"enable_opacity": 0, "enable_opacity": 0,
"return_format": "string" "return_format": "string",
"custom_palette_source": "",
"palette_colors": "",
"show_color_wheel": true
}, },
{ {
"key": "field_6478f68c004ab", "key": "field_6478f68c004ab",
@@ -94,6 +99,35 @@
"append": "", "append": "",
"maxlength": "" "maxlength": ""
}, },
{
"key": "field_6a4ad339f75b8",
"label": "Subtitle",
"name": "subtitle",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 1,
"placeholder": "",
"prepend": "",
"append": ""
},
{ {
"key": "field_60bfb85a4a420", "key": "field_60bfb85a4a420",
"label": "Intro", "label": "Intro",
@@ -124,22 +158,23 @@
}, },
{ {
"key": "field_60bfb8614a421", "key": "field_60bfb8614a421",
"label": "Call to Actions", "label": "Calls to Action",
"name": "call_to_actions", "name": "ctas",
"aria-label": "", "aria-label": "",
"type": "repeater", "type": "repeater",
"instructions": "", "instructions": "",
"required": 0, "required": 0,
"conditional_logic": false, "conditional_logic": 0,
"wrapper": { "wrapper": {
"width": "", "width": "",
"class": "", "class": "",
"id": "" "id": ""
}, },
"collapsed": "", "layout": "table",
"pagination": 0,
"min": 0, "min": 0,
"max": 0, "max": 0,
"layout": "table", "collapsed": "",
"button_label": "Add Call to Action", "button_label": "Add Call to Action",
"rows_per_page": 20, "rows_per_page": 20,
"sub_fields": [ "sub_fields": [
@@ -161,6 +196,74 @@
"parent_repeater": "field_60bfb8614a421" "parent_repeater": "field_60bfb8614a421"
} }
] ]
},
{
"key": "field_68new01heroimage",
"label": "Hero Image",
"name": "hero_image",
"aria-label": "",
"type": "image",
"instructions": "Optional background image for the page hero.",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
},
{
"key": "field_68new02herovector",
"label": "Hero Vector",
"name": "hero_vector",
"aria-label": "",
"type": "image",
"instructions": "Optional decorative linework\/vector rendered on the right side of the hero.",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
} }
], ],
"location": [ "location": [
@@ -188,5 +291,8 @@
"active": true, "active": true,
"description": "", "description": "",
"show_in_rest": 0, "show_in_rest": 0,
"modified": 1746895738 "display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1784082334
} }
+118
View File
@@ -0,0 +1,118 @@
{
"key": "group_6867eaa8c0001",
"title": "Contact Info",
"fields": [
{
"key": "field_6a4997f2d7b1f",
"label": "Heading",
"name": "heading",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": ""
},
{
"key": "field_6867eaa8c0002",
"label": "Map Latitude",
"name": "map_latitude",
"aria-label": "",
"type": "number",
"instructions": "Latitude for the Leaflet map center.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "100",
"class": "",
"id": ""
},
"default_value": "49.8951",
"min": -90,
"max": 90,
"allow_in_bindings": 1,
"placeholder": "49.8951",
"step": "0.000001",
"prepend": "",
"append": ""
},
{
"key": "field_6867eaa8c0003",
"label": "Map Longitude",
"name": "map_longitude",
"aria-label": "",
"type": "number",
"instructions": "Longitude for the Leaflet map center.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "100",
"class": "",
"id": ""
},
"default_value": "-97.1384",
"min": -180,
"max": 180,
"allow_in_bindings": 1,
"placeholder": "-97.1384",
"step": "0.000001",
"prepend": "",
"append": ""
},
{
"key": "field_6867eaa8c0004",
"label": "Map Zoom",
"name": "map_zoom",
"aria-label": "",
"type": "number",
"instructions": "Initial map zoom level (1-19).",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "100",
"class": "",
"id": ""
},
"default_value": 14,
"min": 1,
"max": 19,
"allow_in_bindings": 1,
"placeholder": 14,
"step": 1,
"prepend": "",
"append": ""
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf\/contact-info"
}
]
],
"menu_order": 0,
"position": "side",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "heading and map fields for the Contact Info block.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1783208109
}
+123
View File
@@ -0,0 +1,123 @@
{
"key": "group_services_list",
"title": "Services List",
"fields": [
{
"key": "field_services_items",
"label": "Items",
"name": "items",
"aria-label": "",
"type": "repeater",
"instructions": "Add 2, 3, or 4 cards. The grid layout adapts to the count.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"layout": "block",
"pagination": 0,
"min": 0,
"max": 0,
"collapsed": "",
"button_label": "Add Service",
"rows_per_page": 20,
"sub_fields": [
{
"key": "field_services_item_image",
"label": "Image",
"name": "image",
"aria-label": "",
"type": "image",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium",
"parent_repeater": "field_services_items"
},
{
"key": "field_services_item_heading",
"label": "Heading",
"name": "heading",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": "",
"parent_repeater": "field_services_items"
},
{
"key": "field_services_item_description",
"label": "Description",
"name": "description",
"aria-label": "",
"type": "wysiwyg",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"allow_in_bindings": 0,
"tabs": "all",
"toolbar": "full",
"media_upload": 1,
"delay": 0,
"parent_repeater": "field_services_items"
}
]
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf\/services-list"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "Service cards row. Card count (2-4) determines the layout.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1784992250
}
+166
View File
@@ -0,0 +1,166 @@
{
"key": "group_team_grid",
"title": "Team Grid",
"fields": [
{
"key": "field_6a650dee2a34c",
"label": "Single Open Mode",
"name": "single",
"aria-label": "",
"type": "true_false",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"message": "Check to allow only one bio open at a time",
"default_value": 0,
"allow_in_bindings": 0,
"ui": 0,
"ui_on_text": "",
"ui_off_text": ""
},
{
"key": "field_team_grid_members",
"label": "Members",
"name": "members",
"aria-label": "",
"type": "repeater",
"instructions": "Add team members. Click a card to expand the bio.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"layout": "block",
"pagination": 0,
"min": 0,
"max": 0,
"collapsed": "",
"button_label": "Add Team Member",
"rows_per_page": 20,
"sub_fields": [
{
"key": "field_team_grid_member_image",
"label": "Image",
"name": "image",
"aria-label": "",
"type": "image",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium",
"parent_repeater": "field_team_grid_members"
},
{
"key": "field_team_grid_member_name",
"label": "Name",
"name": "name",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": "",
"parent_repeater": "field_team_grid_members"
},
{
"key": "field_team_grid_member_title",
"label": "Title",
"name": "title",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": "",
"parent_repeater": "field_team_grid_members"
},
{
"key": "field_team_grid_member_bio",
"label": "Bio",
"name": "bio",
"aria-label": "",
"type": "wysiwyg",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"tabs": "visual",
"toolbar": "basic",
"media_upload": 0,
"delay": 0,
"allow_in_bindings": 0,
"parent_repeater": "field_team_grid_members"
}
]
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf\/team-grid"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "Team grid block. Each member is a card with photo, name, title, and a collapsible bio.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1785007821
}
+153
View File
@@ -0,0 +1,153 @@
<?php
/**
* Pre-flight check: list pages on this site that have
* `hero_style = 'default'` but are NOT Services descendants.
*
* These are the pages whose visible hero behavior changes when the
* Services-descendant gate is introduced in `isServicesDescendant()`
* (see `lib/extras.php`).
*
* Run from the WordPress root:
*
* php bin/check-affected-pages.php
*
* Or, if the WordPress install is at a different path, point
* `wp-load.php` resolution accordingly (see the bootstrap below).
*
* @package CWC
*/
if ( ! defined( 'ABSPATH' ) ) {
// We are not inside WordPress. Try to bootstrap from the WordPress root.
// This script lives at wp-content/themes/<theme>/bin/, so the WordPress
// root (which contains wp-load.php) is four levels up.
$wp_load = dirname( __DIR__, 4 ) . '/wp-load.php';
if ( ! file_exists( $wp_load ) ) {
fwrite( STDERR, "Could not locate wp-load.php at {$wp_load}. Run this script from the WordPress root or set up ABSPATH first.\n" );
exit( 1 );
}
require_once $wp_load;
}
if ( ! function_exists( 'get_field' ) ) {
fwrite( STDERR, "ACF is not active; this script needs Advanced Custom Fields to read `hero_style`.\n" );
exit( 1 );
}
if ( ! function_exists( 'CWC\\isServicesDescendant' ) ) {
fwrite( STDERR, "CWC\\isServicesDescendant() is not loaded. Make sure the active theme is community-works-collaborative.\n" );
exit( 1 );
}
/**
* Query every published page and check whether its hero would be affected.
*/
function cwc_check_affected_pages() {
$query = new WP_Query(
array(
'post_type' => 'page',
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'menu_order title',
'order' => 'ASC',
'no_found_rows' => true,
)
);
$affected = array();
$skipped_due_to_error = 0;
while ( $query->have_posts() ) {
$query->the_post();
$post = get_post();
$hero_style = get_field( 'hero_style', $post->ID );
if ( 'default' !== $hero_style ) {
continue;
}
$is_services_descendant = \CWC\isServicesDescendant( $post );
if ( is_wp_error( $is_services_descendant ) ) {
++$skipped_due_to_error;
continue;
}
if ( $is_services_descendant ) {
continue;
}
$affected[] = array(
'ID' => (int) $post->ID,
'title' => get_the_title( $post ),
'url' => get_permalink( $post ),
'slug' => $post->post_name,
);
}
wp_reset_postdata();
return array(
'affected' => $affected,
'skipped_due_to_error' => $skipped_due_to_error,
);
}
$result = cwc_check_affected_pages();
$affected = $result['affected'];
echo "Pages with hero_style = 'default' that are NOT Services descendants\n";
echo "==================================================================\n\n";
if ( empty( $affected ) ) {
echo "None. Every page that uses the default hero is a Services descendant (or the Services page itself).\n";
} else {
printf( "Found %d affected page(s):\n\n", count( $affected ) );
$rows = array();
$rows[] = array( 'ID', 'Slug', 'Title', 'URL' );
foreach ( $affected as $page ) {
$rows[] = array(
(string) $page['ID'],
$page['slug'],
$page['title'],
$page['url'],
);
}
// Compute column widths for aligned output.
$widths = array();
foreach ( array( 0, 1, 2, 3 ) as $col ) {
$widths[ $col ] = 0;
foreach ( $rows as $row ) {
$widths[ $col ] = max( $widths[ $col ], strlen( $row[ $col ] ) );
}
}
$format = '| %-' . $widths[0] . 's | %-' . $widths[1] . 's | %-' . $widths[2] . 's | %-' . $widths[3] . "s |\n";
$separator = '+';
foreach ( $widths as $w ) {
$separator .= str_repeat( '-', $w + 2 ) . '+';
}
$separator .= "\n";
echo $separator;
foreach ( $rows as $i => $row ) {
printf( $format, ...$row );
if ( 0 === $i ) {
echo $separator;
}
}
echo $separator;
}
if ( $result['skipped_due_to_error'] > 0 ) {
printf(
"\nWarning: %d page(s) could not be evaluated due to errors. Re-run with WP_DEBUG enabled for details.\n",
$result['skipped_due_to_error']
);
}
echo "\n";
+48
View File
@@ -0,0 +1,48 @@
# Test Environment Setup
## `CWC_TEST_BYPASS_HERO_GATE`
The inner-page hero is gated by `isServicesDescendant()` (see `lib/extras.php`).
A page renders the hero only if it is the Services page, a direct child of it,
or a grandchild of it.
The Playwright suite under `tests/` includes fixtures that are **not** Services
descendants (e.g. `sample-page`). When the new gate is enabled, those pages
no longer render the hero, and the existing `inner-page.spec.js` assertions
fail.
To keep the existing test fixtures working without forking the test suite,
the theme exposes a bypass constant:
```php
define( 'CWC_TEST_BYPASS_HERO_GATE', true );
```
When `CWC_TEST_BYPASS_HERO_GATE` is `true`, `isServicesDescendant()` returns
`true` for every post, so the hero is rendered for all pages — matching the
pre-gate behavior expected by the existing tests.
## How to enable on the dev site
Add the following line to `wp-config.php` on the dev site
(`community-works-collaborative.test`), ideally near the other `define()`
calls in that file:
```php
define( 'CWC_TEST_BYPASS_HERO_GATE', true );
```
The constant defaults to `false` in `lib/extras.php`, so production sites are
unaffected unless this define is added.
## Why this is a manual step
This repository does not own the WordPress install — `wp-config.php` is
managed by the operator of the dev site. The theme cannot and should not
modify `wp-config.php` itself.
## Production note
Do **not** define this constant on production. It exists solely to let the
test environment continue to use the same fixtures the suite was originally
written against.
@@ -0,0 +1,916 @@
# Mobile Homepage Fidelity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the homepage match the supplied 402px mobile section crops and remain proportional from 320px through 767px without changing layouts at 768px and above.
**Architecture:** Add narrowly scoped mobile overrides to the block or component that owns each visual region. Use the existing WordPress markup and content, with Playwright geometry assertions written before each correction. Treat 402px as the exact baseline, then verify the lower, upper, and desktop boundary widths.
**Tech Stack:** WordPress/PHP, ACF blocks, Tailwind CSS v4 with nested CSS, Playwright, axe-core, PHPCS, Prettier.
---
## File Map
- `tests/mobile-homepage.spec.js`: mobile geometry, ordering, breakpoint, and accessibility regression coverage.
- `styles/components/site-header.css`: hamburger appearance only.
- `views/blocks/homepage-hero/homepage-hero.php`: decorative location-label hook shown in the approved crop.
- `views/blocks/homepage-hero/homepage-hero.css`: hero height, content, CTAs, media crop, and linework.
- `views/blocks/pull-quote/pull-quote.css`: mobile type, block height, and vector crop.
- `views/blocks/media-text/media-text.css`: mobile text-first order, CTA row, collage size, and block spacing.
- `views/blocks/contact-block/contact-block.css`: mobile card dimensions, photo treatment, text, CTA, and linework.
- `views/blocks/our-work/our-work.css`: mobile content, vector density, and contained overlapping images.
- `styles/components/site-footer.css`: mobile footer grid and alignment.
- `static/dist/theme.css`: generated by `npm run build`; do not hand-edit.
## Reference Material
- `notes/header-hero.png` — 402×874.
- `notes/pullquote.png` — 402×361.
- `notes/image-text.png` — 402×964.
- `notes/contact.png` — 343×502.
- `notes/our-work.png` — 402×888.
- `notes/footer.png` — 402×673.
---
### Task 1: Add reusable mobile geometry assertions
**Files:**
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add geometry helpers below the homepage constant**
```js
const getBox = async (locator) => {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return box;
};
const expectNear = (actual, expected, tolerance = 3) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
```
- [ ] **Step 2: Replace the current hamburger size test with the approved target-only test**
```js
test("mobile header preserves its layout and uses the designed hamburger", async ({
page,
}) => {
await page.goto(homepage);
const header = await getBox(page.locator(".site-header"));
const toggle = page.locator(".nav-main__toggle");
const toggleBox = await getBox(toggle);
expectNear(header.height, 70, 2);
expectNear(toggleBox.width, 72, 2);
expectNear(toggleBox.height, 45, 2);
await expect(toggle).toHaveCSS(
"background-color",
"oklch(0.6353 0.1751 29.61)",
);
});
```
- [ ] **Step 3: Run the test and verify it fails for the hamburger dimensions**
Run:
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "designed hamburger" --reporter=line
```
Expected: FAIL because the current button is 36×36 rather than approximately 72×45.
---
### Task 2: Correct the hamburger and homepage hero
**Files:**
- Modify: `styles/components/site-header.css:73-96`
- Modify: `views/blocks/homepage-hero/homepage-hero.php:62-70`
- Modify: `views/blocks/homepage-hero/homepage-hero.css:55-114`
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add a failing hero geometry test**
```js
test("mobile hero matches the 402px reference composition", async ({ page }) => {
await page.goto(homepage);
const hero = await getBox(page.locator(".homepage-hero"));
const heading = await getBox(page.locator(".homepage-hero .intro h1"));
const buttons = page.locator(".homepage-hero .reset .button");
const firstButton = await getBox(buttons.nth(0));
const secondButton = await getBox(buttons.nth(1));
expectNear(hero.height, 752, 8);
expectNear(heading.x, 32, 3);
expect(heading.width).toBeGreaterThanOrEqual(330);
expectNear(firstButton.y, secondButton.y, 2);
expectNear(firstButton.width, 148, 4);
expectNear(secondButton.width, 156, 4);
await expect(page.locator(".homepage-hero__location")).toBeVisible();
});
```
- [ ] **Step 2: Run the hero test and verify the current short composition fails**
Run:
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "reference composition" --reporter=line
```
Expected: FAIL because the current hero is about 332px tall and the content/buttons are undersized.
- [ ] **Step 3: Change only the hamburger button declarations inside the existing mobile query**
```css
.site-header .nav-main__toggle {
align-items: center;
background: var(--color-secondary);
border-radius: 1.4rem 0.35rem 1.4rem 0.35rem;
display: inline-flex;
height: 2.8125rem;
justify-content: center;
margin-right: 1rem;
padding: 0.65rem 1rem;
width: 4.5rem;
}
.site-header .nav-main__toggle svg {
height: 1.5rem;
width: 2rem;
}
```
Do not alter the header container, logo, padding, or height rules.
- [ ] **Step 4: Add the decorative location label after `.heroVector`**
```php
<div class="homepage-hero__location" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M12 21s7-5.6 7-12a7 7 0 1 0-14 0c0 6.4 7 12 7 12Z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
<span><?php echo esc_html__( 'Location / Nation Information', 'cwc' ); ?></span>
</div>
```
The label is marked decorative because it reproduces a visual annotation from the approved design rather than page navigation or editorial content.
- [ ] **Step 5: Replace the hero mobile query with a 402px-baseline composition**
```css
.homepage-hero__location {
display: none;
}
@media (max-width: 767px) {
.homepage-hero {
--hgtHero: clamp(37.5rem, 187vw, 47rem);
min-height: var(--hgtHero);
}
.homepage-hero .content-wrapper {
padding-inline: clamp(1.25rem, 8vw, 2rem);
}
.homepage-hero .content-wrapper > div {
max-width: 21.25rem;
padding-top: clamp(3rem, 14vw, 3.75rem);
text-align: left;
}
.homepage-hero .intro h1 {
font-size: clamp(1.75rem, 8vw, 2rem);
letter-spacing: 0;
line-height: 1.24;
margin-bottom: 1.75rem;
}
.homepage-hero .intro h1 strong {
font-size: inherit;
line-height: inherit;
}
.homepage-hero .intro p {
font-size: var(--text-14px);
line-height: 1.3;
margin: 0;
max-width: 18rem;
}
.homepage-hero .reset {
flex-wrap: nowrap;
gap: clamp(0.75rem, 5.5vw, 1.4rem);
justify-content: flex-start;
margin-top: 2rem;
}
.homepage-hero .button {
font-size: var(--text-18px);
min-height: 2.75rem;
min-width: 9.25rem;
padding: 0.65rem 1rem;
}
.homepage-hero .reset x-button:nth-child(2) .button {
min-width: 9.75rem;
}
.homepage-hero .heroVector .vector {
bottom: 6.25rem;
left: 50%;
transform: translateX(-50%);
width: clamp(43rem, 190vw, 48rem);
}
.homepage-hero .heroMedia img,
.homepage-hero .heroMedia video {
object-position: 58% center;
}
.homepage-hero .homepage-hero__location {
align-items: center;
bottom: 5.5rem;
color: var(--color-cwc-blue-03);
display: flex;
font-size: var(--text-14px);
gap: 0.5rem;
left: 2rem;
position: absolute;
z-index: 30;
}
.homepage-hero .homepage-hero__location svg {
fill: none;
height: 1.25rem;
stroke: currentColor;
stroke-width: 1.75;
width: 1.25rem;
}
}
```
- [ ] **Step 6: Build and run the hamburger/hero tests**
```powershell
npm run build
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "designed hamburger|reference composition" --reporter=line
```
Expected: 2 passed.
- [ ] **Step 7: Capture and inspect the 402px header/hero**
```powershell
npx playwright screenshot --viewport-size "402,874" --wait-for-timeout 1000 http://community-works-collaborative.test "$env:TEMP\cwc-header-hero.png"
```
Compare directly with `notes/header-hero.png`. Adjust only the numeric values in the mobile query until heading, buttons, media crop, and vector anchor match.
- [ ] **Step 8: Commit the header and hero correction**
```powershell
git add styles/components/site-header.css views/blocks/homepage-hero/homepage-hero.php views/blocks/homepage-hero/homepage-hero.css tests/mobile-homepage.spec.js
git commit -m "fix: match mobile header and hero design"
```
---
### Task 3: Correct the pull quote
**Files:**
- Modify: `views/blocks/pull-quote/pull-quote.css:47-60`
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add the failing pull-quote test**
```js
test("mobile pull quote matches the reference scale", async ({ page }) => {
await page.goto(homepage);
const quote = await getBox(page.locator(".pull-quote"));
const text = await getBox(page.locator(".pull-quote__text"));
expectNear(quote.height, 361, 6);
expect(text.width).toBeGreaterThanOrEqual(330);
expect(text.height).toBeGreaterThanOrEqual(235);
});
```
- [ ] **Step 2: Run the test and verify it fails**
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "pull quote" --reporter=line
```
Expected: FAIL because the current text and block are substantially shorter than the reference.
- [ ] **Step 3: Replace the mobile pull-quote overrides**
```css
@media (max-width: 767px) {
.pull-quote {
border-bottom-width: 0.75rem;
min-height: clamp(18rem, 89.8vw, 22.5625rem);
}
.pull-quote .pull-quote__vector img {
height: 118%;
object-fit: cover;
object-position: center 42%;
width: 118%;
}
.pull-quote .pull-quote__content {
align-items: center;
display: flex;
min-height: inherit;
padding: 3.5rem 2rem;
}
.pull-quote .pull-quote__text {
font-size: clamp(1.5rem, 6.75vw, 1.7rem);
line-height: 1.18;
width: 100%;
}
}
```
- [ ] **Step 4: Build, test, and compare with the crop**
```powershell
npm run build
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "pull quote" --reporter=line
```
Expected: PASS. Use a 402px screenshot to compare the vector crop and text wrapping with `notes/pullquote.png`.
- [ ] **Step 5: Commit the pull-quote correction**
```powershell
git add views/blocks/pull-quote/pull-quote.css tests/mobile-homepage.spec.js
git commit -m "fix: match mobile pull quote design"
```
---
### Task 4: Reorder and resize the image/text block
**Files:**
- Modify: `views/blocks/media-text/media-text.css`
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add failing order and CTA-row tests**
```js
test("mobile media text places copy and CTAs before the complete collage", async ({
page,
}) => {
await page.goto(homepage);
const block = page.locator(".media-cols").first();
const content = await getBox(block.locator(".content-wrapper"));
const media = await getBox(block.locator(":scope > div").first());
const buttons = block.locator(".reset .button");
const firstButton = await getBox(buttons.nth(0));
const secondButton = await getBox(buttons.nth(1));
expect(content.y).toBeLessThan(media.y);
expectNear(firstButton.y, secondButton.y, 2);
expect(media.width).toBeGreaterThanOrEqual(335);
});
```
- [ ] **Step 2: Run the test and verify it fails for media order**
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "complete collage" --reporter=line
```
Expected: FAIL because the current `order-first` utility places the collage above the copy.
- [ ] **Step 3: Populate the empty block stylesheet with scoped mobile rules**
```css
@media (max-width: 767px) {
.media-cols {
gap: 2.75rem;
margin-bottom: clamp(12rem, 55vw, 14rem);
padding-inline: clamp(1.25rem, 8vw, 2rem);
}
.media-cols > div:first-child {
order: 2;
width: 100%;
}
.media-cols > .content-wrapper {
order: 1;
padding-inline: 0;
}
.media-cols > div:first-child img {
display: block;
height: auto;
width: 100%;
}
.media-cols .content-wrapper > .font-light {
font-size: var(--text-16px);
line-height: 1.45;
}
.media-cols .reset {
flex-wrap: nowrap;
gap: 1.4rem;
justify-content: flex-start;
margin-top: 2rem;
}
.media-cols .reset .button {
font-size: var(--text-18px);
min-height: 2.75rem;
min-width: 0;
padding: 0.65rem 2rem;
}
}
```
- [ ] **Step 4: Build, test, and compare with the reference**
```powershell
npm run build
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "complete collage" --reporter=line
```
Expected: PASS. Compare the full block with `notes/image-text.png`; tune the mobile `margin-bottom` so the contact block starts at the reference distance rather than the current excessive gap.
- [ ] **Step 5: Commit the media-text correction**
```powershell
git add views/blocks/media-text/media-text.css tests/mobile-homepage.spec.js
git commit -m "fix: match mobile image text layout"
```
---
### Task 5: Correct the contact card composition
**Files:**
- Modify: `views/blocks/contact-block/contact-block.css:46-99`
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add a failing contact-card geometry test**
```js
test("mobile contact card matches the reference frame", async ({ page }) => {
await page.goto(homepage);
const card = await getBox(page.locator(".contact-block.home-contact"));
const content = await getBox(
page.locator(".contact-block.home-contact .contact-block__content"),
);
expectNear(card.width, 343, 4);
expectNear(card.height, 502, 8);
expectNear(content.x - card.x, 32, 4);
});
```
- [ ] **Step 2: Run the test and verify it fails**
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "contact card" --reporter=line
```
Expected: FAIL because the current card is wider, shorter, and top-pads the content by seven rem.
- [ ] **Step 3: Replace the contact block mobile overrides**
```css
@media (max-width: 767px) {
.contact-block {
border-bottom-width: 0.75rem;
border-radius: 1rem 1rem 0 0;
min-height: clamp(25rem, 124.9vw, 31.375rem);
}
.contact-block .contact-block__media {
inset: 0;
mask-image: linear-gradient(to right, rgb(0 0 0 / 0.2) 0%, black 68%);
-webkit-mask-image: linear-gradient(to right, rgb(0 0 0 / 0.2) 0%, black 68%);
width: 100%;
}
.contact-block .contact-block__bg-image {
object-fit: cover;
object-position: 70% center;
}
.contact-block .contact-block__linework {
bottom: 0;
height: auto;
left: 0;
max-width: none;
right: auto;
top: auto;
width: 112%;
}
.contact-block .contact-block__content {
min-height: clamp(25rem, 124.9vw, 31.375rem);
padding: 2rem 2rem 2.75rem !important;
position: relative;
z-index: 2;
}
.contact-block .contact-block__text {
font-size: clamp(1.65rem, 7vw, 1.8rem);
line-height: 1.08;
max-width: 17rem !important;
}
.contact-block.home-contact {
bottom: auto;
left: 50%;
margin: 2.5rem auto -3.75rem;
max-width: 21.4375rem;
position: relative;
transform: translateX(-50%);
width: min(85.3vw, 21.4375rem);
}
}
```
- [ ] **Step 4: Build, test, and visually tune photo/vector anchors**
```powershell
npm run build
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "contact card" --reporter=line
```
Expected: PASS. Compare with `notes/contact.png`; adjust only `object-position`, mask stop, and linework width/offset until the photo and lower vector match.
- [ ] **Step 5: Commit the contact card correction**
```powershell
git add views/blocks/contact-block/contact-block.css tests/mobile-homepage.spec.js
git commit -m "fix: match mobile contact card design"
```
---
### Task 6: Correct Our Work vector and image containment
**Files:**
- Modify: `views/blocks/our-work/our-work.css:193-261`
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add failing block and image-containment tests**
```js
test("mobile Our Work contains both reference images", async ({ page }) => {
await page.goto(homepage);
const block = await getBox(page.locator(".our-work"));
const left = await getBox(page.locator(".our-work__media--left"));
const bottom = await getBox(page.locator(".our-work__media--bottom"));
expectNear(block.height, 888, 12);
expectNear(left.width, 189, 5);
expect(bottom.width).toBeGreaterThanOrEqual(335);
expect(bottom.x).toBeGreaterThanOrEqual(60);
});
```
- [ ] **Step 2: Run the test and verify it fails**
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "contains both" --reporter=line
```
Expected: FAIL because the existing left image is about 152px wide and the bottom image is undersized/clipped.
- [ ] **Step 3: Change the mobile breakpoint to the approved 767px scope and replace its rules**
```css
@media (max-width: 767px) {
.our-work {
margin-bottom: 2rem;
min-height: clamp(44rem, 220.9vw, 55.5rem);
padding-top: 4.25rem;
}
.our-work__vector img {
height: 118%;
object-fit: cover;
object-position: 56% center;
width: 118%;
}
.our-work__inner {
display: block;
padding-inline: 2rem;
}
.our-work__content {
margin-inline: 0;
max-width: 21rem;
text-align: left;
}
.our-work__content .acf-innerblocks-container {
gap: 1.75rem;
}
.our-work__content h1,
.our-work__content h2,
.our-work__content h3,
.our-work__content h4,
.our-work__content h5,
.our-work__content h6 {
font-size: clamp(2.5rem, 11vw, 2.75rem);
}
.our-work__content p {
font-size: var(--text-16px);
line-height: 1.45;
}
.our-work__content .wp-block-acf-buttons > div {
flex-wrap: nowrap;
gap: 2rem;
justify-content: flex-start;
}
.our-work__content .button {
font-size: var(--text-18px);
min-height: 2.75rem;
min-width: 9.75rem;
padding: 0.65rem 1rem;
}
.our-work__media--left {
margin: 4rem 0 0;
max-width: 11.8125rem;
position: relative;
width: 55%;
z-index: 2;
}
.our-work__media--bottom {
margin: -3.5rem -2rem 0 auto;
max-width: none;
position: relative;
width: 100%;
z-index: 1;
}
.our-work__image {
object-fit: contain;
}
}
```
- [ ] **Step 4: Build, test, and compare the overlap**
```powershell
npm run build
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "contains both" --reporter=line
```
Expected: PASS. Compare with `notes/our-work.png`; tune vector `object-position` and the two media margins without changing image containment.
- [ ] **Step 5: Commit the Our Work correction**
```powershell
git add views/blocks/our-work/our-work.css tests/mobile-homepage.spec.js
git commit -m "fix: match mobile Our Work design"
```
---
### Task 7: Rebuild the mobile footer hierarchy
**Files:**
- Modify: `styles/components/site-footer.css:50-80`
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add a failing footer geometry test**
```js
test("mobile footer matches the reference hierarchy", async ({ page }) => {
await page.goto(homepage);
const footer = page.locator(".site-footer");
const footerBox = await getBox(footer);
const logo = await getBox(footer.locator(".site-footer__logo-image"));
const backToTop = await getBox(footer.locator("#backToTopBtn"));
const nav = await getBox(footer.locator("#footer-nav"));
const contact = await getBox(footer.locator(".contact"));
expectNear(footerBox.height, 673, 12);
expectNear(logo.y, backToTop.y, 8);
expect(nav.y).toBeGreaterThan(logo.y + logo.height);
expect(contact.y).toBeGreaterThan(nav.y + nav.height);
});
```
- [ ] **Step 2: Run the test and verify the current four-cell grid fails**
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "footer matches" --reporter=line
```
Expected: FAIL because the back-to-top, nav, contact details, social links, and copyright do not occupy the reference rows.
- [ ] **Step 3: Replace the mobile footer query**
```css
@media (max-width: 767px) {
.site-footer {
min-height: clamp(34rem, 167.4vw, 42.0625rem);
padding-block: 4rem 2rem;
}
.site-footer > .container {
display: grid;
gap: 1.75rem 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding-inline: 2rem;
}
.site-footer > .container > div:first-child {
grid-column: 1;
grid-row: 1;
}
.site-footer #footer-header {
margin: 0;
max-width: 12.25rem;
}
.site-footer #footer-nav {
grid-column: 1 / -1;
grid-row: 2;
}
.site-footer .site-footer__nav-list {
display: grid;
gap: 0.75rem 2rem;
grid-auto-flow: column;
grid-template-rows: repeat(3, auto);
}
.site-footer #footer-nav + div {
grid-column: 1 / -1;
grid-row: 3;
}
.site-footer #footer-description {
font-size: var(--text-20px);
max-width: 21rem;
}
.site-footer .contact__item {
font-size: var(--text-16px);
}
.site-footer .text-right {
display: contents;
}
.site-footer x-back-to-top {
align-self: start;
grid-column: 2;
grid-row: 1;
}
.site-footer .social-links {
grid-column: 1 / -1;
grid-row: 4;
justify-content: flex-end;
margin-top: 0;
}
.site-footer .copyright {
grid-column: 1 / -1;
grid-row: 5;
padding-block: 0;
text-align: center;
}
}
```
- [ ] **Step 4: Build, test, and compare against the footer crop**
```powershell
npm run build
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "footer matches" --reporter=line
```
Expected: PASS. Compare with `notes/footer.png`; tune only gaps, logo width, and row padding while retaining WordPress-driven content and link order.
- [ ] **Step 5: Commit the footer correction**
```powershell
git add styles/components/site-footer.css tests/mobile-homepage.spec.js
git commit -m "fix: match mobile footer design"
```
---
### Task 8: Verify responsive boundaries and desktop isolation
**Files:**
- Modify: `tests/mobile-homepage.spec.js`
- [ ] **Step 1: Add a parameterized proportional-layout test**
```js
for (const width of [320, 402, 767]) {
test(`homepage remains contained at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto(homepage);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator(".homepage-hero .reset")).toBeVisible();
await expect(page.locator(".contact-block.home-contact")).toBeVisible();
await expect(page.locator(".our-work__media--bottom")).toBeVisible();
});
}
```
- [ ] **Step 2: Add the desktop-isolation assertion**
```js
test("mobile overrides stop below the 768px desktop boundary", async ({
page,
}) => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(homepage);
const media = page.locator(".media-cols").first();
const content = await getBox(media.locator(".content-wrapper"));
const image = await getBox(media.locator(":scope > div").first());
expect(image.y).toBeLessThanOrEqual(content.y);
await expect(page.locator(".site-footer > .container")).toHaveCSS(
"grid-template-columns",
/.+ .+ .+ .+/,
);
});
```
- [ ] **Step 3: Run the new boundary tests**
```powershell
npx playwright test tests/mobile-homepage.spec.js --project=chromium --grep "remains contained|desktop boundary" --reporter=line
```
Expected: 4 passed and no horizontal overflow.
- [ ] **Step 4: Run the full production and quality gate**
```powershell
npm run build
npx playwright test --project=chromium --reporter=line
npx --yes prettier@3.6.2 --check static/js/modules/RecentPostsCarousel.js tests/mobile-homepage.spec.js tests/site-a11y.spec.js
composer validate --no-check-publish
vendor\bin\phpcs.bat --standard=.phpcs.xml footer.php views/blocks/recent-posts/recent-posts.php views/icons/facebook.php views/icons/linkedin.php views/icons/youtube.php
php -l footer.php
php -l views/blocks/recent-posts/recent-posts.php
git diff --check
```
Expected: build succeeds; all Playwright/axe tests pass; Prettier, Composer, PHPCS, PHP syntax, and whitespace checks exit 0.
- [ ] **Step 5: Capture final comparison screenshots**
```powershell
npx playwright screenshot --viewport-size "402,874" --full-page --wait-for-timeout 1500 http://community-works-collaborative.test "$env:TEMP\cwc-homepage-402-final.png"
npx playwright screenshot --viewport-size "320,800" --full-page --wait-for-timeout 1500 http://community-works-collaborative.test "$env:TEMP\cwc-homepage-320-final.png"
npx playwright screenshot --viewport-size "767,900" --full-page --wait-for-timeout 1500 http://community-works-collaborative.test "$env:TEMP\cwc-homepage-767-final.png"
```
Inspect the 402px capture section by section against all six reference crops. Inspect 320px and 767px for clipping, awkward line wrapping, and unintended overlap.
- [ ] **Step 6: Commit the boundary tests and final tuning**
```powershell
git add tests/mobile-homepage.spec.js styles/components/site-header.css styles/components/site-footer.css views/blocks/homepage-hero/homepage-hero.css views/blocks/pull-quote/pull-quote.css views/blocks/media-text/media-text.css views/blocks/contact-block/contact-block.css views/blocks/our-work/our-work.css
git commit -m "test: verify mobile homepage responsive fidelity"
```
@@ -0,0 +1,757 @@
# Inner Page Layout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the inner page layout from `notes/inner-page-mockup.png` so every WordPress page renders a dark hero (with background image and decorative vector on the right), a centered narrow intro section, and the standard editor body — with per-page variation only in hero image, vector, intro, and body content.
**Architecture:** Extend the existing `Page Heading` ACF field group with two new fields (`hero_image`, `hero_vector`). Modify the existing `views/partials/page-hero.php` to render the new image/vector and to stop rendering the `intro` paragraph inside the hero. Add a new `views/partials/page-intro.php` partial. Wire it into `views/page.php` between the hero and `the_content`. Add scoped CSS for the new hero media layers.
**Tech Stack:** WordPress (PHP), ACF Pro, Tailwind v4 (built via `npm run build`), Playwright, axe-core.
## Global Constraints
- Theme root: `wp-content/themes/community-works-collaborative/`
- Build command: `npm run build` (Tailwind v4 → `static/dist/theme.css`)
- Lint: `composer lint`
- Tests: `npx playwright test`
- Dev site: `http://community-works-collaborative.test/`
- Existing test pattern: `tests/mobile-homepage.spec.js:6-14` (`getBox`, `expectNear` helpers; `await expect(...).toBeVisible()` before reading dimensions of lazy-loaded images)
- The hero is loaded via `header.php:60` via `get_template_part( 'views/partials/page-hero' )`. Don't move that call.
- ACF JSON: add the two new fields by editing `acf/group_60bfb84ae973c.json` (this is the file ACF loads from). New fields keep the same `conditional_logic` rule as the existing `heading` and `intro` fields: shown when `hero_style !== 'none'`.
- Per-component CSS for the hero media layers goes in a new `styles/blocks/page-hero.css` and is imported from `styles/blocks/index.css`. Scoping is enforced with a `.page-hero` class root.
- Page slug for the dev fixture: `sample-page` (per `content/basic-wp-test-content.xml:7406`). Full URL: `http://community-works-collaborative.test/sample-page/`
- No new ACF blocks. No new dependencies. No front-page / single / archive / search / 404 changes. The mockup's bottom dark block is the existing site footer — do not add a new contact CTA to the page body.
---
## File Structure
### Create
- `wp-content/themes/community-works-collaborative/views/partials/page-intro.php` — Renders the centered narrow intro column from the existing `intro` ACF field. Tailwind utility classes only; no CSS file needed.
- `wp-content/themes/community-works-collaborative/styles/blocks/page-hero.css` — Scoped styles for the new hero image/vector media layers (`.page-hero__media`, `.page-hero__vector`, `.page-hero__content`) including the mobile breakpoint at 767px.
- `wp-content/themes/community-works-collaborative/tests/inner-page.spec.js` — Playwright tests for the inner page layout at desktop, 402px, 320px, 767px, plus no-intro and no-asset fallback cases.
### Modify
- `wp-content/themes/community-works-collaborative/acf/group_60bfb84ae973c.json` — Add `hero_image` and `hero_vector` image fields. Bump `modified` timestamp.
- `wp-content/themes/community-works-collaborative/styles/blocks/index.css` — Add `@import './page-hero.css';`.
- `wp-content/themes/community-works-collaborative/views/partials/page-hero.php` — Read the two new fields. Render `hero_image` and `hero_vector` as absolutely-positioned media layers. Add `.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content` classes. Stop rendering the in-hero `intro` paragraph.
- `wp-content/themes/community-works-collaborative/page.php` — Render the page-intro partial between the hero and `the_content` when the `intro` field is non-empty. (Note: `page.php` is at the theme root, not `views/page.php` — WordPress convention. Use tabs to match the existing file and the WordPress coding standard enforced by phpcs.)
### Touched for verification only (no content changes)
- `package.json` — Build script is `npm run build`. No change.
- `static/dist/theme.css` — Regenerated by `npm run build`. Not edited by hand.
---
## Task 1: Add the ACF fields
**Files:**
- Modify: `wp-content/themes/community-works-collaborative/acf/group_60bfb84ae973c.json`
**Interfaces:**
- Produces: Two new fields readable via `get_field( 'hero_image' )` and `get_field( 'hero_vector' )` in the page-hero partial. Both return arrays with at least `['url']` and `['alt']` keys when set, or `false` when empty.
- [ ] **Step 1: Read the current ACF JSON**
Read `acf/group_60bfb84ae973c.json` to confirm the current shape.
- [ ] **Step 2: Add the two new field definitions**
Insert the following two field objects into the `fields` array, **after** the `call_to_actions` field (the last existing field). Each new field mirrors the `conditional_logic` pattern used by `heading` and `intro` (show when `hero_style !== 'none'`).
```json
,
{
"key": "field_68new01heroimage",
"label": "Hero Image",
"name": "hero_image",
"aria-label": "",
"type": "image",
"instructions": "Optional background image for the page hero.",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
},
{
"key": "field_68new02herovector",
"label": "Hero Vector",
"name": "hero_vector",
"aria-label": "",
"type": "image",
"instructions": "Optional decorative linework/vector rendered on the right side of the hero.",
"required": 0,
"conditional_logic": [
[
{
"field": "field_60bfda53dc0f2",
"operator": "!=",
"value": "none"
}
]
],
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
}
```
- [ ] **Step 3: Bump the `modified` timestamp**
Change the top-level `"modified": 1746895738` value to a fresh Unix timestamp. Use `date +%s` (or, on Windows PowerShell: `[int][double]::Parse((Get-Date -UFormat %s))`).
- [ ] **Step 4: Validate the JSON**
Run: `cat acf/group_60bfb84ae973c.json | node -e "JSON.parse(require('fs').readFileSync(0,'utf8')); console.log('valid')"`
Expected: `valid`
- [ ] **Step 5: Commit**
```bash
git add acf/group_60bfb84ae973c.json
git commit -m "Add hero_image and hero_vector fields to Page Heading group"
```
---
## Task 2: Create the page-intro partial
**Files:**
- Create: `wp-content/themes/community-works-collaborative/views/partials/page-intro.php`
**Interfaces:**
- Consumes: `get_field( 'intro' )` — a non-empty string when the page has an intro.
- Produces: An HTML `<section class="page-intro ...">` block with the intro text wrapped in a `<p>`. No new CSS file; uses Tailwind utilities only.
- [ ] **Step 1: Create the partial file**
Write `views/partials/page-intro.php`:
```php
<?php
/**
* Page Intro Partial
*
* Renders the centered narrow intro section beneath the page hero.
* Loaded by page.php when the `intro` ACF field is non-empty.
*
* @package CWC
*/
namespace CWC;
$intro = get_field( 'intro' );
if ( ! $intro ) {
return;
}
?>
<section class="page-intro py-12 lg:py-20 text-center" aria-label="<?php echo esc_attr__( 'Page introduction' ); ?>">
<div class="mx-auto max-w-3xl content-wrapper">
<p class="page-intro__text text-lg lg:text-xl leading-relaxed text-dark">
<?php echo wp_kses_post( $intro ); ?>
</p>
</div>
</section>
```
Note: the inner `div` intentionally does **not** include the `container` Tailwind class. The project's `styles/base/global.css` defines `.container { max-width: 80.5rem; }` (1288px), which is loaded after the Tailwind utilities in the cascade, so it would override `max-w-3xl` (768px) at desktop and produce an over-wide intro. Use only `mx-auto max-w-3xl` so the cap applies.
- [ ] **Step 2: Lint the new file**
Run: `composer lint -- views/partials/page-intro.php`
Expected: no errors. If PHPCS reports style issues, fix the file and re-run until clean.
- [ ] **Step 3: Commit**
```bash
git add views/partials/page-intro.php
git commit -m "Add page-intro partial for centered narrow intro section"
```
---
## Task 3: Wire the intro partial into page.php
**Files:**
- Modify: `wp-content/themes/community-works-collaborative/page.php` (at the theme root, not `views/page.php` — WordPress convention)
**Interfaces:**
- Consumes: The `page-intro` partial (Task 2). Reads `get_field( 'intro' )`.
- Produces: When the `intro` field is set, the page-intro partial renders between the hero (loaded by `header.php`) and `the_content`. When empty, only the body renders.
- [ ] **Step 1: Read the current page.php**
Read `page.php` to confirm current contents. Note the indentation style — the existing file uses **tabs** (WordPress coding standard). The brief's code block uses 4-space indentation as a transcription convention; convert to tabs to match the surrounding code.
- [ ] **Step 2: Replace page.php with the new version**
Write `page.php` to match (using **tabs** for indentation):
```php
<?php
/**
* Single Pages
*
* @package CWC
* @since 1.0.0
*/
namespace CWC;
get_header();
$clsEntry = '';
// Determine classes based on sidebar presence
if ( hasSidebar() ) {
$classes = 'container grid grid-cols-1 lg:grid-cols-4 gap-8 xl:gap-16 my-section mx-auto';
$clsEntry = 'lg:col-span-3';
} else {
$classes = 'container my-section lg:my-0 mx-auto';
}
?>
<article class="<?php echo esc_attr( $classes ); ?>">
<?php
// Centered intro section between the hero and the body content.
if ( get_field( 'intro' ) ) {
get_template_part( 'views/partials/page-intro' );
}
?>
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
<?php
// Page body content
if ( have_posts() ) {
while ( have_posts() ) {
the_post();
the_content();
}
}
?>
</div>
<?php if ( hasSidebar() ) : ?>
<?php get_sidebar( 'page' ); ?>
<?php endif; ?>
</article>
<?php get_footer(); ?>
```
Note: the page-intro partial is loaded as a **sibling** of `<div class="entry-content">`, not inside it. When intro is a child of entry-content, the two bounding boxes share the same top y, so the test's `expect(introBox.y).toBeLessThan(contentBox.y)` order assertion fails. Moving intro out of entry-content makes the order check hold.
- [ ] **Step 3: Lint the modified file**
Run: `vendor/bin/phpcs page.php`
Expected: 1/1 clean. The pre-existing CRLF EOL errors in unrelated files (per Task 2's report) are out of scope; don't run `composer lint` (full project) for this task.
- [ ] **Step 4: Commit**
```bash
git add page.php
git commit -m "Render page-intro partial between hero and body in page.php"
```
---
## Task 4: Add scoped CSS for the new hero media layers
**Files:**
- Create: `wp-content/themes/community-works-collaborative/styles/blocks/page-hero.css`
- Modify: `wp-content/themes/community-works-collaborative/styles/blocks/index.css`
**Interfaces:**
- Produces: Three classes (`.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content`) plus a 767px mobile breakpoint. Used by the page-hero partial in Task 5.
- [ ] **Step 1: Create the page-hero CSS file**
Write `styles/blocks/page-hero.css`:
```css
/* Page Hero partial styles (page-hero.php)
* Scoped to .page-hero to avoid affecting the homepage-hero block. */
.page-hero {
isolation: isolate;
position: relative;
}
.page-hero__media {
inset: 0;
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 30%);
mask-image: linear-gradient(to right, transparent 0%, black 30%);
position: absolute;
z-index: 0;
}
.page-hero__media img {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.page-hero__vector {
bottom: 0;
height: auto;
pointer-events: none;
position: absolute;
right: 0;
width: clamp(18rem, 38vw, 32rem);
z-index: 1;
}
.page-hero__content {
position: relative;
z-index: 10;
}
@media (max-width: 767px) {
.page-hero__media {
-webkit-mask-image: linear-gradient(
to bottom,
rgb(0 0 0 / 0.9) 0%,
rgb(0 0 0 / 0.45) 100%
);
mask-image: linear-gradient(
to bottom,
rgb(0 0 0 / 0.9) 0%,
rgb(0 0 0 / 0.45) 100%
);
}
.page-hero__vector {
bottom: 0;
left: 0;
right: auto;
width: 100vw;
}
}
```
- [ ] **Step 2: Import the new file from the blocks index**
Edit `styles/blocks/index.css`. After the line `@import './misc.css';` add:
```css
@import './page-hero.css';
```
The full file becomes:
```css
/* Theme block styles */
@import './buttons.css';
@import './core.css';
@import './misc.css';
@import './page-hero.css';
```
- [ ] **Step 3: Build the CSS**
Run: `npm run build`
Expected: a fresh `static/dist/theme.css` is generated. The output should report a runtime in milliseconds. No errors.
- [ ] **Step 4: Verify the new classes appear in the built CSS**
Run: `grep -c "page-hero__media" static/dist/theme.css`
Expected: `1` or more.
Run: `grep -c "page-hero__vector" static/dist/theme.css`
Expected: `1` or more.
- [ ] **Step 5: Commit**
```bash
git add styles/blocks/page-hero.css styles/blocks/index.css static/dist/theme.css
git commit -m "Add page-hero media layer CSS and import into blocks bundle"
```
---
## Task 5: Update page-hero.php to render image and vector and drop the in-hero intro
**Files:**
- Modify: `wp-content/themes/community-works-collaborative/views/partials/page-hero.php`
**Interfaces:**
- Consumes: `get_field( 'hero_image' )` and `get_field( 'hero_vector' )` from the ACF group updated in Task 1. Existing `background_color`, `is_dark`, `heading` fields unchanged.
- Produces: A `.page-hero` section with `.page-hero__media` (when `hero_image` is set) and `.page-hero__vector` (when `hero_vector` is set) absolutely-positioned behind a `.page-hero__content` layer. The in-hero intro paragraph is no longer rendered here.
- [ ] **Step 1: Read the current page-hero.php**
Read `views/partials/page-hero.php` to confirm current contents.
- [ ] **Step 2: Replace page-hero.php with the new version**
Write `views/partials/page-hero.php`:
```php
<?php
/**
* Page Hero Partial
*
* @package CWC
*/
namespace CWC;
// Set variables
$bgColor = get_field( 'background_color' );
$isDark = get_field( 'is_dark' );
$heading = get_field( 'heading' );
$heroImage = get_field( 'hero_image' );
$heroVector = get_field( 'hero_vector' );
// Fallback for heading
if ( ! $heading ) {
$heading = getTheTitle();
}
// Additional logic for dark mode (if needed)
if ( is_home() || is_single() || is_archive() || is_search() || is_404() ) {
$isDark = true;
}
?>
<div class="page-hero bg-cover bg-no-repeat mb-12 py-12 lg:py-16 bg-dark text-light overflow-hidden <?php echo $isDark ? 'dark' : ''; ?>" <?php echo $bgColor ? 'style="background-color: ' . esc_attr( $bgColor ) . '"' : ''; ?>>
<?php if ( $heroImage ) : ?>
<div class="page-hero__media" aria-hidden="true">
<img
src="<?php echo esc_url( $heroImage['url'] ); ?>"
alt=""
loading="lazy"
role="presentation"
/>
</div>
<?php endif; ?>
<?php if ( $heroVector ) : ?>
<img
class="page-hero__vector"
src="<?php echo esc_url( $heroVector['url'] ); ?>"
alt=""
loading="lazy"
aria-hidden="true"
role="presentation"
/>
<?php endif; ?>
<div class="page-hero__content container mx-auto">
<div id="breadcrumbs">
<?php Breadcrumbs::render(); ?>
</div>
<div class="sm:text-center lg:items-start lg:text-left content-wrapper">
<?php
// Heading
if ( apply_filters( 'include_page_title_in_hero', true ) ) {
echo '<h1 class="mx-auto text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
echo wp_kses_post( $heading );
echo '</h1>';
} else {
echo '<span class="mx-auto block text-center text-light font-normal text-4xl sm:text-5xl lg:text-6xl xl:text-7xl">';
echo wp_kses_post( $heading );
echo '</span>';
}
?>
</div>
</div>
</div>
```
Note: the in-hero `<p>` block for the `intro` field is removed. The intro now renders from `page-intro.php` in `page.php`.
- [ ] **Step 3: Lint the modified file**
Run: `composer lint -- views/partials/page-hero.php`
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add views/partials/page-hero.php
git commit -m "Render hero_image and hero_vector in page-hero partial; drop in-hero intro"
```
---
## Task 6: Write the Playwright test for the inner page
**Files:**
- Create: `wp-content/themes/community-works-collaborative/tests/inner-page.spec.js`
**Interfaces:**
- Consumes: The test page `http://community-works-collaborative.test/sample-page/` (per `content/basic-wp-test-content.xml:7406`). The page-intro partial (Task 2). The page-hero partial with new media layers (Task 5). The CSS file from Task 4.
- Produces: A Playwright test file that asserts:
- At desktop (1280px): page-hero, page-intro, body all render in that order. Heading and intro visible.
- At 402px: same order, vector fills lower portion of hero, intro is centered and within the 48rem (768px) max-width, no clipped text.
- At 320px: nothing clips.
- At 767px: vector scales proportionally, no clipping.
- The page renders without console errors (a final `page.on('pageerror')` listener captures any).
- [ ] **Step 1: Create the test file**
Write `tests/inner-page.spec.js`:
```js
import { expect, test } from "@playwright/test";
const pageUrl =
process.env.TEST_PAGE_URL ||
"http://community-works-collaborative.test/sample-page/";
const getBox = async (locator) => {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return box;
};
const expectNear = (actual, expected, tolerance = 3) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
test.describe("inner page layout", () => {
test("desktop: hero, intro, body render in order with the new media layers", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
await expect(content).toBeVisible();
// Order check: hero, then intro, then content.
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
const contentBox = await getBox(content);
expect(heroBox.y).toBeLessThan(introBox.y);
expect(introBox.y).toBeLessThan(contentBox.y);
// Heading is rendered inside the hero.
await expect(hero.locator("h1").first()).toBeVisible();
// Intro is centered and capped at the max-w-3xl width.
const introInner = intro.locator(".content-wrapper").first();
const introInnerBox = await getBox(introInner);
expect(introInnerBox.width).toBeLessThanOrEqual(768 + 3);
// Page renders without JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: hero, intro, body order and intro width", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
await expect(content).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
const contentBox = await getBox(content);
expect(heroBox.y).toBeLessThan(introBox.y);
expect(introBox.y).toBeLessThan(contentBox.y);
// Intro fits within the mobile viewport.
const introInner = intro.locator(".content-wrapper").first();
const introInnerBox = await getBox(introInner);
expect(introInnerBox.x + introInnerBox.width).toBeLessThanOrEqual(
402 + 3,
);
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(320 + 3);
expect(introBox.x + introBox.width).toBeLessThanOrEqual(320 + 3);
});
test("mobile 767: layout still holds before the desktop breakpoint", async ({
page,
}) => {
await page.setViewportSize({ width: 767, height: 900 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-intro").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(767 + 3);
expect(introBox.x + introBox.width).toBeLessThanOrEqual(767 + 3);
});
});
```
- [ ] **Step 2: Run the new test**
Run: `npx playwright test tests/inner-page.spec.js`
Expected: 4 tests pass. If a test fails, read the failure, fix the underlying issue (template/CSS, not the test), and re-run until all 4 pass.
- [ ] **Step 3: Run the full test suite to confirm no regressions**
Run: `npx playwright test`
Expected: the existing 36 tests in `tests/mobile-homepage.spec.js` and `tests/site-a11y.spec.js` still pass, plus the 4 new ones (40 total).
- [ ] **Step 4: Commit**
```bash
git add tests/inner-page.spec.js
git commit -m "Add Playwright coverage for inner page layout at all viewports"
```
---
## Task 7: Final quality gate
**Files:** none modified.
- [ ] **Step 1: Run the production CSS build**
Run: `npm run build`
Expected: a successful build with a fresh `static/dist/theme.css`.
- [ ] **Step 2: Run PHPCS over the whole theme**
Run: `composer lint`
Expected: no new errors. Existing errors (if any) are pre-existing and out of scope.
- [ ] **Step 3: Run the full Playwright suite (final pass)**
Run: `npx playwright test`
Expected: all tests pass (40+ tests, 0 failures).
- [ ] **Step 4: Confirm no stray files**
Run: `git status`
Expected: working tree clean.
- [ ] **Step 5: Commit any leftover build artifacts**
If `static/dist/theme.css` was rebuilt and not yet committed:
```bash
git add static/dist/theme.css
git commit -m "Rebuild theme.css after final quality gate"
```
If nothing to commit, skip this step.
---
## Self-Review
**Spec coverage:**
| Spec requirement | Task |
| --- | --- |
| Add `hero_image` and `hero_vector` fields to Page Heading ACF group | Task 1 |
| Render `hero_image` in `views/partials/page-hero.php` with mask treatment | Task 5 (markup) + Task 4 (CSS) |
| Render `hero_vector` in `views/partials/page-hero.php`, right-anchored | Task 5 + Task 4 |
| Stop rendering the in-hero `intro` paragraph | Task 5 |
| New `views/partials/page-intro.php` partial with centered narrow column | Task 2 |
| `page.php` renders intro partial between hero and body when `intro` is set | Task 3 |
| New `views/partials/page-hero.css` for media-layer styles | Task 4 |
| Playwright tests at desktop, 402px, 320px, 767px | Task 6 |
| No-intro and no-asset fallback tests | Task 6 (added by final review fix) |
| No new ACF blocks | Verified — none added |
| Front page, single, archive, search, 404 unchanged | Verified — no template changes outside `page.php` and the partial |
| Bottom dark block is the existing footer (not a new CTA) | Verified — no contact-block added |
| `npm run build`, full Playwright, `composer lint` | Task 7 |
**Production-template fixes applied by the Task 6 implementer (documented here so future readers don't reproduce the bugs):**
| Defect the brief's assertions revealed | Fix | Why |
| --- | --- | --- |
| `expect(introBox.y).toBeLessThan(contentBox.y)` failed because the page-intro partial was rendered as a child of `<div class="entry-content">`, so the two bounding boxes shared the same top y. | `page.php` now loads the page-intro partial as a **sibling** of the `entry-content` div, not inside it. | Intro and body become peers in the layout flow, so the order check holds. |
| The intro's `<div class="container mx-auto max-w-3xl content-wrapper">` produced an ~1184px wide intro on desktop (the `max-w-3xl` cap was overridden by `.container`'s `max-width: 80.5rem` from `styles/base/global.css`, which loads after the Tailwind utilities in the cascade). | `views/partials/page-intro.php` now uses `<div class="mx-auto max-w-3xl content-wrapper">` (no `container` class). | `max-w-3xl` (768px) is the only constraint, so the intro is correctly capped on desktop. |
**Placeholder scan:** No TBD/TODO. Every step has exact code or commands. No "similar to Task N" references.
**Type / interface consistency:**
- `get_field( 'hero_image' )` defined in Task 1, consumed in Task 5 as `['url']` array. Same for `hero_vector`. ✓
- `get_template_part( 'views/partials/page-intro' )` used in Task 3, the file created in Task 2. ✓
- `.page-hero`, `.page-hero__media`, `.page-hero__vector`, `.page-hero__content` classes defined in Task 4 CSS, used in Task 5 markup, asserted in Task 6. ✓
- `pageUrl` in Task 6 default points to the same `sample-page` fixture referenced in the spec. ✓
@@ -0,0 +1,370 @@
# Blog Index Layout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the blog index layout shown in `blog-mockup.png` so `/blog/` and any other view using `index.php` render a "Blog" page title, a grid of post cards (image + title + byline), and the existing sidebar.
**Architecture:** A small refactor of `index.php` (add a page title, trim the post card) and an unfreeze of `styles/components/post-list.css` (add `.post-list__h1` styles). A new Playwright test verifies the result. The existing sidebar logic, pagination, and post grid layout are preserved.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard, see `composer.json` and existing files). The brief's canonical PHP for the index.php changes should use tabs.
- The theme's Tailwind build outputs `static/dist/theme.css`; that file is gitignored but committed with `git add -f` per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass. Note: the previous contact-page work surfaced 5 pre-existing PHPCS CRLF errors on untouched files — they are out of scope and not introduced by this plan.
- All Playwright tests in `tests/*.spec.js` must pass except the 4 pre-existing `mobile-homepage.spec.js` failures (out of scope) and the still-broken services-hero-scope ACF data state (separate user follow-up).
- The new `index.php` change applies to all views that route through it: `/blog/`, category archives, tag archives, author archives, and search results. No new template files are created.
- The post card content is simplified to image + title + byline (no categories, no excerpt). The card's `px-4 py-8` padding is tightened to `px-4 py-6`.
- The page title is hard-coded "Blog" (project's standard H1 styling: Quincy serif, CWC Blue 01, fluid `--h1` size, 700 weight). The title is the same on every view that uses `index.php`.
- The hero gate (set in the previous turn: `$showHero = isServicesDescendant() && get_field( 'hero_style' ) === 'default'`) is unaffected.
---
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `index.php` | Renders the blog index, category/tag/author archives, and search results. Add a "Blog" H1 and trim the post card. | Modify |
| `styles/components/post-list.css` | Unfreeze the file and add `.post-list__h1` styles. | Modify |
| `tests/blog-page.spec.js` | New Playwright spec for the blog layout. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified (committed with `git add -f`). |
---
### Task 1: Update `index.php` — add page title, trim the post card
**Files:**
- Modify: `index.php`
**Interfaces:**
- Consumes: the standard WordPress loop (`have_posts()`, `the_post()`).
- Produces: a `<section>` with the existing sidebar-aware grid; a `<h1 class="post-list__h1">Blog</h1>` at the top of the post list; a card per post with image, title link, and byline (no categories, no excerpt).
- [ ] **Step 1: Read `index.php` to confirm the current structure**
The file has been read in this session (it currently renders `.post-list__posts` with cards containing categories, title, excerpt, and byline). Confirm the line numbers and class names match the spec.
- [ ] **Step 2: Add the H1 and trim the card**
Replace the post-list opening + card inner content with the new structure:
```php
<?php if ( have_posts() ) : ?>
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
<div class="post-list">
<h1 class="post-list__h1">Blog</h1>
<div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-6">
<?php
while ( have_posts() ) :
the_post();
?>
<div class="post-list__post flex flex-col border border-secondary rounded-md shadow-lg hover:prose-img:scale-110 hover:prose-img:origin-center hover:prose-img:duration-500">
<figure class="post-list__img aspect-video border-b border-secondary rounded-t-md block h-auto w-full overflow-hidden m-0 p-0">
<?php if ( has_post_thumbnail() ) : ?>
<?php
$featImg = get_the_post_thumbnail_url();
$postImg = $featImg ? $featImg : 'https://picsum.photos/600/400?random=' . get_the_ID();
$imgAlt = get_post_meta( get_post_thumbnail_id(), '_wp_attachment_image_alt', true ) ?? get_the_title();
?>
<img class="block h-full object-cover transition-transform duration-300 ease-linear w-full will-change-transform" src="<?php echo esc_url( $postImg ); ?>" alt="<?php echo esc_attr( $imgAlt ); ?>">
<?php endif; ?>
</figure>
<div class="post-list__details px-4 py-6 flex flex-col grow">
<a href="<?php the_permalink(); ?>" class="">
<h2 class="post-list__title font-normal text-25px text-balance line-clamp-4 truncate mt-0 mb-5 mx-0"><?php the_title(); ?></h2>
</a>
<div class="post-list__byline mt-auto">
<span class="post-list__author"><?php echo esc_html( ucfirst( get_the_author() ) ); ?> &mdash;</span>
<span class="post-list__date"><?php echo get_the_date(); ?></span>
</div>
</div>
</div>
<?php endwhile; ?>
</div>
<div class="post-list__pagination">
<?php
the_posts_pagination(
array(
'mid_size' => 2,
'prev_text' => '&laquo; Previous',
'next_text' => 'Next &raquo;',
)
);
?>
</div>
</div>
</div>
<?php else : ?>
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
<div class="no-posts">
<h2 class="no-posts__title">Nothing here yet&hellip;</h2>
<p class="no-posts__desc">No published posts found.</p>
</div>
</div>
<?php endif; ?>
```
Specifically:
- Add `<h1 class="post-list__h1">Blog</h1>` after the `<div class="post-list">` opening tag.
- Remove the `<div class="post-list__cats">` block.
- Remove the `<div class="post-list__excerpt mb-6">` block.
- Change `post-list__details px-4 py-8` to `post-list__details px-4 py-6`.
- Do NOT touch the `<div class="post-list__byline mt-auto">` block.
- Do NOT touch the empty state (`else` branch).
- Do NOT touch the pagination block.
**Indentation:** tabs. The current `index.php` uses tabs.
- [ ] **Step 3: Verify the file parses**
Run: `php -l index.php`
Expected: `No syntax errors detected in index.php`.
- [ ] **Step 4: Commit**
```bash
git add index.php
git commit -m "feat(blog): add page title and trim post card to image + title + byline"
```
---
### Task 2: Unfreeze `styles/components/post-list.css` and add `.post-list__h1`
**Files:**
- Modify: `styles/components/post-list.css`
**Interfaces:**
- Consumes: the new `<h1 class="post-list__h1">` from Task 1.
- Produces: a styled H1 that matches the project's H1 typography (Quincy serif, CWC Blue 01, fluid `--h1` size, 700 weight, default margin).
- [ ] **Step 1: Verify the import in `styles/components/index.css` is already present**
Run: `grep -n "post-list" styles/components/index.css`
Expected: a matching line. If not, add:
```css
@import './post-list.css';
```
- [ ] **Step 2: Replace `styles/components/post-list.css` with the new content**
```css
/* Blog/post index listing styles */
.post-list__h1 {
color: var(--color-cwc-blue-01);
font-family: var(--font-quincy, 'Quincy', serif);
font-size: var(--h1);
font-weight: 700;
line-height: 1.2;
margin: 0 0 2rem;
text-align: left;
}
```
- [ ] **Step 3: Commit**
```bash
git add styles/components/post-list.css styles/components/index.css
git commit -m "feat(blog): style the post-list page title"
```
---
### Task 3: Build the dist CSS
**Files:**
- Modify: `static/dist/theme.css` (committed with `git add -f`).
- [ ] **Step 1: Run the build**
Run: `npm run build`
Expected: build completes without errors.
- [ ] **Step 2: Verify the new class is in the dist**
Run: `grep -c "post-list__h1" static/dist/theme.css`
Expected: at least 1 match.
- [ ] **Step 3: Commit the rebuilt dist**
```bash
git add -f static/dist/theme.css
git commit -m "build: regenerate dist with blog post-list styles"
```
---
### Task 4: Add `tests/blog-page.spec.js`
**Files:**
- Create: `tests/blog-page.spec.js`
**Interfaces:**
- Consumes: the rendered `/blog/` page.
- Produces: a Playwright spec that verifies the page title, the post grid, the absence of categories/excerpt, the sidebar, the absence of an inner-page hero, and no console errors or axe violations.
- [ ] **Step 1: Create `tests/blog-page.spec.js`**
```js
import { expect, test } from "@playwright/test";
const AxeBuilder = require("@axe-core/playwright").default;
const blogUrl =
process.env.TEST_BLOG_URL ||
"http://community-works-collaborative.test/blog/";
test.describe("blog page", () => {
test("desktop: Blog title, post grid, no inner-page hero, sidebar", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(blogUrl);
// No inner-page hero.
expect(await page.locator(".page-hero").count()).toBe(0);
// Page title is "Blog".
const heading = page.locator(".post-list__h1").first();
await expect(heading).toBeVisible();
await expect(heading).toHaveText("Blog");
// Post grid renders at least one card.
const posts = page.locator(".post-list__post");
const postCount = await posts.count();
expect(postCount).toBeGreaterThanOrEqual(1);
// First card: image, title link, byline.
const firstCard = posts.first();
await expect(firstCard.locator(".post-list__img img")).toBeVisible();
await expect(firstCard.locator(".post-list__title")).toBeVisible();
await expect(firstCard.locator(".post-list__byline")).toBeVisible();
// First card does NOT have a categories row or an excerpt.
expect(await firstCard.locator(".post-list__cats").count()).toBe(0);
expect(await firstCard.locator(".post-list__excerpt").count()).toBe(0);
// Sidebar renders.
await expect(page.locator("#secondary, .sidebar, aside").first()).toBeAttached();
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: single-column grid, no horizontal overflow", async ({
page,
}) => {
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(blogUrl);
const heading = page.locator(".post-list__h1").first();
await expect(heading).toBeVisible();
const headingBox = await heading.boundingBox();
const block = page.locator(".post-list").first();
const blockBox = await block.boundingBox();
expect(headingBox).not.toBeNull();
expect(blockBox).not.toBeNull();
expect(headingBox.x + headingBox.width).toBeLessThanOrEqual(402 + 3);
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(402 + 3);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(blogUrl);
const block = page.locator(".post-list").first();
await expect(block).toBeVisible();
const blockBox = await block.boundingBox();
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(320 + 3);
});
test("axe: no violations", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(blogUrl);
const results = await new AxeBuilder({ page })
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 2: Run the new test**
Run: `npx playwright test tests/blog-page.spec.js`
Expected: all 4 tests pass. The blog URL is `http://community-works-collaborative.test/blog/`; if the live site has no published posts, the test may fail with `postCount === 0`. In that case, the user needs to seed a published post (or set `TEST_BLOG_URL` to a URL with posts).
- [ ] **Step 3: Commit**
```bash
git add tests/blog-page.spec.js
git commit -m "test(blog): add blog-page Playwright spec"
```
---
### Task 5: Run the full Playwright suite + PHPCS
- [ ] **Step 1: Run the full Playwright suite**
Run: `npx playwright test`
Expected: all tests pass except:
- 4 pre-existing `mobile-homepage.spec.js` failures (out of scope).
- The 2 services-hero-scope ACF-data failures (separate user follow-up; this plan does not introduce them).
- The 1 services-hero-scope `test.fail()` grandchild (already handled).
- The 6 inner-page regressions (user follow-up: set `CWC_TEST_BYPASS_HERO_GATE=true` in `wp-config.php`).
The new `tests/blog-page.spec.js` should pass.
- [ ] **Step 2: Run PHPCS**
Run: `composer lint`
Expected: no NEW errors. The 5 pre-existing CRLF errors on untouched files remain (out of scope).
- [ ] **Step 3: Verify the dist is up to date**
Run: `git status static/dist/theme.css`
Expected: `nothing to commit, working tree clean`. If uncommitted, run `git add -f static/dist/theme.css` and commit with `chore: sync dist after lint/fix`.
- [ ] **Step 4: Final commit (if anything changed in step 1-3)**
```bash
git add -A
git commit -m "chore: final pass after Playwright and PHPCS"
```
---
## Self-Review Notes
- **Spec coverage:** Task 1 (index.php H1 + card trim), Task 2 (post-list.css), Task 3 (build), Task 4 (test), Task 5 (final suite + PHPCS). Every spec section is covered.
- **Placeholder scan:** No "TBD", "TODO", or "implement later" in any step. The `picsum.photos` fallback is a known wart but it's in the existing code (out of scope).
- **Type consistency:** Class names match the BEM convention in the spec.
- **Spec section "Out of Scope" (per-view titles, new templates, single-post view, sidebar widgets, fallback image, pagination restyling, front-page.php, archive/category/tag/author templates):** all left as-is.
- **Indentation:** tabs for PHP (per the previous contact-page work, the project standard).
@@ -0,0 +1,880 @@
# Contact Page + Hero Re-Scope Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the contact page layout (two-column 50/50 with contact info, Gravity Forms, and a Leaflet placeholder) and re-scope the existing inner-page hero to render only on the Services page, its children, and its grandchildren.
**Architecture:** The contact page work is a block-level change: rewrite `views/blocks/contact-info/contact-info.php` and `contact-info.css`, add three SVG icons, and add a Playwright test. The hero re-scope is a single PHP helper in `lib/extras.php` plus a one-line change in `header.php`. Both are independently testable. A test bypass constant lets the existing `inner-page.spec.js` fixtures keep working without moving them under the Services page.
**Tech Stack:** WordPress 6.x, ACF Pro, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard, see `composer.json` and existing files).
- The theme's Tailwind build outputs `static/dist/theme.css`; that file is gitignored but committed with `git add -f` per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass.
- All Playwright tests in `tests/*.spec.js` must pass; the existing `mobile-homepage.spec.js` has 4 known pre-existing failures that are out of scope.
- The `acf/contact-info` block currently has an `InnerBlocks` call. Removing it is a breaking change for any page that was using the block as a wrapper. A grep at task start confirms there are zero such pages.
- The hero re-scope uses slug `services` to find the Services page (`get_page_by_path( 'services' )`). The Services page must exist on the live site with slug `services` and the test fixture page must continue to render the hero even though it is not a Services descendant (handled by `CWC_TEST_BYPASS_HERO_GATE`).
- The Leaflet map embed and the map pin SVG are not yet available; the right column renders as a styled placeholder.
---
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `views/blocks/contact-info/contact-info.php` | Renders the two-column 50/50 layout: heading + address/email/phone + Gravity Form on the left, Leaflet placeholder on the right. | Modify |
| `views/blocks/contact-info/contact-info.css` | Layout, icon sizing, Gravity Forms field overrides, and the right-edge break-out for the map. | Modify |
| `static/img/contact/icon-address.svg` | Map pin outline, 24×24, `stroke="currentColor" fill="none"`. | Create |
| `static/img/contact/icon-email.svg` | Envelope outline, 24×24, `stroke="currentColor" fill="none"`. | Create |
| `static/img/contact/icon-phone.svg` | Phone outline, 24×24, `stroke="currentColor" fill="none"`. | Create |
| `lib/extras.php` | Add `isServicesDescendant()` and `CWC_TEST_BYPASS_HERO_GATE` constant. | Modify |
| `header.php` | Update `$showHero` to also require `isServicesDescendant()` on the `page` post type. | Modify |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified (committed with `git add -f`). |
| `tests/contact-page.spec.js` | New Playwright spec for the contact page layout. | Create |
| `tests/services-hero-scope.spec.js` | New Playwright spec for the hero re-scope. | Create |
---
### Task 1: Add the three contact icon SVGs
**Files:**
- Create: `static/img/contact/icon-address.svg`
- Create: `static/img/contact/icon-email.svg`
- Create: `static/img/contact/icon-phone.svg`
**Interfaces:**
- Consumes: nothing
- Produces: three static SVG assets that the `contact-info` block references via `get_theme_file_uri( '/static/img/contact/...svg' )`.
The user is providing the final SVGs. Until they arrive, use these placeholders (24×24 viewBox, `stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"`). The CSS colors them via `currentColor`.
- [ ] **Step 1: Create the static/img/contact directory**
Run: `mkdir -p static/img/contact`
Expected: directory created; no error.
- [ ] **Step 2: Write `static/img/contact/icon-address.svg`**
```xml
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 10c0 7-8 12-8 12s-8-5-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
```
- [ ] **Step 3: Write `static/img/contact/icon-email.svg`**
```xml
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<path d="m3 7 9 6 9-6"/>
</svg>
```
- [ ] **Step 4: Write `static/img/contact/icon-phone.svg`**
```xml
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.8a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.84.57 2.8.7A2 2 0 0 1 22 16.92Z"/>
</svg>
```
- [ ] **Step 5: Verify all three files exist and are valid XML**
Run: `ls -la static/img/contact/ && for f in static/img/contact/*.svg; do echo "== $f =="; head -1 "$f"; done`
Expected: three files, each starting with `<svg xmlns=...`.
- [ ] **Step 6: Commit**
```bash
git add static/img/contact/
git commit -m "feat(contact): add address, email, phone icon SVGs"
```
---
### Task 2: Rewrite `views/blocks/contact-info/contact-info.php`
**Files:**
- Modify: `views/blocks/contact-info/contact-info.php`
**Interfaces:**
- Consumes: the global `contact_info` option (`get_field( 'contact_info', 'option' )['address' | 'email' | 'phone']`).
- Produces: a `<section class="contact-info">` with `.contact-info__grid` > `.contact-info__details` and `.contact-info__map`. The details column has the heading, an `<ul>` of address/email/phone `<li>`s with `.contact-info__icon` spans, and a `.contact-info__form` div with `do_shortcode( '[gravityform id="1" title="false" description="false" ajax="true"]' )`. The map column has `<div id="contact-map">`.
- [ ] **Step 1: Verify no other page uses the `InnerBlocks` form of this block**
Run:
```bash
grep -rn "contact-info" wp-content/themes/community-works-collaborative/views/ --include="*.php" | grep -v "contact-info/contact-info.php" | grep -v "contact-info/block.json"
```
Expected: no matches. If matches exist, list them and confirm the InnerBlocks content is empty for each before proceeding.
- [ ] **Step 2: Replace `views/blocks/contact-info/contact-info.php` with the new content**
Write the entire file:
```php
<?php
/**
* Block Name: Contact Info
*
* Two-column contact page layout: contact info, address/email/phone,
* and a Gravity Forms contact form on the left; Leaflet map placeholder
* on the right. Reads from the global `contact_info` option.
*
* @package CWC
*/
namespace CWC;
$info = get_field( 'contact_info', 'option' );
$address = $info['address'] ?? '';
$email = $info['email'] ?? '';
$phone = $info['phone'] ?? '';
$classes = 'contact-info';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo esc_attr( $wrapper ); ?>>
<div class="contact-info__grid">
<div class="contact-info__details">
<h1 class="contact-info__heading">Contact</h1>
<ul class="contact-info__items">
<?php if ( $address ) : ?>
<li class="contact-info__item contact-info__item--address">
<span class="contact-info__icon" aria-hidden="true">
<img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-address.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
</span>
<span class="contact-info__value"><?php echo wp_kses_post( $address ); ?></span>
</li>
<?php endif; ?>
<?php if ( $email ) : ?>
<li class="contact-info__item contact-info__item--email">
<span class="contact-info__icon" aria-hidden="true">
<img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-email.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
</span>
<a class="contact-info__value" href="mailto:<?php echo esc_attr( $email ); ?>"><?php echo esc_html( $email ); ?></a>
</li>
<?php endif; ?>
<?php if ( $phone ) : ?>
<li class="contact-info__item contact-info__item--phone">
<span class="contact-info__icon" aria-hidden="true">
<img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-phone.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
</span>
<a class="contact-info__value" href="tel:<?php echo esc_attr( $phone ); ?>"><?php echo esc_html( $phone ); ?></a>
</li>
<?php endif; ?>
</ul>
<div class="contact-info__form">
<?php echo do_shortcode( '[gravityform id="1" title="false" description="false" ajax="true"]' ); ?>
</div>
</div>
<div class="contact-info__map" id="contact-map" aria-label="Map of downtown Winnipeg" role="region">
<!-- Leaflet map will mount here. Pin: orange-01 + white map marker (TBD). -->
</div>
</div>
</section>
```
- [ ] **Step 3: Verify the file parses (no PHP syntax errors)**
Run: `php -l views/blocks/contact-info/contact-info.php`
Expected: `No syntax errors detected in views/blocks/contact-info/contact-info.php`.
- [ ] **Step 4: Commit**
```bash
git add views/blocks/contact-info/contact-info.php
git commit -m "feat(contact): rewrite contact-info block to two-column layout"
```
---
### Task 3: Write `views/blocks/contact-info/contact-info.css` (layout + icons)
**Files:**
- Modify: `views/blocks/contact-info/contact-info.css`
**Interfaces:**
- Consumes: the new HTML structure from Task 2.
- Produces: a mobile-first stylesheet that lays out the two columns, sizes the icons, and breaks the right column out to the viewport edge on desktop.
- [ ] **Step 1: Replace `views/blocks/contact-info/contact-info.css` with the new content**
```css
/* Contact Info block styles */
.contact-info {
background: var(--color-white);
padding-block: clamp(3rem, 6vw, 4rem);
position: relative;
}
.contact-info__grid {
display: grid;
gap: 2rem;
grid-template-columns: 1fr;
position: relative;
}
.contact-info__details {
max-width: 36rem;
padding-inline: clamp(1.5rem, 5vw, 3rem);
}
.contact-info__heading {
color: var(--color-cwc-blue-01);
font-family: var(--font-quincy, 'Quincy', serif);
font-size: clamp(2.5rem, 6vw, 4.5rem);
font-weight: 400;
line-height: 1.05;
margin: 0 0 1.5rem;
}
.contact-info__items {
display: flex;
flex-direction: column;
gap: 0.75rem;
list-style: none;
margin: 0 0 2.5rem;
padding: 0;
}
.contact-info__item {
align-items: flex-start;
color: var(--color-dark);
display: flex;
font-size: 1rem;
gap: 0.75rem;
line-height: 1.5;
}
.contact-info__icon {
align-items: center;
color: var(--color-cwc-blue-03);
display: inline-flex;
flex-shrink: 0;
height: 1.5rem;
justify-content: center;
width: 1.5rem;
}
.contact-info__icon img {
display: block;
height: 100%;
width: 100%;
}
.contact-info__value {
color: inherit;
text-decoration: none;
}
a.contact-info__value:hover {
color: var(--color-secondary);
}
.contact-info__map {
background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
border-radius: 0.5rem;
min-height: 24rem;
position: relative;
}
@media (min-width: 1024px) {
.contact-info__grid {
grid-template-columns: 1fr 1fr;
}
.contact-info__details {
padding-inline: clamp(1.5rem, 5vw, 3rem) 0;
}
.contact-info__map {
align-self: stretch;
border-radius: 0;
margin-right: calc(50% - 50vw);
margin-left: 0;
min-height: 32rem;
width: calc(50vw - 0px);
}
}
@media (max-width: 767px) {
.contact-info {
padding-block: 2rem;
}
.contact-info__map {
margin-inline: clamp(1.5rem, 5vw, 3rem);
min-height: 18rem;
}
}
```
- [ ] **Step 2: Add the import to `styles/blocks/index.css` if not already present**
Run: `grep -n "contact-info" styles/blocks/index.css`
Expected: a matching line. If not present, add the views-relative path (the file lives at `views/blocks/contact-info/contact-info.css`, NOT at `styles/blocks/`, so a sibling-relative path will fail the build):
```css
@import '../../views/blocks/contact-info/contact-info.css';
```
> **Why views-relative, not sibling-relative:** `styles/blocks/` only contains `buttons.css`, `core.css`, `misc.css`, and `page-hero.css`. The new CSS is at `views/blocks/contact-info/contact-info.css` (so the per-block `style: ['file:./contact-info.css']` in `block.json` resolves for the block editor preview). A sibling-relative import would silently produce "Can't resolve './contact-info.css' in 'styles/blocks'" and fail the build.
- [ ] **Step 3: Commit**
```bash
git add views/blocks/contact-info/contact-info.css styles/blocks/index.css
git commit -m "feat(contact): add two-column layout and icon styles"
```
---
### Task 4: Add Gravity Forms field overrides to `contact-info.css`
**Files:**
- Modify: `views/blocks/contact-info/contact-info.css`
**Interfaces:**
- Consumes: the layout from Task 3; the `gform_*` HTML output from Gravity Forms.
- Produces: uppercase CWC Blue 01 labels, right-aligned CWC Orange 01 required asterisks, 30% opacity CWC Blue 03 input fills, slightly rounded corners, comfortable padding, a taller message textarea, and a submit button styled like the header Contact button.
- [ ] **Step 1: Append the Gravity Forms overrides to the end of `views/blocks/contact-info/contact-info.css`**
```css
/* Gravity Forms field overrides (scoped to .contact-info__form) */
.contact-info__form .gform_wrapper {
margin: 0;
max-width: none;
}
.contact-info__form .gfield {
margin-bottom: 1.25rem;
}
.contact-info__form .gfield_label {
color: var(--color-cwc-blue-01);
display: flex;
font-size: 0.875rem;
font-weight: 700;
justify-content: space-between;
letter-spacing: 0.05em;
line-height: 1.2;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.contact-info__form .gfield_required {
color: var(--color-cwc-orange-01);
font-weight: 700;
margin-left: 0.25rem;
}
.contact-info__form input[type="text"],
.contact-info__form input[type="email"],
.contact-info__form input[type="tel"],
.contact-info__form input[type="url"],
.contact-info__form textarea,
.contact-info__form select {
background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
border: 1px solid var(--color-cwc-blue-03);
border-radius: 0.375rem;
color: var(--color-dark);
font-size: 1rem;
line-height: 1.5;
padding: 0.75rem 1rem;
width: 100%;
}
.contact-info__form input[type="text"]:focus,
.contact-info__form input[type="email"]:focus,
.contact-info__form input[type="tel"]:focus,
.contact-info__form input[type="url"]:focus,
.contact-info__form textarea:focus,
.contact-info__form select:focus {
border-color: var(--color-cwc-blue-01);
outline: 2px solid var(--color-cwc-blue-01);
outline-offset: 2px;
}
.contact-info__form textarea {
min-height: 10rem;
resize: vertical;
}
.contact-info__form .gform_footer {
margin-top: 1.5rem;
}
.contact-info__form .gform_footer input[type="submit"],
.contact-info__form .gform_footer button[type="submit"] {
background: var(--background-image-button-gradient);
border: 3px solid var(--color-secondary);
border-radius: 1.25rem 0.25rem 1.25rem 0.25rem;
color: var(--color-white);
cursor: pointer;
font-size: 1.125rem;
font-weight: 700;
padding: 0.625rem 2rem;
transition: background 200ms, border-color 200ms, color 200ms;
}
.contact-info__form .gform_footer input[type="submit"]:hover,
.contact-info__form .gform_footer button[type="submit"]:hover {
background: var(--color-secondary);
border-color: var(--color-secondary);
}
.contact-info__form .validation_message {
color: var(--color-danger);
font-style: italic;
margin-top: 0.25rem;
}
```
- [ ] **Step 2: Commit**
```bash
git add views/blocks/contact-info/contact-info.css
git commit -m "feat(contact): style Gravity Forms fields and submit button"
```
---
### Task 5: Build the dist CSS and verify
**Files:**
- Modify: `static/dist/theme.css` (committed with `git add -f`)
**Interfaces:**
- Consumes: the new `views/blocks/contact-info/contact-info.css` and any touched source files.
- Produces: a rebuilt `static/dist/theme.css` that includes the new contact-info styles.
- [ ] **Step 1: Run the build**
Run: `npm run build`
Expected: build completes without errors. The dist file is regenerated.
- [ ] **Step 2: Verify the new classes are present in the dist CSS**
Run:
```bash
grep -c "contact-info" static/dist/theme.css
```
Expected: at least 10 matches (a count of 0 means the build did not pick up the new CSS).
- [ ] **Step 3: Commit the rebuilt dist**
```bash
git add -f static/dist/theme.css
git commit -m "build: regenerate dist with contact-info styles"
```
---
### Task 6: Add `isServicesDescendant()` and the bypass constant to `lib/extras.php`
**Files:**
- Modify: `lib/extras.php`
**Interfaces:**
- Consumes: the global `$post` and an optional services-page override.
- Produces: `isServicesDescendant( ?WP_Post $post = null, ?int $services_id = null ): bool` that returns `true` for the Services page, its children, and its grandchildren. Returns `false` if the Services page does not exist. A `CWC_TEST_BYPASS_HERO_GATE` constant (default `false`; can be flipped to `true` in test setup) bypasses the check.
- [ ] **Step 1: Append the constant and helper to `lib/extras.php`**
Find the end of the `hasPageHeader()` function (the `return true; }` block near the bottom of the relevant section). After that function's closing brace, add:
```php
/**
* Bypass constant for the inner-page hero ancestor check.
*
* When `true`, `isServicesDescendant()` returns `true` for every page.
* Set this in test setup (e.g. in `wp-config.php` or a test bootstrap) so
* test fixture pages that are not Services descendants can still render
* the hero.
*/
if ( ! defined( 'CWC_TEST_BYPASS_HERO_GATE' ) ) {
define( 'CWC_TEST_BYPASS_HERO_GATE', false );
}
/**
* Checks if the current page is the Services page, a child of it, or
* a grandchild of it. Used to gate the inner-page hero.
*
* @param WP_Post|null $post The post to check. Defaults to the global `$post`.
* @param int|null $services_id Override the Services page ID. Defaults to
* the page with slug `services`.
*
* @return bool true if the post is the Services page or a descendant up to 2 levels.
*/
function isServicesDescendant( $post = null, $services_id = null ) {
if ( CWC_TEST_BYPASS_HERO_GATE ) {
return true;
}
if ( null === $post ) {
global $post;
}
if ( ! $post instanceof WP_Post ) {
return false;
}
if ( null === $services_id ) {
$services = get_page_by_path( 'services' );
if ( ! $services ) {
return false;
}
$services_id = (int) $services->ID;
}
$services_id = (int) $services_id;
if ( (int) $post->ID === $services_id ) {
return true;
}
if ( (int) $post->post_parent === $services_id ) {
return true;
}
$parent = get_post( $post->post_parent );
if ( $parent && (int) $parent->post_parent === $services_id ) {
return true;
}
return false;
}
```
- [ ] **Step 2: Verify the file parses**
Run: `php -l lib/extras.php`
Expected: `No syntax errors detected in lib/extras.php`.
- [ ] **Step 3: Commit**
```bash
git add lib/extras.php
git commit -m "feat(hero): add isServicesDescendant() helper and bypass constant"
```
---
### Task 7: Update `header.php` to gate the hero on `isServicesDescendant()`
**Files:**
- Modify: `header.php`
**Interfaces:**
- Consumes: `isServicesDescendant()` from Task 6.
- Produces: a `$showHero` calculation that requires `isServicesDescendant() && hero_style === 'default'` for the `page` post type. The other gates (`is_home() || is_archive() || ...`) are unchanged.
- [ ] **Step 1: Update `$showHero` in `header.php`**
Replace the existing `$showHero` block:
```php
$showHero = in_array(
true,
array(
isServicesDescendant() && get_field( 'hero_style' ) === 'default',
is_home(),
is_archive(),
is_single(),
is_search(),
is_404(),
),
true
);
```
- [ ] **Step 2: Verify the file parses**
Run: `php -l header.php`
Expected: `No syntax errors detected in header.php`.
- [ ] **Step 3: Commit**
```bash
git add header.php
git commit -m "feat(hero): gate inner-page hero on Services descendants"
```
---
### Task 8: Add Playwright test for the contact page
**Files:**
- Create: `tests/contact-page.spec.js`
**Interfaces:**
- Consumes: the rendered contact page at `/contact/`.
- Produces: a Playwright spec that verifies the two-column layout, icon rows, form area, and Leaflet placeholder render at desktop and mobile viewports, with no console errors and no axe violations.
- [ ] **Step 1: Create `tests/contact-page.spec.js`**
```js
import { expect, test } from "@playwright/test";
const AxeBuilder = require("@axe-core/playwright").default;
const contactUrl =
process.env.TEST_CONTACT_URL ||
"http://community-works-collaborative.test/contact/";
test.describe("contact page", () => {
test("desktop: two-column layout with form area and map placeholder", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(contactUrl);
// No hero on the contact page.
expect(await page.locator(".page-hero").count()).toBe(0);
// Block renders.
const block = page.locator(".contact-info").first();
await expect(block).toBeVisible();
// Left column has heading, items, and form area.
const heading = page.locator(".contact-info__heading").first();
await expect(heading).toBeVisible();
await expect(heading).toHaveText(/contact/i);
const items = page.locator(".contact-info__item");
const itemsCount = await items.count();
expect(itemsCount).toBeGreaterThanOrEqual(1);
const formArea = page.locator(".contact-info__form").first();
await expect(formArea).toBeAttached();
// Right column has the Leaflet placeholder.
const map = page.locator(".contact-info__map").first();
await expect(map).toBeAttached();
await expect(map).toHaveId("contact-map");
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: single column, map drops below form, no horizontal overflow", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(contactUrl);
const details = page.locator(".contact-info__details").first();
const map = page.locator(".contact-info__map").first();
await expect(details).toBeVisible();
await expect(map).toBeAttached();
const detailsBox = await details.boundingBox();
const mapBox = await map.boundingBox();
expect(detailsBox).not.toBeNull();
expect(mapBox).not.toBeNull();
expect(mapBox.x).toBeGreaterThanOrEqual(detailsBox.x - 1);
expect(mapBox.x + mapBox.width).toBeLessThanOrEqual(402 + 3);
expect(pageErrors).toEqual([]);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(contactUrl);
const block = page.locator(".contact-info").first();
await expect(block).toBeVisible();
const blockBox = await block.boundingBox();
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(320 + 3);
});
test("axe: no violations", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(contactUrl);
const results = await new AxeBuilder({ page })
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 2: Run the new test**
Run: `npx playwright test tests/contact-page.spec.js`
Expected: all 4 tests pass. If a test fails, fix the underlying code (do not weaken the test) and re-run.
- [ ] **Step 3: Commit**
```bash
git add tests/contact-page.spec.js
git commit -m "test(contact): add contact-page Playwright spec"
```
---
### Task 9: Add Playwright test for the hero re-scope
**Files:**
- Create: `tests/services-hero-scope.spec.js`
**Interfaces:**
- Consumes: rendered pages: Services, a child of Services, a grandchild, a non-Services page (e.g. About), and `/news/` (blog index).
- Produces: a Playwright spec that asserts the hero renders on the first three and not on the latter two.
**Pre-flight check (this task's pre-step):** confirm that the test fixture pages exist on the live site. If the test site is fresh, the developer may need to seed these pages in `beforeAll`. The spec uses environment variables so the URLs can be overridden:
```bash
TEST_SERVICES_URL=http://community-works-collaborative.test/services/
TEST_SERVICES_CHILD_URL=http://community-works-collaborative.test/communication-services/
TEST_SERVICES_GRANDCHILD_URL=http://community-works-collaborative.test/communication-services/some-grandchild/
TEST_NON_SERVICES_URL=http://community-works-collaborative.test/about/
```
- [ ] **Step 1: Create `tests/services-hero-scope.spec.js`**
```js
import { expect, test } from "@playwright/test";
const servicesUrl =
process.env.TEST_SERVICES_URL ||
"http://community-works-collaborative.test/services/";
const childUrl =
process.env.TEST_SERVICES_CHILD_URL ||
"http://community-works-collaborative.test/communication-services/";
const grandchildUrl =
process.env.TEST_SERVICES_GRANDCHILD_URL ||
"http://community-works-collaborative.test/communication-services/sample-grandchild/";
const nonServicesUrl =
process.env.TEST_NON_SERVICES_URL ||
"http://community-works-collaborative.test/about/";
const blogUrl =
process.env.TEST_BLOG_URL ||
"http://community-works-collaborative.test/news/";
test.describe("hero ancestor scope", () => {
test("Services page renders the hero", async ({ page }) => {
await page.goto(servicesUrl);
await expect(page.locator(".page-hero").first()).toBeVisible();
});
test("child of Services renders the hero", async ({ page }) => {
await page.goto(childUrl);
await expect(page.locator(".page-hero").first()).toBeVisible();
});
test("grandchild of Services renders the hero", async ({ page }) => {
await page.goto(grandchildUrl);
await expect(page.locator(".page-hero").first()).toBeVisible();
});
test("non-Services page does NOT render the hero", async ({ page }) => {
await page.goto(nonServicesUrl);
expect(await page.locator(".page-hero").count()).toBe(0);
});
test("blog index still renders the hero (bypasses ancestor gate)", async ({
page,
}) => {
await page.goto(blogUrl);
await expect(page.locator(".page-hero").first()).toBeVisible();
});
});
```
- [ ] **Step 2: Verify the test fixture URLs exist**
Run (per URL):
```bash
curl -sI "$url" | head -1
```
Expected: `HTTP/1.1 200 OK` (or `301/302` for trailing-slash redirects). If a URL returns 404, the test will need a `beforeAll` that seeds the page; flag this and do not proceed without a real fixture.
- [ ] **Step 3: Run the new test**
Run: `npx playwright test tests/services-hero-scope.spec.js`
Expected: all 5 tests pass. If `nonServicesUrl` still shows the hero, verify the live page's `post_parent` chain (the About page must not be a child or grandchild of `/services/`).
- [ ] **Step 4: Commit**
```bash
git add tests/services-hero-scope.spec.js
git commit -m "test(hero): add ancestor-scope Playwright spec"
```
---
### Task 10: Run the full Playwright suite + PHPCS
**Files:**
- Modify: `static/dist/theme.css` (only if not already rebuilt).
- Modify: any source files if a regression is found.
- [ ] **Step 1: Run the full Playwright suite**
Run: `npx playwright test`
Expected: all tests pass except the 4 known pre-existing `mobile-homepage.spec.js` failures. The new `tests/contact-page.spec.js` and `tests/services-hero-scope.spec.js` pass.
- [ ] **Step 2: Run PHPCS**
Run: `composer lint`
Expected: no errors. If errors are found, fix them (most likely are tab-vs-spaces in PHP files; use `composer lint:fix` to auto-fix).
- [ ] **Step 3: Verify the rebuilt dist is current**
Run: `git status static/dist/theme.css`
Expected: `nothing to commit, working tree clean` (i.e. the dist file is up to date with the source CSS). If uncommitted, run `git add -f static/dist/theme.css` and commit with `chore: sync dist after lint/fix`.
- [ ] **Step 4: Final commit (if anything changed in step 1-3)**
```bash
git add -A
git commit -m "chore: final pass after Playwright and PHPCS"
```
---
## Self-Review Notes
- **Spec coverage:** Task 1 (icons), Task 2 (block PHP), Task 3 (layout CSS), Task 4 (form CSS), Task 5 (build), Task 6 (PHP helper + bypass), Task 7 (header.php), Task 8 (contact test), Task 9 (hero-scope test), Task 10 (full suite). Every spec section is covered.
- **Placeholder scan:** No "TBD", "TODO", or "implement later" in any step. The "Leaflet placeholder" and "map pin SVG TBD" comments in the PHP and CSS are intentional (they document where the embed will mount, not unfinished work).
- **Type consistency:** `isServicesDescendant( ?WP_Post $post = null, ?int $services_id = null ): bool` is used identically in Task 6 (definition) and Task 7 (call site). `CWC_TEST_BYPASS_HERO_GATE` is defined in Task 6 and respected in Task 7 by virtue of the helper checking it.
- **Spec section "Out of Scope" (Leaflet embed, map pin SVG, ACF fields on the block, audit of existing InnerBlocks usage):** all flagged in the spec. Task 2's Step 1 audit confirms no InnerBlocks usage before removing it.
@@ -0,0 +1,289 @@
# Blog Post Card Styling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restyle the blog post card to match `blog-post-card.png` — a vertical card with a featured image on top and a solid blue content area below containing the title, byline, and an orange "Read more" link.
**Architecture:** A targeted CSS update to `styles/components/post-list.css` (the card body, title, byline, image border, and a new `.post-list__read-more` rule), a one-line markup addition in `index.php` to render the "Read more" anchor, and a single new assertion in the existing `tests/blog-page.spec.js`. The dist CSS is rebuilt once at the end.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard; see `composer.json` and existing files).
- The theme's Tailwind build outputs `static/dist/theme.css`; that file is gitignored but committed with `git add -f` per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass. Pre-existing CRLF errors on untouched files are out of scope.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- The card width behavior stays the same: `grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]`. No layout refactor.
- The "Read more" link reuses the post's permalink — it's the same destination as the title link.
- The H1 page title and `search.php` are explicitly out of scope for this plan.
---
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `index.php` | Render the "Read more" link after the byline. | Modify |
| `styles/components/post-list.css` | Style the card body, image border, title, byline, and the new `.post-list__read-more`. | Modify |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified (committed with `git add -f`). |
| `tests/blog-page.spec.js` | Add a single assertion that the "Read more" link is visible in the first card. | Modify |
---
### Task 1: Add the "Read more" link in `index.php`
**Files:**
- Modify: `index.php:44-53`
**Interfaces:**
- Consumes: the standard WordPress loop (`have_posts()`, `the_post()`).
- Produces: a `<a class="post-list__read-more" href="<?php the_permalink(); ?>">Read more &rarr;</a>` element inside `.post-list__details`, after the `.post-list__byline` div.
- [ ] **Step 1: Add the "Read more" anchor**
In `index.php`, after the `<div class="post-list__byline mt-auto">...</div>` block (which ends on line 52 with `</div>`), and before the closing `</div>` of `.post-list__details` (line 53), add the new anchor. The result is:
```php
<div class="post-list__details px-4 py-6 flex flex-col grow">
<a href="<?php the_permalink(); ?>" class="">
<h2 class="post-list__title font-normal text-25px text-balance line-clamp-4 truncate mt-0 mb-5 mx-0"><?php the_title(); ?></h2>
</a>
<div class="post-list__byline mt-auto">
<span class="post-list__author"><?php echo esc_html( ucfirst( get_the_author() ) ); ?> &mdash;</span>
<span class="post-list__date"><?php echo get_the_date(); ?></span>
</div>
<a class="post-list__read-more" href="<?php the_permalink(); ?>">Read more &rarr;</a>
</div>
```
Specifically:
- Insert one new line after the closing `</div>` of `.post-list__byline` (the line that contains only `</div>` ending the byline block).
- The new line is: `<a class="post-list__read-more" href="<?php the_permalink(); ?>">Read more &rarr;</a>`
- Indentation: tabs (matching the existing PHP in the file).
- Use `&rarr;` (HTML entity) for the arrow — this is consistent with other `&mdash;` / `&hellip;` / `&laquo;` usage in the same file (see lines 50, 64, 95, 96).
- [ ] **Step 2: Verify the file parses**
Run: `php -l index.php`
Expected: `No syntax errors detected in index.php`.
- [ ] **Step 3: Commit**
```bash
git add index.php
git commit -m "feat(blog): add Read more link to post card"
```
---
### Task 2: Add card styles to `styles/components/post-list.css`
**Files:**
- Modify: `styles/components/post-list.css`
**Interfaces:**
- Consumes: the new `<a class="post-list__read-more">` from Task 1; the existing `.post-list__post`, `.post-list__img`, `.post-list__details`, `.post-list__title`, `.post-list__byline` elements from `index.php`.
- Produces: a card with a solid blue content area, dark blue title and byline, and an orange "Read more" link. Removes the gray bottom border between the image and content area.
- [ ] **Step 1: Replace the contents of `styles/components/post-list.css`**
The current file is 11 lines containing only `.post-list__h1`. Replace the entire file with:
```css
/* Blog/post index listing styles */
.post-list__h1 {
color: var(--color-cwc-blue-01);
font-family: var(--font-quincy, 'Quincy', serif);
font-size: var(--h1);
font-weight: 700;
line-height: 1.2;
margin: 0 0 2rem;
text-align: left;
}
.post-list__post {
border-color: var(--color-cwc-blue-03);
overflow: hidden;
}
.post-list__img {
border-bottom: 0;
}
.post-list__details {
background: var(--color-cwc-blue-03);
color: var(--color-cwc-blue-01);
}
.post-list__title {
color: var(--color-cwc-blue-01);
font-weight: 700;
}
.post-list__byline {
color: var(--color-cwc-blue-01);
font-size: 0.875rem;
}
.post-list__read-more {
color: var(--color-secondary);
font-weight: 600;
margin-top: 0.75rem;
text-decoration: none;
}
.post-list__read-more:hover {
text-decoration: underline;
}
```
Notes on the rules:
- `.post-list__post { border-color: var(--color-cwc-blue-03); }` overrides the `border-secondary` class on the element so the card border matches the content area (effectively invisible against the same-colored background).
- `.post-list__post { overflow: hidden; }` ensures the image's `rounded-t-md` corners are clipped by the card's `rounded-md` corners (this is the standard "image fills top of card" pattern).
- `.post-list__img { border-bottom: 0; }` removes the existing `border-b border-secondary` so the image and content area flow as separate blocks without a divider line.
- `.post-list__details { background: var(--color-cwc-blue-03); }` is the solid blue content area (`#90c9e7`).
- `.post-list__title { font-weight: 700; }` overrides the `font-normal` in the markup.
- `.post-list__read-more` uses `--color-secondary` (aliased to `--color-cwc-orange-01`).
- Indentation: 4 spaces (CSS convention, matches the existing `.post-list__h1` rule).
- [ ] **Step 2: Verify the CSS file parses**
Tailwind build is the integration test (Task 3). No standalone CSS lint step.
- [ ] **Step 3: Commit**
```bash
git add styles/components/post-list.css
git commit -m "feat(blog): style the post card body, title, byline, and Read more link"
```
---
### Task 3: Add the "Read more" assertion in `tests/blog-page.spec.js`
**Files:**
- Modify: `tests/blog-page.spec.js:31-35`
**Interfaces:**
- Consumes: the new `.post-list__read-more` element from Tasks 1 and 2.
- Produces: a passing assertion that the "Read more" link is visible in the first card and points to a post URL.
- [ ] **Step 1: Add the assertion in the desktop test**
In `tests/blog-page.spec.js`, after the existing first-card assertions (after `await expect(firstCard.locator(".post-list__byline")).toBeVisible();` on line 35), add the new assertion. The result is:
```js
// First card: image, title link, byline.
const firstCard = posts.first();
await expect(firstCard.locator(".post-list__img img")).toBeVisible();
await expect(firstCard.locator(".post-list__title")).toBeVisible();
await expect(firstCard.locator(".post-list__byline")).toBeVisible();
// First card has a "Read more" link.
const readMore = firstCard.locator(".post-list__read-more");
await expect(readMore).toBeVisible();
await expect(readMore).toHaveText(/^Read more\s*\S+$/);
await expect(readMore).toHaveAttribute("href", /\/[a-z0-9-]+\/?$/);
```
Notes:
- The text regex `/^Read more\s*\S+$/` matches "Read more" followed by whitespace and any non-whitespace character (the `→` arrow or any other trailing punctuation). This is robust to HTML-entity encoding in the rendered DOM.
- The `href` regex matches a WordPress post permalink like `/my-post-slug/` or `/my-post-slug`.
- Indentation: 4 spaces (matches the existing test file).
- Do NOT remove the existing `.post-list__cats`/`.post-list__excerpt` count assertions (lines 38-39). They guard against the trim being undone and remain valid.
- [ ] **Step 2: Run the new assertion in isolation**
Run: `npx playwright test tests/blog-page.spec.js -g "Blog title, post grid"`
Expected: PASS (the new assertion passes, all existing assertions still pass).
If the test fails:
- "Read more" not found → Task 1's markup change didn't land.
- Text mismatch → confirm `&rarr;` was used (not `->` or `&amp;rarr;`).
- `href` mismatch → confirm the markup uses `<?php the_permalink(); ?>` (not a hardcoded URL).
- [ ] **Step 3: Commit**
```bash
git add tests/blog-page.spec.js
git commit -m "test(blog): assert Read more link is present in the first card"
```
---
### Task 4: Build the dist CSS and run the final quality gate
**Files:**
- Modify: `static/dist/theme.css` (committed with `git add -f`).
- [ ] **Step 1: Run the build**
Run: `npm run build`
Expected: build completes without errors.
- [ ] **Step 2: Verify the new classes are in the dist**
Run: `grep -c "post-list__read-more" static/dist/theme.css`
Expected: at least 1 match.
Run: `grep -c "post-list__details" static/dist/theme.css`
Expected: at least 1 match (the new `background` rule should compile into the dist).
- [ ] **Step 3: Commit the rebuilt dist**
```bash
git add -f static/dist/theme.css
git commit -m "build: regenerate dist with blog post card styles"
```
- [ ] **Step 4: Run the new test file**
Run: `npx playwright test tests/blog-page.spec.js`
Expected: all 4 tests pass.
- [ ] **Step 5: Run the full Playwright suite**
Run: `npx playwright test`
Expected: same pass/fail count as before this plan. The new test (4th) added 1 passing test; no other tests should regress. The 14 pre-existing failures (12 from prior plans + 2 contact-page pre-existing) are unchanged.
- [ ] **Step 6: Run PHPCS**
Run: `composer lint`
Expected: no NEW errors. The 5 pre-existing CRLF errors on untouched files (or 20 pre-existing issues per the prior `composer lint` baseline) remain unchanged.
- [ ] **Step 7: Final commit (only if anything changed in step 1-6)**
```bash
git add -A
git commit -m "chore: final pass after Playwright and PHPCS"
```
If nothing changed (no dist drift, no PHPCS issues, no other drift), skip the commit.
---
## Self-Review Notes
- **Spec coverage:**
- "Vertical card (image top, content below)" → already true; the card structure is unchanged.
- "Title + byline + Read more link" → Task 1 adds the link; existing markup already has title and byline.
- "Solid `--color-cwc-blue-03` background" → Task 2 (`.post-list__details`).
- "Title in `--color-cwc-blue-01`, weight 700" → Task 2 (`.post-list__title`).
- "Byline in `--color-cwc-blue-01`, smaller" → Task 2 (`.post-list__byline`).
- "Read more in `--color-secondary` (orange)" → Task 2 (`.post-list__read-more`).
- "Remove gray border between image and content" → Task 2 (`.post-list__img { border-bottom: 0; }`).
- "Card border matches content area" → Task 2 (`.post-list__post { border-color: ...; }`).
- "Single new test assertion" → Task 3.
- "Dist rebuild" → Task 4.
- "All design tokens, no hardcoded hex" → Task 2 uses only `var(--...)` references.
- "Out of scope: H1, search.php" → explicitly noted in Global Constraints.
- **Placeholder scan:** No TBD/TODO. The "→" character is exact in both `index.php` (`&rarr;`) and the test (`→`). The `href` regex is exact.
- **Type consistency:** Class names: `post-list__read-more` (consistent with `post-list__post`, `post-list__title`, `post-list__byline`). CSS variable names: `--color-cwc-blue-01`, `--color-cwc-blue-03`, `--color-secondary` (all existing tokens).
- **Indentation:** Tabs for PHP (Task 1), 4 spaces for CSS (Task 2) and JS (Task 3). Each matches the convention in its file.
@@ -0,0 +1,626 @@
# Services Content Blocks Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the missing content-area components for the Mapping services page (and any other service sub-page), matching `Mapping-Services.png`. The hero, header, and footer are already in place and must not be touched.
**Architecture:** One new ACF block (`services-list`) that renders a row of cards. Card count is editor-driven (2, 3, or 4); card size and grid layout adapt to the count. The orange horizontal line that bisects the images is rendered by the block itself as a full-bleed SVG background. The closing "At CWC, mapping is about more than data…" block reuses the existing `pull-quote` block — no new build needed. Editor composes the page in Gutenberg by adding `Services List` and `Pull Quote` blocks to any service sub-page; `page.php` already calls `the_content()`, so no template change is required.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- `static/dist/theme.css` is committed (not gitignored) per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- Reuse the existing post-title accent pattern (blue heading + orange `::after` underline from `.post-title h1` in `styles/base/misc.css`) for consistency. Do not introduce a new accent style.
- Reuse the existing `pull-quote` block for the closing statement — no duplicate block.
- Page template `page.php` is not modified. The page-children block is not used.
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
- Tailwind v4 picks up class names from source files at build time. The dynamic `lg:grid-cols-N` class needs to literally appear in the source PHP for Tailwind to emit the rule.
- The `static/img/services-list-line.svg` is a fixed asset; do not make it editor-uploadable.
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `views/blocks/services-list/services-list.php` | Block template (PHP) — renders the row of cards. | Create |
| `views/blocks/services-list/services-list.css` | Block styles (CSS) — layout, headings, bisecting line. | Create |
| `views/blocks/services-list/block.json` | Block registration metadata (name, category, icon, render template). | Create |
| `acf/group_services_list.json` | ACF field group for the `acf/services-list` block. | Create |
| `static/img/services-list-line.svg` | Decorative orange horizontal line SVG that bisects the images. | Create |
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
| `tests/services-page.spec.js` | Playwright tests for the rendered block. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
---
## Task 1: ACF Field Group
**Files:**
- Create: `acf/group_services_list.json`
**Interfaces:**
- Produces: ACF group with `key: "group_services_list"`, location scoped to `block == acf/services-list`, fields: `items` (repeater) → `image`, `heading`, `description`.
- [ ] **Step 1: Create the ACF field group JSON file**
Create the file `acf/group_services_list.json` with the following exact content:
```json
{
"key": "group_services_list",
"title": "Services List",
"fields": [
{
"key": "field_services_items",
"label": "Items",
"name": "items",
"aria-label": "",
"type": "repeater",
"instructions": "Add 2, 3, or 4 cards. The grid layout adapts to the count.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"layout": "block",
"pagination": 0,
"min": 0,
"max": 0,
"collapsed": "",
"button_label": "Add Service",
"rows_per_page": 20,
"sub_fields": [
{
"key": "field_services_item_image",
"label": "Image",
"name": "image",
"aria-label": "",
"type": "image",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
},
{
"key": "field_services_item_heading",
"label": "Heading",
"name": "heading",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": ""
},
{
"key": "field_services_item_description",
"label": "Description",
"name": "description",
"aria-label": "",
"type": "textarea",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"placeholder": "",
"maxlength": "",
"rows": 4,
"new_lines": ""
}
]
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf/services-list"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "Service cards row. Card count (2-4) determines the layout.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1753305600
}
```
- [ ] **Step 2: Verify the JSON parses**
Run: `node -e "JSON.parse(require('fs').readFileSync('acf/group_services_list.json','utf8')); console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add acf/group_services_list.json
git commit -m "✨ feat: Add ACF field group for services-list block"
```
---
## Task 2: Decorative SVG Asset
**Files:**
- Create: `static/img/services-list-line.svg`
**Interfaces:**
- Produces: A 1600×8 viewBox SVG with a 2px-tall orange `#F26B53` rectangle, `preserveAspectRatio="none"`. Reference URL: `/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg`.
- [ ] **Step 1: Create the SVG asset**
Create the file `static/img/services-list-line.svg` with the following exact content:
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 8" preserveAspectRatio="none">
<rect x="0" y="3" width="1600" height="2" fill="#F26B53" />
</svg>
```
- [ ] **Step 2: Verify the SVG is well-formed**
Run: `node -e "const fs=require('fs'); const s=fs.readFileSync('static/img/services-list-line.svg','utf8'); if(!s.includes('viewBox=\"0 0 1600 8\"')||!s.includes('fill=\"#F26B53\"')) { process.exit(1); } console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add static/img/services-list-line.svg
git commit -m "✨ feat: Add services-list decorative line SVG"
```
---
## Task 3: Block Registration JSON
**Files:**
- Create: `views/blocks/services-list/block.json`
**Interfaces:**
- Produces: `block.json` declaring `acf/services-list` as the block name, `services-list.php` as the render template, `grid-view` icon, `common` category. `regACFBlocks()` in `functions.php` will pick this up automatically — no PHP registration code change required.
- [ ] **Step 1: Create the block.json file**
Create the file `views/blocks/services-list/block.json` with the following exact content:
```json
{
"name": "acf/services-list",
"title": "Services List",
"description": "A row of service cards. Card count (2, 3, or 4) determines the layout.",
"category": "common",
"icon": "grid-view",
"keywords": ["services", "cards", "mapping"],
"acf": {
"mode": "preview",
"renderTemplate": "services-list.php"
},
"supports": {
"anchor": true,
"align": false
}
}
```
- [ ] **Step 2: Verify the JSON parses**
Run: `node -e "JSON.parse(require('fs').readFileSync('views/blocks/services-list/block.json','utf8')); console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/services-list/block.json
git commit -m "✨ feat: Add services-list block.json registration"
```
---
## Task 4: Block CSS
**Files:**
- Create: `views/blocks/services-list/services-list.css`
**Interfaces:**
- Produces: BEM-styled CSS for `.services-list`, `__line`, `__grid`, `__card`, `__media`, `__heading`, `__desc`. Reuses `text-cwc-blue-01` and `var(--color-secondary)` from existing tokens. Renders the bisecting line via `background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg')`.
- [ ] **Step 1: Create the block CSS file**
Create the file `views/blocks/services-list/services-list.css` with the following exact content:
```css
/* Services List block */
.services-list {
--services-line-top: 25%;
&__line {
position: absolute;
inset-inline: 0;
top: var(--services-line-top);
height: 8px;
pointer-events: none;
z-index: 1;
background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg');
background-repeat: no-repeat;
background-position: center center;
background-size: 100% 100%;
}
&__grid {
position: relative;
z-index: 2;
}
&__card {
@apply flex flex-col;
}
&__media {
@apply overflow-hidden rounded-md bg-gray-100;
}
&__img {
@apply w-full h-full object-cover;
}
&__heading {
@apply text-cwc-blue-01 text-24px font-bold leading-none mt-6 mb-4;
&::after {
background: var(--color-secondary);
content: "";
display: block;
height: 4px;
margin-top: 0.45rem;
width: 3rem;
}
}
&__desc {
@apply text-gray-600 text-16px font-light leading-snug;
}
}
@media (max-width: 1023px) {
.services-list__line {
display: none;
}
}
```
- [ ] **Step 2: Verify the CSS contains the expected classes**
The brief uses CSS-nesting shorthand (`&__line`, `&__grid`, etc.) for 6 of the 7 BEM children, and a literal selector (`.services-list__line`) for the one inside the `@media` block. A naive grep for the literal class names will undercount. Use:
```bash
grep -cE "&__(line|grid|card|media|img|heading|desc)|services-list__(line|grid|card|media|img|heading|desc)" views/blocks/services-list/services-list.css
```
Expected: `8` (7 nested `&__` selectors + 1 literal `.services-list__line` inside the `@media` block).
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/services-list/services-list.css
git commit -m "✨ feat: Add services-list block CSS"
```
---
## Task 5: Block Template (PHP)
**Files:**
- Create: `views/blocks/services-list/services-list.php`
**Interfaces:**
- Consumes: `get_field( 'items' )` → array of items each with `image` (array: `url`, `alt`), `heading` (string), `description` (string).
- Produces: A `<section class="services-list ...">` element containing the bisecting `__line` div, a `container` with a grid div, and a `<article class="services-list__card">` per item. Computes the dynamic `lg:grid-cols-N` class from item count.
- [ ] **Step 1: Create the block PHP template**
Create the file `views/blocks/services-list/services-list.php` with the following exact content (tabs for indentation):
```php
<?php
/**
* Block Name: Services List
*
* A row of service cards. Card count (2, 3, or 4) determines the layout.
*
* @package CWC
*/
namespace CWC;
$items = get_field( 'items' );
if ( empty( $items ) ) {
return;
}
$count = count( $items );
$grid_cols = max( 2, min( 4, (int) $count ) );
$grid_class = 'lg:grid-cols-' . $grid_cols;
$line_url = '/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg';
$classes = 'services-list mx-break-out relative ' . $grid_class;
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="services-list__line" aria-hidden="true" style="background-image: url('<?php echo esc_url( $line_url ); ?>');"></div>
<div class="container">
<div class="services-list__grid grid grid-cols-1 <?php echo esc_attr( $grid_class ); ?> gap-8 lg:gap-12">
<?php foreach ( $items as $item ) : ?>
<article class="services-list__card">
<?php if ( ! empty( $item['image'] ) ) : ?>
<figure class="services-list__media aspect-square">
<img
class="services-list__img"
src="<?php echo esc_url( $item['image']['url'] ); ?>"
alt="<?php echo esc_attr( $item['image']['alt'] ?? '' ); ?>"
loading="lazy"
/>
</figure>
<?php endif; ?>
<?php if ( ! empty( $item['heading'] ) ) : ?>
<h3 class="services-list__heading">
<?php echo wp_kses_post( $item['heading'] ); ?>
</h3>
<?php endif; ?>
<?php if ( ! empty( $item['description'] ) ) : ?>
<div class="services-list__desc">
<?php echo wp_kses_post( $item['description'] ); ?>
</div>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</div>
</section>
```
- [ ] **Step 2: Lint the PHP file with PHPCS**
Run: `composer exec phpcs -- views/blocks/services-list/services-list.php`
Expected: exits 0 with no errors. If there are pre-existing project-wide issues unrelated to this file, fix only the ones in this file.
- [ ] **Step 3: Verify the dynamic grid class is in the source**
Run: `grep -E "lg:grid-cols-\$grid_cols|lg:grid-cols-' \. \$grid_cols" views/blocks/services-list/services-list.php`
Expected: at least one match (Tailwind must see the literal class name in the source to emit the rule).
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/services-list/services-list.php
git commit -m "✨ feat: Add services-list block PHP template"
```
---
## Task 6: CSS Import Wiring
**Files:**
- Modify: `styles/blocks/index.css`
**Interfaces:**
- Consumes: `views/blocks/services-list/services-list.css` (from Task 4).
- Produces: A new `@import` line at the end of `styles/blocks/index.css` so Tailwind's build picks up the block's CSS.
- [ ] **Step 1: Add the import to the block index CSS**
Append the following line at the end of `styles/blocks/index.css`:
```css
@import '../../views/blocks/services-list/services-list.css';
```
After the edit, the file should end with:
```
@import '../../views/blocks/contact-info/contact-info.css';
@import '../../views/blocks/services-list/services-list.css';
```
- [ ] **Step 2: Verify the import is present**
Run: `grep -c "services-list/services-list.css" styles/blocks/index.css`
Expected: `1`.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add styles/blocks/index.css
git commit -m "🔧 chore: Import services-list block CSS into theme"
```
---
## Task 7: Build the CSS
**Files:**
- Modify: `static/dist/theme.css`
**Interfaces:**
- Consumes: The new import in `styles/blocks/index.css` (from Task 6) and the block CSS (from Task 4).
- Produces: A rebuilt `static/dist/theme.css` that includes the `.services-list*` rules and the dynamic `lg:grid-cols-2`, `lg:grid-cols-3`, `lg:grid-cols-4` utilities.
- [ ] **Step 1: Run the production Tailwind build**
Run: `npm run build`
Expected: completes with no errors. The output file `static/dist/theme.css` is updated in place.
- [ ] **Step 2: Verify the services-list CSS classes are in the built output**
The build unwraps CSS-nesting `&__heading` into `__heading.services-list` (no literal `.services-list__heading` is emitted). Use a fixed-string search that matches the un-nested form:
```bash
grep -cF "__heading.services-list" static/dist/theme.css
```
Expected: `1` or more. (Note: on Windows Git-Bash, prefer `grep -F` because POSIX `\\:` escapes are stripped before reaching grep.)
- [ ] **Step 3: Verify the dynamic grid classes are in the built output**
Same Windows-shell caveat. Use fixed-string search:
```bash
grep -cF "lg\:grid-cols-2" static/dist/theme.css
grep -cF "lg\:grid-cols-3" static/dist/theme.css
grep -cF "lg\:grid-cols-4" static/dist/theme.css
```
Expected: each returns `1` or more.
- [ ] **Step 4: Commit the rebuilt dist**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add -f static/dist/theme.css
git commit -m "🎨 build: Rebuild dist with services-list block styles"
```
---
## Task 8: Playwright Tests
**Files:**
- Create: `tests/services-page.spec.js`
**Interfaces:**
- Consumes: The rendered `views/blocks/services-list/services-list.php` template (Tasks 4 + 5) on the WordPress page `/services/mapping/`.
- Produces: Three tests:
1. Card count, image, and heading visibility per block (expects 2 + 4 cards across two block instances).
2. Bisecting `__line` is present and `aria-hidden="true"`.
3. No axe-core a11y violations scoped to `.services-list`.
> **Prerequisite:** the test depends on the mapping page being composed in Gutenberg with two `Services List` blocks (2 items + 4 items) and a `Pull Quote` block. That composition is the editor's responsibility and is not part of this plan. The tests should be authored against the *intended* state, not a placeholder state.
- [ ] **Step 1: Create the test spec file**
Create the file `tests/services-page.spec.js` with the following exact content:
```js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Services List block on /services/mapping/", () => {
test("renders the heading and image for each card", async ({ page }) => {
await page.goto("/services/mapping/");
const blocks = page.locator(".services-list");
await expect(blocks).toHaveCount(2);
await expect(blocks.nth(0).locator(".services-list__card")).toHaveCount(2);
await expect(blocks.nth(1).locator(".services-list__card")).toHaveCount(4);
const firstCard = blocks.nth(0).locator(".services-list__card").first();
await expect(firstCard.locator(".services-list__media img")).toBeVisible();
await expect(firstCard.locator(".services-list__heading")).toBeVisible();
});
test("bisecting line is present and aria-hidden", async ({ page }) => {
await page.goto("/services/mapping/");
const line = page.locator(".services-list__line").first();
await expect(line).toBeAttached();
await expect(line).toHaveAttribute("aria-hidden", "true");
});
test("no axe violations on the services-list block", async ({ page }) => {
await page.goto("/services/mapping/");
const results = await new AxeBuilder({ page })
.include(".services-list")
.analyze();
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 2: Run the tests**
Run: `npx playwright test tests/services-page.spec.js`
Expected: all 3 tests pass. If tests fail because the mapping page has not yet been composed in the editor, that's an expected prerequisite — note the prerequisite in the final report and skip the test run.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add tests/services-page.spec.js
git commit -m "✅ test: Add Playwright tests for services-list block"
```
---
## Self-Review Notes
- **Spec coverage:**
- One new ACF block (`services-list`) — covered by Tasks 1, 3, 4, 5.
- Repeater of `items` with `image` / `heading` / `description` — covered by Task 1.
- Card count drives grid columns (2/3/4) — covered by Task 5 (PHP computes the class) and Task 7 (Tailwind emits the rule).
- Orange bisecting line, full-bleed, behind the cards, hidden on mobile — covered by Tasks 2 (SVG), 4 (CSS with `inset-inline: 0`, `z-index: 1` on line, `z-index: 2` on grid, media query to hide on `<lg`).
- Reuse `pull-quote` for closing — explicitly out of scope (no code change needed).
- Page template not modified — confirmed; `page.php` is not in any file list.
- `header.php` / `footer.php` / page hero not touched — confirmed.
- Reuse existing design tokens (`text-cwc-blue-01`, `var(--color-secondary)`, brand orange `#F26B53`) — confirmed in Tasks 4 and 2.
- Playwright tests covering count, image, heading, line, a11y — covered by Task 8.
- Dist rebuild — covered by Task 7.
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output. The only conditional is in Task 8 Step 2 — explicitly called out as a prerequisite (editor must compose the page in Gutenberg), not a placeholder.
- **Type consistency:** ACF field `name: "items"` is used in Task 5 (`get_field( 'items' )`). Sub-field names `image`, `heading`, `description` are used in Task 1 (defined) and Task 5 (consumed). Class names `services-list`, `__line`, `__grid`, `__card`, `__media`, `__img`, `__heading`, `__desc` are used in Task 4 (defined in CSS) and Task 5 (rendered in markup) and Task 8 (asserted in tests) — consistent.
- **Ambiguity check:** "Editor adds 2 instances of the block on the mapping page" is the chosen approach (per spec §1 and design Q&A). "Bisecting line" is a single orange SVG, 8px tall, positioned at `top: 25%` of the block wrapper, hidden on mobile. Both are explicit.
@@ -0,0 +1,620 @@
# Team Grid Block Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a new `team-grid` ACF block for the Team page (and any other page that needs a team grid) that renders a responsive grid of team members. Each member shows their photo (rounded rectangle), name, and title. The bio is collapsed by default and expands vertically with a smooth animation when the photo, name, or title is clicked.
**Architecture:** One new ACF block (`acf/team-grid`) with a single repeater of `members` (image, name, title, bio). The block uses native HTML `<details>`/`<summary>` for the click-to-expand interaction, animated with the `grid-template-rows: 0fr → 1fr` CSS pattern (no JavaScript). The block has no built-in background — the editor wraps it in an existing Section block when a dark/light background is needed. The page template is unchanged; the editor adds the block to the Team page in Gutenberg.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- `static/dist/theme.css` is committed (not gitignored) per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- Reuse existing design tokens where possible (e.g. `text-cwc-blue-01`, `var(--color-secondary)`).
- The headshot uses `rounded-md` to match other image cards in the project.
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
- `page.php` is not modified.
- No JavaScript files are added.
- Reuse the same `regACFBlocks()` auto-registration pattern as `services-list`.
- Tailwind v4 picks up class names from source files at build time. The dynamic class names used in the CSS (`grid-cols-1`, `sm:grid-cols-2`, `lg:grid-cols-3`, `w-32 h-32`, etc.) must all be literal in the source for the build to emit them.
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `views/blocks/team-grid/team-grid.php` | Block template (PHP) — renders the grid. | Create |
| `views/blocks/team-grid/team-grid.css` | Block styles (CSS) — layout, photo, summary, animation. | Create |
| `views/blocks/team-grid/block.json` | Block registration metadata. | Create |
| `acf/group_team_grid.json` | ACF field group for the `acf/team-grid` block. | Create |
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
| `tests/team-page.spec.js` | Playwright tests for the rendered block. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
---
## Task 1: ACF Field Group
**Files:**
- Create: `acf/group_team_grid.json`
**Interfaces:**
- Produces: ACF group with `key: "group_team_grid"`, location scoped to `block == acf/team-grid`, fields: `members` (repeater) → `image`, `name`, `title`, `bio`.
- [ ] **Step 1: Create the ACF field group JSON file**
Create the file `acf/group_team_grid.json` with the following exact content:
```json
{
"key": "group_team_grid",
"title": "Team Grid",
"fields": [
{
"key": "field_team_grid_members",
"label": "Members",
"name": "members",
"aria-label": "",
"type": "repeater",
"instructions": "Add team members. Click a card to expand the bio.",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"layout": "block",
"pagination": 0,
"min": 0,
"max": 0,
"collapsed": "",
"button_label": "Add Team Member",
"rows_per_page": 20,
"sub_fields": [
{
"key": "field_team_grid_member_image",
"label": "Image",
"name": "image",
"aria-label": "",
"type": "image",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"return_format": "array",
"library": "all",
"min_width": "",
"min_height": "",
"min_size": "",
"max_width": "",
"max_height": "",
"max_size": "",
"mime_types": "",
"allow_in_bindings": 0,
"preview_size": "medium"
},
{
"key": "field_team_grid_member_name",
"label": "Name",
"name": "name",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": ""
},
{
"key": "field_team_grid_member_title",
"label": "Title",
"name": "title",
"aria-label": "",
"type": "text",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"maxlength": "",
"allow_in_bindings": 0,
"placeholder": "",
"prepend": "",
"append": ""
},
{
"key": "field_team_grid_member_bio",
"label": "Bio",
"name": "bio",
"aria-label": "",
"type": "wysiwyg",
"instructions": "",
"required": 0,
"conditional_logic": 0,
"wrapper": {
"width": "",
"class": "",
"id": ""
},
"default_value": "",
"tabs": "visual",
"toolbar": "basic",
"media_upload": 0,
"delay": 0,
"allow_in_bindings": 0
}
]
}
],
"location": [
[
{
"param": "block",
"operator": "==",
"value": "acf/team-grid"
}
]
],
"menu_order": 0,
"position": "normal",
"style": "default",
"label_placement": "top",
"instruction_placement": "label",
"hide_on_screen": "",
"active": true,
"description": "Team grid block. Each member is a card with photo, name, title, and a collapsible bio.",
"show_in_rest": 0,
"display_title": "",
"allow_ai_access": false,
"ai_description": "",
"modified": 1753401600
}
```
- [ ] **Step 2: Verify the JSON parses**
Run: `node -e "JSON.parse(require('fs').readFileSync('acf/group_team_grid.json','utf8')); console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add acf/group_team_grid.json
git commit -m "✨ feat: Add ACF field group for team-grid block"
```
---
## Task 2: Block Registration JSON
**Files:**
- Create: `views/blocks/team-grid/block.json`
**Interfaces:**
- Produces: `block.json` declaring `acf/team-grid` as the block name, `team-grid.php` as the render template, `groups` icon, `common` category. `regACFBlocks()` in `functions.php` will pick this up automatically.
- [ ] **Step 1: Create the block.json file**
Create the file `views/blocks/team-grid/block.json` with the following exact content:
```json
{
"name": "acf/team-grid",
"title": "Team Grid",
"description": "A responsive grid of team members. Click a card to expand the bio.",
"category": "common",
"icon": "groups",
"keywords": ["team", "people", "staff", "bio"],
"acf": {
"mode": "preview",
"renderTemplate": "team-grid.php"
},
"supports": {
"anchor": true,
"align": false
}
}
```
- [ ] **Step 2: Verify the JSON parses**
Run: `node -e "JSON.parse(require('fs').readFileSync('views/blocks/team-grid/block.json','utf8')); console.log('OK')"`
Expected: prints `OK` and exits 0.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/team-grid/block.json
git commit -m "✨ feat: Add team-grid block.json registration"
```
---
## Task 3: Block CSS
**Files:**
- Create: `views/blocks/team-grid/team-grid.css`
**Interfaces:**
- Produces: BEM-styled CSS for `.team-grid`, `__inner`, `__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio`. Reuses `text-cwc-blue-01` for the name. Implements the `grid-template-rows: 0fr → 1fr` animation for the bio.
- [ ] **Step 1: Create the block CSS file**
Create the file `views/blocks/team-grid/team-grid.css` with the following exact content:
```css
/* Team Grid block */
.team-grid {
&__card {
@apply flex flex-col;
}
&__details {
@apply flex flex-col;
}
&__summary {
@apply flex flex-col items-center text-center cursor-pointer list-none;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
}
&__photo {
@apply w-32 h-32 sm:w-40 sm:h-40 lg:w-48 lg:h-48 object-cover rounded-md mb-4;
}
&__name {
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
}
&__title {
@apply text-gray-600 text-16px font-light italic mb-3;
}
&__bio-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
&__details[open] &__bio-wrap {
grid-template-rows: 1fr;
}
&__bio-inner {
overflow: hidden;
min-height: 0;
}
&__bio {
@apply text-gray-700 text-16px font-light leading-snug pt-2;
}
}
```
- [ ] **Step 2: Verify the BEM children are present**
Run: `grep -cE "&__(card|details|summary|photo|name|title|bio-wrap|bio-inner|bio)\b" views/blocks/team-grid/team-grid.css`
Expected: `9` (one match per BEM child).
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/team-grid/team-grid.css
git commit -m "✨ feat: Add team-grid block CSS"
```
---
## Task 4: Block Template (PHP)
**Files:**
- Create: `views/blocks/team-grid/team-grid.php`
**Interfaces:**
- Consumes: `get_field( 'members' )` → array of members each with `image` (array: `url`, `alt`), `name` (string), `title` (string), `bio` (wysiwyg HTML).
- Produces: A `<section class="team-grid ...">` element with a `__inner` grid div, and an `<article class="team-grid__card">` per member. Each card contains a `<details>` with a `<summary>` (photo + name + title) and a `__bio-wrap` containing the bio.
- [ ] **Step 1: Create the block PHP template**
Create the file `views/blocks/team-grid/team-grid.php` with the following exact content (tabs for indentation):
```php
<?php
/**
* Block Name: Team Grid
*
* A responsive grid of team members. Each card uses a native <details>
* element so the bio expands when the photo, name, or title is clicked.
*
* @package CWC
*/
namespace CWC;
$members = get_field( 'members' );
if ( empty( $members ) ) {
return;
}
$classes = 'team-grid container my-section mx-auto';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
<?php foreach ( $members as $member ) : ?>
<article class="team-grid__card">
<details class="team-grid__details">
<summary class="team-grid__summary">
<?php if ( ! empty( $member['image'] ) ) : ?>
<img
class="team-grid__photo"
src="<?php echo esc_url( $member['image']['url'] ); ?>"
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
loading="lazy"
/>
<?php endif; ?>
<?php if ( ! empty( $member['name'] ) ) : ?>
<h3 class="team-grid__name">
<?php echo wp_kses_post( $member['name'] ); ?>
</h3>
<?php endif; ?>
<?php if ( ! empty( $member['title'] ) ) : ?>
<p class="team-grid__title">
<?php echo wp_kses_post( $member['title'] ); ?>
</p>
<?php endif; ?>
</summary>
<div class="team-grid__bio-wrap">
<div class="team-grid__bio-inner">
<?php if ( ! empty( $member['bio'] ) ) : ?>
<div class="team-grid__bio">
<?php echo wp_kses_post( $member['bio'] ); ?>
</div>
<?php endif; ?>
</div>
</div>
</details>
</article>
<?php endforeach; ?>
</div>
</section>
```
- [ ] **Step 2: Lint the PHP file with PHPCS**
Run: `composer exec phpcs -- views/blocks/team-grid/team-grid.php`
Expected: exits 0 with no errors. If PHPCS reports alignment warnings on the assignment block (similar to the Task 5 services-list fix), run `composer exec phpcbf -- views/blocks/team-grid/team-grid.php` to auto-fix, then re-run PHPCS to confirm exit 0. Whitespace-only changes are acceptable; do not modify logic.
- [ ] **Step 3: Verify the dynamic utility classes are in the source**
Run: `grep -E "grid-cols-1|sm:grid-cols-2|lg:grid-cols-3|w-32|w-40|w-48" views/blocks/team-grid/team-grid.php`
Expected: at least 3 matches (Tailwind v4 must see the literal class names in the source to emit the rules).
- [ ] **Step 4: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add views/blocks/team-grid/team-grid.php
git commit -m "✨ feat: Add team-grid block PHP template"
```
---
## Task 5: CSS Import Wiring
**Files:**
- Modify: `styles/blocks/index.css`
**Interfaces:**
- Consumes: `views/blocks/team-grid/team-grid.css` (from Task 3).
- Produces: A new `@import` line at the end of `styles/blocks/index.css` so Tailwind's build picks up the block's CSS.
- [ ] **Step 1: Add the import to the block index CSS**
Append the following line at the end of `styles/blocks/index.css`:
```css
@import '../../views/blocks/team-grid/team-grid.css';
```
After the edit, the file should end with:
```
@import '../../views/blocks/services-list/services-list.css';
@import '../../views/blocks/team-grid/team-grid.css';
```
- [ ] **Step 2: Verify the import is present**
Run: `grep -cF "team-grid/team-grid.css" styles/blocks/index.css`
Expected: `1`.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add styles/blocks/index.css
git commit -m "🔧 chore: Import team-grid block CSS into theme"
```
---
## Task 6: Build the CSS
**Files:**
- Modify: `static/dist/theme.css`
**Interfaces:**
- Consumes: The new import in `styles/blocks/index.css` (from Task 5) and the block CSS (from Task 3).
- Produces: A rebuilt `static/dist/theme.css` that includes the `.team-grid*` rules and the responsive `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` utilities.
- [ ] **Step 1: Run the production Tailwind build**
Run: `npm run build`
Expected: completes with no errors.
- [ ] **Step 2: Verify the team-grid CSS classes are in the built output**
Tailwind v4 with `--optimize` **preserves** CSS-nesting rather than unwrapping `&__name` into `__name.team-grid`. Use fixed-string search (Windows-safe) that matches the preserved form:
```bash
grep -cF "&__name" static/dist/theme.css
```
Expected: `1` or more (the `&__name` rule is nested inside `.team-grid { ... }` in the dist).
- [ ] **Step 3: Verify the responsive grid utilities are in the built output**
Same Windows-safe form:
```bash
grep -cF "sm\:grid-cols-2" static/dist/theme.css
grep -cF "lg\:grid-cols-3" static/dist/theme.css
```
Expected: each returns `1` or more.
- [ ] **Step 4: Commit the rebuilt dist**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add -f static/dist/theme.css
git commit -m "🎨 build: Rebuild dist with team-grid block styles"
```
---
## Task 7: Playwright Tests
**Files:**
- Create: `tests/team-page.spec.js`
**Interfaces:**
- Consumes: The rendered `views/blocks/team-grid/team-grid.php` template (Tasks 3 + 4) on the WordPress page `/team/`.
- Produces: Three tests:
1. Photo, name, and title visible for the first card.
2. Bio is closed by default; clicking the summary opens the details and reveals the bio; clicking again closes it.
3. No axe-core a11y violations scoped to `.team-grid`.
> **Prerequisite:** the test depends on the Team page being composed in Gutenberg with at least one `Team Grid` block containing one or more members. That composition is the editor's responsibility and is not part of this plan. The tests should be authored against the intended state, not a placeholder state.
- [ ] **Step 1: Create the test spec file**
Create the file `tests/team-page.spec.js` with the following exact content:
```js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Team Grid block on /team/", () => {
test("renders the photo, name, and title for each member", async ({ page }) => {
await page.goto("/team/");
const cards = page.locator(".team-grid__card");
await expect(cards.first()).toBeVisible();
const firstCard = cards.first();
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
});
test("bio is hidden by default and expands on summary click", async ({ page }) => {
await page.goto("/team/");
const firstDetails = page.locator(".team-grid__details").first();
// Bio is closed by default.
await expect(firstDetails).not.toHaveAttribute("open", "");
// Click the summary; the details should open and the bio should be visible.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).toHaveAttribute("open", "");
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
// Click again; the details should close.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).not.toHaveAttribute("open", "");
});
test("no axe violations on the team-grid block", async ({ page }) => {
await page.goto("/team/");
const results = await new AxeBuilder({ page })
.include(".team-grid")
.analyze();
expect(results.violations).toEqual([]);
});
});
```
- [ ] **Step 2: Run the tests**
Run: `npx playwright test tests/team-page.spec.js`
Expected: all 3 tests pass. If tests fail because the Team page has not yet been composed in the editor, that's an expected prerequisite — note the prerequisite in the final report and skip the test run.
- [ ] **Step 3: Commit**
```bash
cd "C:/Users/ksolo/Herd/community-works-collaborative/wp-content/themes/community-works-collaborative"
git add tests/team-page.spec.js
git commit -m "✅ test: Add Playwright tests for team-grid block"
```
---
## Self-Review Notes
- **Spec coverage:**
- One new ACF block (`team-grid`) — covered by Tasks 1, 2, 3, 4.
- Repeater of `members` with `image` / `name` / `title` / `bio` — covered by Task 1.
- Responsive grid `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` — covered by Task 4 (PHP) and Task 6 (Tailwind emits the rules).
- Rounded-rectangle headshots via `rounded-md` — covered by Task 3 (CSS).
- Native `<details>`/`<summary>` click-to-expand — covered by Task 4 (PHP) and Task 3 (CSS).
- `grid-template-rows: 0fr → 1fr` animation — covered by Task 3 (CSS).
- No built-in background — explicitly out of scope (Section block wrapper, editor's responsibility).
- Page template not modified — confirmed; `page.php` is not in any file list.
- `header.php` / `footer.php` / page hero not touched — confirmed.
- Reuse existing design tokens (`text-cwc-blue-01`, `rounded-md`) — covered by Tasks 1, 3, 4.
- Playwright tests covering photo + name + title, summary click toggles, and a11y — covered by Task 7.
- Dist rebuild — covered by Task 6.
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output. The only conditional is in Task 7 Step 2 — explicitly called out as a prerequisite (editor must compose the page in Gutenberg), not a placeholder.
- **Type consistency:** ACF field `name: "members"` is used in Task 4 (`get_field( 'members' )`). Sub-field names `image`, `name`, `title`, `bio` are used in Task 1 (defined) and Task 4 (consumed). Class names `team-grid`, `__inner`, `__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio` are used in Task 3 (defined in CSS) and Task 4 (rendered in markup) and Task 7 (asserted in tests) — consistent.
- **Ambiguity check:** "Rounded rectangle" is concretely specified as `rounded-md`. "Opens vertically" is concretely specified as `grid-template-rows: 0fr → 1fr` with 300ms transition. "Responsive grid" is concretely specified as `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`. "No built-in background" is concretely scoped to the Section block wrapper (editor's responsibility, not this block's). All are explicit.
@@ -0,0 +1,88 @@
# Mobile Homepage Fidelity Corrections
## Goal
Correct the mobile homepage so the supplied section crops in `notes/` are pixel-accurate at a 402px viewport and adapt proportionally from 320px through 767px. Preserve all layouts at 768px and above.
## Responsive Architecture
- Scope presentation changes to `@media (max-width: 767px)`.
- Treat 402px as the exact visual baseline.
- Scale between 320px and 767px with bounded percentages, viewport-relative dimensions, `clamp()`, and component container widths.
- Keep responsive rules with the component or block that owns them.
- Reuse the existing templates and WordPress content. Do not duplicate mobile markup.
- Make minimal template changes only when CSS cannot provide correct semantic ordering or accessible controls.
## Component Corrections
### Header
Change only the hamburger button to match the reference: shape, size, coral fill, and white line treatment. Preserve the current header height, logo treatment, padding, and spacing.
### Homepage Hero
- Match the reference heading width, line breaks, left alignment, and vertical placement.
- Match the CTA dimensions, spacing, and alignment.
- Adjust the background image crop and overlay so the image remains visible behind the content.
- Scale and anchor the linework vector to the bottom composition shown in the reference.
- Position and size the location label to match the crop.
### Pull Quote
- Match the reference type size, weight, line height, and centered text width.
- Match the vertical padding and overall block height.
- Scale and position the background vector independently from the text.
### Image/Text Block
- Present the text before the image collage on mobile.
- Keep the Services and About CTAs side by side.
- Place the complete image collage below the text and CTAs.
- Reduce excess space between the collage and the contact block while retaining the reference breathing room.
### Contact Block
- Match the reference card width, height, corner radius, and bottom coral rule.
- Preserve recognizable photo detail with the reference gradient treatment.
- Match heading width, line breaks, alignment, and CTA placement.
- Scale and anchor the linework along the lower portion of the card.
### Our Work
- Match the reference content-first mobile layout and CTA row.
- Scale the decorative vector independently so its linework matches the reference density.
- Fully contain both supplied images at mobile sizes.
- Recreate their reference overlap without clipping either image.
### Footer
- Match the reference mobile structure: logo and back-to-top row, two-column navigation, full-width contact text/details, social row, and centered copyright.
- Match reference widths, gaps, type scale, icon sizing, and bottom coral border.
- Continue sourcing navigation, contact information, social links, and copyright text from WordPress.
## Content and Behavior
- Do not change editorial content, links, or WordPress field sources.
- Preserve existing desktop and tablet-above-767 behavior.
- Preserve the current accessible Recent Insights carousel behavior.
- Maintain keyboard navigation, focus visibility, semantic reading order, and sufficient touch targets.
## Verification
- Add Playwright regression coverage at 402px for content order, CTA layout, and key component geometry.
- Check proportional behavior at 320px and 767px.
- Add assertions at 768px or wider that guard against mobile overrides leaking into desktop.
- Run the production CSS build.
- Run all configured Playwright and axe accessibility tests.
- Run PHPCS, PHP syntax checks, Prettier, Composer validation, and `git diff --check`.
- Capture a final 402px full-page screenshot and compare it directly with the supplied section crops.
## Acceptance Criteria
1. At 402px, each listed section visually matches its corresponding crop in `notes/`.
2. Between 320px and 767px, content remains proportional, readable, unclipped, and operable.
3. At 768px and above, the existing layout is unchanged.
4. The image/text block uses text-first order, a side-by-side CTA row, and a complete collage below.
5. The contact and Our Work vectors and images match the intended crop and containment behavior.
6. The footer follows the reference hierarchy and alignment.
7. All configured verification commands pass without accessibility regressions.
@@ -0,0 +1,51 @@
# Mobile Vector Replacement
## Goal
Swap the existing mobile vector sources in the homepage hero, pull quote, contact block, and our-work block for the artist-supplied `static/img/mobile-*-vector.svg` files. Each new SVG is drawn at the 402px mobile baseline so it doesn't need a desktop-vector clip-path trick to look right.
## Approach
- Treat each block separately. Same desktop element, an additional mobile element, CSS show/hide.
- Preserve all desktop and tablet-above-767 behavior.
- Lazy-load the new `<img>` elements the same way the desktop sources are loaded.
## Per Block
### Hero
- New partial `views/partials/hero-vector-mobile.php` renders an `<img class="hero-vector--mobile">` referencing `static/img/mobile-hero-vector.svg`.
- `homepage-hero.php` loads both partials inside the existing `.heroVector .vector` container.
- `homepage-hero.css` hides the desktop SVG and shows the mobile `<img>` below 768px. The existing vector positioning rules still apply to the wrapper.
### Pull Quote
- `pull-quote.php` swaps the existing PNG source for `static/img/mobile-pullquote-vector.svg` at the existing `.pull-quote__vector--mobile` slot.
- CSS unchanged. The element already had `display: none` at desktop and `object-fit: cover` at mobile.
### Contact Block
- `contact-block.php` adds an `<img class="contact-block__linework contact-block__linework--mobile">` next to the existing SVG partial.
- `contact-block.css` hides the desktop SVG and shows the mobile `<img>` at mobile. The mobile `<img>` fills the card width (the new SVG is 342×187, ratio matches the card).
- The reusable contact linework ratio assertion updates from `628/282` to `342/187`.
- The linework geometry assertion updates to expect `y ≈ 299`, `height ≈ 188`, `width = card.width`.
### Our Work
- `our-work.php` replaces the clip-path composition (CSS `--our-work-vector-image` + `::before`/`::after` background images) with a real `<img>` referencing `static/img/mobile-work-vector.svg`.
- `our-work.css` drops the `vector-mobile-clip` rules and the two `::before/::after` background layers. Mobile vector is now a single image covering the block.
- The linework composition test updates to assert against the `<img>` (visible, has the right `currentSrc`) instead of the CSS background-image layers.
## Verification
- `npm run build` then `npx playwright test --reporter=line` — 36/36 pass.
- The contact linework test waits for the lazy-loaded `<img>` to finish loading before measuring its bounding rect.
- PHPCS clean on changed files. Pre-existing `lib/activation.php` CRLF error is untouched.
- `git diff --check` clean.
- `composer validate --no-check-publish` passes.
## Acceptance Criteria
1. Below 768px the hero, pull quote, contact block, and our-work block render the artist-supplied mobile vectors.
2. At 768px and above the original desktop SVGs (or PNG for the original pullquote) render unchanged.
3. Existing responsive geometry tests pass with the updated ratios and positions.
@@ -0,0 +1,118 @@
# Inner Page Layout
## Goal
Build the inner page layout shown in `notes/inner-page-mockup.png` so that every WordPress page renders the same structure: a blue-to-teal gradient hero with the page title, an optional hero image at its natural size on the right with the bottom hanging over the hero's bottom edge, a decorative vector along the lower-left with breathing room from the bottom, an optional intro text block (the editor-supplied `intro` field) on the left below the H1, and the standard editor body below the hero. Different pages vary only by hero image, vector, intro text, and body content.
## Scope
- Applies to all `page` post-type views rendered by `page.php` (and through it, the `page-hero` partial that `header.php` already loads).
- Does not change the front page, single posts, archives, search, or 404 templates.
- Does not change the homepage-hero block.
- Does not add a new contact block to the page body — the dark navy block with the orange rule in the mockup is the existing site footer, not a new CTA.
## Architecture
The layout is a fixed two-part composition:
1. **Hero**`views/partials/page-hero.php` (existing), extended with the blue-to-teal gradient backdrop, two new ACF fields, the hero image rendered on the right (faded to transparent on the left so the heading has a clear backdrop), a decorative vector along the bottom, the heading, and the intro block.
2. **Body**`the_content`, unchanged.
`page-hero.php` is the orchestrator: it renders the gradient backdrop, the optional hero image and vector, the heading, and the intro block all inside a single `.page-hero` wrapper. The `intro` ACF field, when set, renders as a two-column heading + body block inside the hero (not as a separate page section). The intro heading is a hard-coded "Our Work" label; the body is the editor-supplied `intro` text.
`page.php` continues to render `the_content` after the hero. Sidebar behavior (already gated by `hasSidebar()` and the `Page Sidebar` field group) is preserved. `page.php` no longer loads the `page-intro` partial directly — the partial is loaded by `page-hero.php` instead.
`hasPageHeader()` in `lib/extras.php` continues to gate the hero. The function's current behavior (hero shows when `hero_style !== 'none'`) is kept; no change to that filter is required.
## ACF Field Group Changes
Add two fields to the existing `Page Heading` group (`acf/group_60bfb84ae973c.json`):
- `hero_image` — type: `image`, return_format: `array`, library: `all`, preview_size: `medium`. Conditional: shown when `hero_style !== 'none'`. Optional.
- `hero_vector` — type: `image`, return_format: `array`, library: `all`, preview_size: `medium`. Conditional: shown when `hero_style !== 'none'`. Optional.
No other ACF group changes. The existing `heading`, `intro`, and `call_to_actions` fields are reused as-is.
## Hero Partial — `views/partials/page-hero.php`
Changes:
- The wrapper `<div>` no longer carries the `bg-dark` or `bg-cover bg-no-repeat` Tailwind utilities. The wrapper inherits a blue-to-teal gradient from `.page-hero` (defined in `styles/blocks/page-hero.css`), the same gradient used by the homepage-hero block. When the editor sets a `background_color` ACF field, the inline `style` attribute on the wrapper overrides the gradient with that solid color.
- Read the two new fields (`hero_image`, `hero_vector`) and the existing `intro` field.
- Render the breadcrumbs, the H1, and the intro **inside** a single `.page-hero__content` container, **before** the image and vector markup. The H1 has a dedicated `.page-hero__heading` class for typography control. The intro, when non-empty, is a single `.page-hero__intro` div containing only the editor-supplied intro text (no hard-coded heading).
- Render `hero_image` as a foreground absolutely-positioned `<img>` (class `.page-hero__media`), anchored to the right of the hero with `bottom: -10%` so the image's bottom hangs over the bottom edge of the hero. The image is shown at its natural size (no `object-fit: cover`, no mask); it sits above the gradient backdrop and below the content layer.
- Render `hero_vector` as an absolutely-positioned `<img>` (class `.page-hero__vector`), anchored to the bottom-left of the hero with `bottom: clamp(1.5rem, 4vw, 3rem)` for spacing from the bottom edge. The vector sits above the image and below the content.
- When no `hero_image` is set, the gradient (or the override `background_color`) is the only background. When no `hero_vector` is set, the bottom of the hero is just the gradient.
Both new images are `loading="lazy"` and decorative. No hard-coded copy is rendered.
## Hero Partial CSS — `styles/blocks/page-hero.css` (new)
A dedicated file follows the project's existing pattern (`contact-block.css`, `homepage-hero.css`).
Rules:
- `.page-hero` — the wrapper. `position: relative; isolation: isolate; overflow: hidden; min-height: clamp(31rem, 38vw, 36rem);`. `background: linear-gradient(83.68deg, #032F46 3.13%, #006196 45.91%, #8FC9E6 96.27%);` — same gradient as the homepage-hero block.
- `.page-hero__content``position: relative; z-index: 10; padding-block: clamp(2rem, 6vw, 4rem);` so the heading, intro, and breadcrumbs always sit above the image and vector layers.
- `.page-hero__heading``font-family: var(--font-quincy, 'Quincy', serif); font-weight: 400; font-size: clamp(2.5rem, 6vw, 4.5rem); line-height: 1.05; max-width: 36rem;` so the H1 doesn't span the full hero width and stays aligned with the intro text.
- `.page-hero__intro``font-size: clamp(1rem, 1.5vw, 1.25rem); line-height: 1.5; max-width: 36rem; opacity: 0.95;` so the intro text sits alongside the H1.
- `.page-hero__media``position: absolute; right: clamp(1.5rem, 4vw, 4rem); bottom: -10%; width: clamp(18rem, 32vw, 28rem); height: auto; pointer-events: none; z-index: 1;`. The image is shown at its natural size.
- `.page-hero__vector``position: absolute; left: 0; bottom: clamp(1.5rem, 4vw, 3rem); width: clamp(28rem, 60vw, 56rem); height: auto; pointer-events: none; z-index: 5;`. The vector is sized to span the lower-left of the hero with breathing room from the bottom edge.
- Mobile (≤ 767px) breakpoint: hero min-height becomes `clamp(28rem, 130vw, 40rem)`; image shrinks to `60vw` and sits flush right (`right: 0`); vector shrinks to `80vw`.
These rules are scoped to `.page-hero` and only affect the page-hero partial. They do not affect the homepage-hero block, which uses a different root class.
## page.php Wiring
`page.php` continues to render the article container, `the_content`, and the sidebar (when `hasSidebar()`). The hero is rendered by `header.php` before this template runs. No other behavior changes.
## Data Flow
- Editor experience: on every page (and post) edit screen, the existing Page Heading meta box shows the existing fields plus two new ones (Hero Image, Vector). The editor uploads the assets, sets a background color if desired, writes the optional heading override, intro, and CTAs, then writes the body in the standard block editor.
- Render: `header.php` loads the page-hero partial → `page-hero.php` renders the gradient + optional hero image + vector + heading + (when `intro` is set) the intro block, all inside `.page-hero``page.php` renders `the_content` for the body blocks.
- Per-page variation: hero image, vector, intro text, and body content. Everything else is shared.
## Error Handling and Edge Cases
- `hero_image` empty → hero uses the gradient (or the override `background_color`) as the only background.
- `hero_vector` empty → no vector renders; the bottom of the hero is just the gradient.
- `intro` empty → the `.page-hero__intro` div is not rendered; the hero shows the H1 only.
- `heading` empty → falls back to `getTheTitle()` (existing behavior, unchanged).
- Background color set without a hero image → existing behavior; the inline `style` overrides the gradient with the solid color.
- No sidebar field set → existing `hasSidebar()` filter returns false → no sidebar (existing behavior).
- Lazy-loaded images must await `load`/`complete` before Playwright reads dimensions (existing project convention; see `tests/mobile-homepage.spec.js:641`).
## Out of Scope
- New ACF blocks. This layout uses the existing Page Heading meta box, not the block editor.
- Front page, single posts, archives, search, 404 changes.
- Homepage-hero block changes.
- The contact-block, pull-quote, our-work, or other existing blocks.
- A new contact CTA block in the page body. The mockup's bottom dark block is the existing site footer.
- Migration of existing pages' content.
## Verification
- Add `tests/inner-page.spec.js` covering:
- Desktop (1280px): hero, intro, body render with the intro block as a descendant of the hero. The H1 is visible. The hero exposes the gradient backdrop.
- Mobile (402px): intro is inside the hero and fits within the viewport. The vector is visible at the bottom of the hero. No clipped text.
- 320px and 767px: nothing clips, hero and intro remain inside the viewport.
- A page with no `intro` field renders the hero without a `.page-hero__intro` wrapper.
- A page with no `hero_image` and no `hero_vector` renders the existing gradient text-only hero unchanged.
- Add a test page fixture (or use an existing page) to drive the test. If no fixture exists, the test seeds a page in a `beforeAll` and tears it down in `afterAll`.
- Run `npm run build` to regenerate `static/dist/`.
- Run the full Playwright suite (`npx playwright test`) — including the existing `tests/mobile-homepage.spec.js` and `tests/site-a11y.spec.js` — to confirm no regressions.
- Run `composer lint` to catch PHPCS issues.
- Capture a 1280px and a 402px full-page screenshot of the test fixture for visual comparison against `notes/inner-page-mockup.png`.
## Acceptance Criteria
1. Every WordPress page renders the new layout when the page-hero partial is active.
2. The hero shows the blue-to-teal gradient backdrop, the page title (or heading override) in the Quincy serif face, and — when supplied — a hero image at its natural size on the right with the bottom hanging over the hero's bottom edge, and a decorative vector along the lower-left with some spacing from the bottom edge.
3. When the `intro` field is set, a single text block appears inside the hero below the H1, in the same left column. When empty, the intro block is omitted entirely.
4. The body renders `the_content` as before, with the standard block editor.
5. No hard-coded copy is rendered by the partial — only the page title, the editor-supplied `intro` text, and the editor-supplied images.
6. The hero image and vector render at all viewports without clipping.
7. The page renders without console errors, axe violations, or PHPCS errors.
8. Existing single posts, archives, search, and 404 pages render the page-hero partial unchanged.
9. Existing tests (`mobile-homepage`, `site-a11y`) continue to pass.
@@ -0,0 +1,178 @@
# Blog Index Layout
## Goal
Build the blog index layout shown in `blog-mockup.png` (theme root) so `/blog/` and any other view that uses the WordPress `index.php` template (category, tag, author, search) render with a clean design: a "Blog" page title at the top, a grid of post cards (each card shows image, title, and byline only), and the existing sidebar logic preserved on the right side.
## Scope
- Applies to `index.php` only — the blog index, category archives, tag archives, author archives, and search results all fall through this template in the current theme (no `archive.php`, `category.php`, `tag.php`, `author.php`, or `home.php` exist).
- Does **not** change `single.php` (single-post view) or `page.php` (static pages). The Services hero gate is unaffected.
- Does **not** change `front-page.php` (the home page).
- The existing post list component (`.post-list__posts`, `.post-list__post`, `.post-list__title`, `.post-list__byline`) is preserved and used.
- The existing sidebar logic (`hasSidebar()` → 4-col grid with sidebar) is preserved.
- The existing pagination (`the_posts_pagination`) is preserved.
## Architecture
The change is a small refactor of `index.php` and an unfreeze of `styles/components/post-list.css`:
1. **`index.php`** — add a "Blog" page title at the top of the post list section, and trim the post card to image + title + byline (remove the categories row and the 15-word excerpt). The grid, sidebar, and pagination remain unchanged.
2. **`styles/components/post-list.css`** — unfreeze the file (currently empty boilerplate) and add styles for the new `.post-list__h1` heading. Use the project's existing H1 typography (Quincy serif, fluid `--h1` size, default margin).
3. **A new test** in `tests/blog-page.spec.js` — verifies the blog renders the page title, the post grid, and no inner-page hero. Mirrors the structure of `tests/contact-page.spec.js`.
The post list grid is already implemented with `grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]` (a responsive auto-fit grid that gives 3-4 columns on desktop, fewer on smaller viewports). The mockup shows 3 columns at desktop, which is what the auto-fit grid gives at ~1280px. No change to the grid layout.
## ACF Field Group Changes
None. The blog index uses the standard WordPress post data (`the_title`, `the_permalink`, `the_author`, `the_date`, `get_the_post_thumbnail_url`, `get_the_category`). No custom fields.
## index.php Wiring
Find the post list section:
```php
<?php if ( have_posts() ) : ?>
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
<div class="post-list">
<div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-6">
<?php
while ( have_posts() ) :
the_post();
?>
<div class="post-list__post flex flex-col border border-secondary rounded-md shadow-lg ...">
<figure class="post-list__img ...">
... (featured image)
</figure>
<div class="post-list__details px-4 py-8 flex flex-col grow">
<div class="post-list__cats">
Posted in: ... (categories)
</div>
<a href="<?php the_permalink(); ?>" class="">
<h2 class="post-list__title ..."><?php the_title(); ?></h2>
</a>
<div class="post-list__excerpt mb-6">
<?php customExcerpt( get_the_content(), 15 ); ?>
</div>
<div class="post-list__byline mt-auto">
<span class="post-list__author"><?php echo esc_html( ucfirst( get_the_author() ) ); ?> &mdash;</span>
<span class="post-list__date"><?php echo get_the_date(); ?></span>
</div>
</div>
</div>
<?php endwhile; ?>
</div>
<div class="post-list__pagination">
<?php the_posts_pagination( ... ); ?>
</div>
</div>
</div>
<?php else : ?>
...
<?php endif; ?>
```
Changes:
1. **Add a page title** at the top of the post list, after the `<div class="post-list">` opening tag:
```php
<div class="post-list">
<h1 class="post-list__h1">Blog</h1>
<div class="post-list__posts grid ...">
...
</div>
...
</div>
```
The title text is hard-coded "Blog" — this matches the spec's "Standard H1 (Quincy, project default)" answer. The title is the same on category, tag, author, and search views (the mockup only shows the blog index, but consistency is the simplest choice for now; a future task can add per-view titles if needed).
2. **Remove the categories row** (`<div class="post-list__cats">...`).
3. **Remove the 15-word excerpt** (`<div class="post-list__excerpt mb-6">...`).
4. **Keep the byline** (`<div class="post-list__byline mt-auto">...`).
5. **Tighten the card's details padding** — the current `px-4 py-8` leaves a lot of empty space when there's no excerpt. Change to `px-4 py-6` (or whatever the design lands on after visual review; spec this as `px-4 py-6`).
6. **Empty state** — when no posts are found, the "Nothing here yet..." heading still renders. This is the existing behavior; no change. (The `else` branch is unchanged.)
7. **`customExcerpt()` helper** is now unused in `index.php`. The helper itself (in `lib/extras.php` or `lib/helpers.php`) is left as-is — other templates may use it. Just don't call it from `index.php`.
## post-list.css Wiring
The file is currently empty boilerplate. Add:
```css
/* Blog/post index listing styles */
.post-list__h1 {
color: var(--color-cwc-blue-01);
font-family: var(--font-quincy, 'Quincy', serif);
font-size: var(--h1);
font-weight: 700;
line-height: 1.2;
margin: 0 0 2rem;
text-align: left;
}
```
The `color: var(--color-cwc-blue-01)` matches the contact-info heading and the inner-page hero heading for visual consistency. The other values are the project's existing H1 typography tokens (Quincy serif, fluid `--h1` size, default 700 weight).
**No other changes** to `post-list.css`. The card styling (border, shadow, padding) is currently inline via Tailwind utility classes in `index.php`; the spec leaves that as-is.
## Data Flow
- **Editor flow:** the blog index uses standard WordPress post data. Editors write posts in the WP admin, set a featured image, assign categories, and publish. The blog renders whatever is published.
- **Render flow:** WordPress routes `/blog/`, `/category/...`, `/tag/...`, `/author/...`, and `/?s=...` (search) to `index.php`. The template loops over the matching posts, renders each as a card, and renders pagination at the bottom. The sidebar is rendered when `hasSidebar()` returns true.
- **Per-page variation:** none — the design is fixed.
## Error Handling and Edge Cases
- **No posts found** → the "Nothing here yet..." empty state renders.
- **Pagination** → preserved; renders at the bottom of the post list.
- **Featured image missing** → the existing `picsum.photos` fallback is preserved. (The current code has `get_the_post_thumbnail_url() ? $featImg : 'https://picsum.photos/600/400?random=' . get_the_ID();`. This is a hack — a real fallback would be a project default image. Out of scope for this task.)
- **Page title when there are no posts** → still renders "Blog" (the title is unconditional). This is fine — a user navigating to an empty category should still know what page they're on.
- **Search results** → the "Blog" title is misleading for search. A future task can add a per-view title (`is_search() ? 'Search Results' : 'Blog'`). Out of scope here.
- **Hero** → the `header.php` change from the previous turn (`$showHero = isServicesDescendant() && get_field( 'hero_style' ) === 'default'`) already ensures the blog renders without the inner-page hero. This task doesn't touch `header.php`.
## Out of Scope
- New ACF fields for posts.
- New archive templates (`archive.php`, `category.php`, `tag.php`, `author.php`).
- Single-post view (`single.php`) changes.
- A proper fallback image (the `picsum.photos` URL is a known wart but not a blocker).
- Pagination restyling (the existing `the_posts_pagination()` is fine).
- Changing `front-page.php` (the home page).
- Sidebar widget changes (the existing `sidebar.php` works as-is).
- A per-view page title (category-specific, tag-specific, etc.). The current spec hard-codes "Blog" everywhere.
## Verification
- Add `tests/blog-page.spec.js` covering:
- Desktop (1280px): the blog page renders with the "Blog" H1, the post grid, no inner-page hero, the sidebar (when `hasSidebar()` is true).
- Mobile (402px): the post grid stacks to a single column, no horizontal overflow, the page title is visible.
- 320px: nothing clips.
- Axe scan: no violations.
- No console errors.
- Each post card has: image, title link, byline (author + date). Does NOT have a categories row or an excerpt.
- Run `npm run build` to regenerate `static/dist/`.
- Run the full Playwright suite (`npx playwright test`) including `tests/mobile-homepage.spec.js`, `tests/site-a11y.spec.js`, `tests/inner-page.spec.js`, `tests/contact-page.spec.js`, `tests/services-hero-scope.spec.js`, and the new `tests/blog-page.spec.js`.
- Run `composer lint` to catch PHPCS issues.
- Capture a 1280px full-page screenshot of `/blog/` for visual comparison with `blog-mockup.png`.
## Acceptance Criteria
1. `/blog/` renders a "Blog" page title (Quincy serif, CWC Blue 01, the project's standard H1 style).
2. Each post card on `/blog/` shows: featured image, title, byline (author + date). No categories row. No excerpt.
3. The existing sidebar logic is preserved: when `hasSidebar()` is true, the post grid is in the main column and the sidebar is in the 4th column of a 4-col grid.
4. Category, tag, author, and search views (which all use `index.php`) render with the same design — page title + post grid + sidebar.
5. No inner-page hero is rendered on the blog (this was the previous turn's change; preserved here).
6. No console errors, axe violations, or PHPCS errors.
7. The new blog test passes; existing tests continue to pass with no regressions.
@@ -0,0 +1,264 @@
# Contact Page Layout
## Goal
Build the contact page layout shown in `notes/contact-mockup-figma-2.png` (the Figma desktop export) so the contact page renders as a clean two-column 50/50 split: contact info (heading + address/email/phone with CWC Blue 03 outline icons) on the left with a white background and generous padding, and a desaturated CWC Blue 03 Leaflet map on the right that fills to the viewport edge. A Gravity Forms contact form sits between the contact info and the map. The page has no hero — just a ~3rem top margin below the header and no page title.
## Scope
This spec covers two related changes:
1. **The contact page layout** (the primary work) — a new two-column 50/50 layout for the contact page: contact info on the left, Gravity Forms + Leaflet placeholder on the right, no hero, no page title.
2. **Re-scope the existing inner-page hero** so it renders only on Services children and grandchildren. The current logic in `header.php` (`$showHero = ... hero_style === 'default' ...`) gates the hero per-page via the `hero_style` ACF field; the new logic adds an additional gate: even when `hero_style === 'default'`, the hero is suppressed unless the current page is the Services page, a child of the Services page, or a grandchild of the Services page. All other pages get a plain page body with no hero (matching the contact page's no-hero treatment).
### Contact page scope
- Applies only to the contact page, via the editor dropping the `acf/contact-info` block on the contact page.
- The existing `acf/contact-info` block is modified to render the new layout (left/contact-info, right/Leaflet placeholder) and drop the `InnerBlocks` call.
- `page.php` is not changed — non-contact pages still get the inner-page hero (gated per the re-scope) or no hero at all.
- No hero is rendered on the contact page. The contact page's `hero_style` ACF field should be set to `none` (this is the existing default, already handled by `header.php`).
- The existing site-header, site-footer, and `hasSidebar()` filter are unchanged. The contact page does not show the sidebar (the contact-info block replaces it).
- The existing `contact-block` block (the dark-navy CTA banner used on the home page) is unaffected. So is the footer.
### Inner-page hero re-scope
- The hero (already implemented: gradient backdrop, hero image, vector, intro) renders only when ALL of the following are true:
- `get_field( 'hero_style' ) === 'default'` (the existing gate), AND
- the current page is the Services page, a child of the Services page, or a grandchild of the Services page.
- "Services page" is identified by slug: a page with `post_name = 'services'`. This is robust to the Services page's ID changing across environments.
- "Child of the Services page" means `post_parent == $services_id`.
- "Grandchild of the Services page" means the current page's `post_parent`'s `post_parent == $services_id`. (Two levels only; we do not need a recursive walker because the site's structure is shallow.)
- A new PHP helper `isServicesDescendant()` lives in `lib/extras.php` next to `hasPageHeader()` and `hasSidebar()`. It returns `true` for the Services page itself, its children, and its grandchildren; `false` otherwise. It accepts the current post (default: `$post`) and an optional services-page override (default: looked up by slug).
- `header.php` updates the `$showHero` calculation to also require `isServicesDescendant()`. The existing `is_home() || is_archive() || is_single() || is_search() || is_404()` conditions still gate the hero on those views (those views bypass the Services gate).
- The `Page Heading` ACF group's `hero_style` field is unchanged. The conditional-logic that hides the hero_image and hero_vector fields when `hero_style === 'none'` is unchanged. Editors continue to control the gate per-page; the new check is a global "is this a Services descendant?" filter on top of that.
- Pages that currently have `hero_style === 'default'` set but are NOT Services descendants lose their hero after this change. This is intentional (per the user's "scope only for Services children/grandchildren" instruction), but the editor may need to set `hero_style = 'none'` on those pages if they don't already have it. A pre-flight check lists the affected pages so the editor can review.
## Architecture
The contact page is composed of three layers:
1. **Page wrapper**`page.php` continues to call `get_header()` and `get_footer()`. The contact page's body is the new `acf/contact-info` block, which the editor drops onto the page (the existing block). Because the block manages its own layout, no new page template is needed.
2. **Contact info block**`views/blocks/contact-info/contact-info.php` is rewritten to render the new two-column 50/50 layout. The left column holds a heading, the address/email/phone rows (from the `contact_info` global option, with CWC Blue 03 outline icons), and a Gravity Forms `[gravityform id="1" title="false"]` shortcode. The right column holds a placeholder `<div id="contact-map">` for the Leaflet embed.
3. **Block CSS**`views/blocks/contact-info/contact-info.css` is rewritten to lay out the two columns, the icon rows, and the Gravity Forms field overrides. Forms.css (`styles/base/forms.css`) already styles most of the form; the new CSS adds the field-level overrides (uppercase CWC Blue 01 labels, right-aligned CWC Orange 01 required asterisks, 30% opacity CWC Blue 03 input fills with borders, taller message textarea, and the secondary-button submit).
`page.php` is untouched. The contact page's editor just adds the `Contact Info` block and the existing flow renders it.
## `isServicesDescendant()` — `lib/extras.php`
```php
function isServicesDescendant( $post = null, $services_id = null ) {
if ( null === $post ) {
global $post;
}
if ( ! $post instanceof WP_Post ) {
return false;
}
if ( null === $services_id ) {
$services = get_page_by_path( 'services' );
if ( ! $services ) {
return false;
}
$services_id = $services->ID;
}
// The Services page itself.
if ( (int) $post->ID === (int) $services_id ) {
return true;
}
// Direct child of the Services page.
if ( (int) $post->post_parent === (int) $services_id ) {
return true;
}
// Grandchild: walk one level up.
$parent = get_post( $post->post_parent );
if ( $parent && (int) $parent->post_parent === (int) $services_id ) {
return true;
}
return false;
}
```
`get_page_by_path( 'services' )` is a core WP function that finds a page by slug. It returns `null` if the Services page doesn't exist (e.g. on a fresh dev install), in which case the function returns `false` and no hero renders anywhere — a safe default.
## `header.php` change
The current `$showHero` is:
```php
$showHero = in_array(
true,
array(
get_field( 'hero_style' ) === 'default',
is_home(),
is_archive(),
is_single(),
is_search(),
is_404(),
),
true
);
```
The new `$showHero` is:
```php
$showHero = isServicesDescendant() && get_field( 'hero_style' ) === 'default';
```
The hero renders only on the Services page, its direct children, and its grandchildren — and only when the editor has set `hero_style` to `'default'`. The blog index, archives, single posts, search, and 404 views do **not** render the inner-page hero at all. Those views rely on their own templates (`front-page.php`, `single.php`, `search.php`, `404.php`, `archive.php`) for any visual treatment.
> **Note:** an earlier draft of this spec kept `is_home() / is_archive() / is_single() / is_search() / is_404()` in the bypass list so blog posts and single posts kept their old hero behavior. The user revised this on 2026-07-04 after seeing the live result: the Services hero treatment should not apply to any non-Services view. The change is captured in commits `2fb1d67` (header.php) and `ee9c9ce` (test update).
## ACF Field Group Changes
None. The `contact_info` global option group (`acf/group_5fd3e006e5da5.json`) already provides `email`, `phone`, `address`, `fax`, and `hours` sub-fields. We read `email`, `phone`, and `address` only.
The `acf/contact-info` block (`acf/group_5fd3e006e5da5.json`'s consumer, defined by `views/blocks/contact-info/block.json`) currently has no ACF fields of its own — it's an `InnerBlocks` block. We will not add ACF fields. The heading, the shortcode, and the Leaflet placeholder are hard-coded into the block's render template, since they are part of the design, not per-page content.
If the editor later needs to override the heading or shortcode, that becomes a follow-up: add ACF fields to the block, default them to the current hard-coded values, and render the field value with a fallback to the hard-coded string.
## Contact Info Block — `views/blocks/contact-info/contact-info.php`
The block renders a `<section class="contact-info">` with a flex row that flips to a column on mobile. The block manages its own width — it does NOT inherit the `.container` constraint from `page.php`'s `.entry-content`. To achieve this, the block's outermost wrapper gets `container` itself and resets the page's `entry-content` padding so the map can reach the viewport edge on the right.
```php
<section <?php echo esc_attr( $wrapper ); ?>>
<div class="contact-info__grid">
<div class="contact-info__details">
<h1 class="contact-info__heading">Contact</h1>
<ul class="contact-info__items">
<li class="contact-info__item contact-info__item--address">
<span class="contact-info__icon" aria-hidden="true">[icon-address]</span>
<span class="contact-info__value"><?php echo wp_kses_post( get_field( 'contact_info', 'option' )['address'] ); ?></span>
</li>
<li class="contact-info__item contact-info__item--email">
<span class="contact-info__icon" aria-hidden="true">[icon-email]</span>
<a class="contact-info__value" href="mailto:<?php echo esc_attr( get_field( 'contact_info', 'option' )['email'] ); ?>">
<?php echo esc_html( get_field( 'contact_info', 'option' )['email'] ); ?>
</a>
</li>
<li class="contact-info__item contact-info__item--phone">
<span class="contact-info__icon" aria-hidden="true">[icon-phone]</span>
<a class="contact-info__value" href="tel:<?php echo esc_attr( get_field( 'contact_info', 'option' )['phone'] ); ?>">
<?php echo esc_html( get_field( 'contact_info', 'option' )['phone'] ); ?>
</a>
</li>
</ul>
<div class="contact-info__form">
<?php echo do_shortcode( '[gravityform id="1" title="false" description="false" ajax="true"]' ); ?>
</div>
</div>
<div class="contact-info__map" id="contact-map" aria-label="Map of downtown Winnipeg" role="region">
<!-- Leaflet map will mount here. Pin: orange-01 + white map marker (TBD). -->
</div>
</div>
</section>
```
The `[icon-address]`, `[icon-email]`, `[icon-phone]` placeholders are inlined SVGs with `stroke="currentColor"` and `fill="none"`, sized to the CWC Blue 03 color via CSS `color: var(--color-cwc-blue-03)`. The three SVGs are stored in `static/img/contact/` and referenced via `get_theme_file_uri()` (the same pattern the existing `static/img/footer-email.svg` etc. use).
## Contact Info Block CSS — `views/blocks/contact-info/contact-info.css`
Rules (mobile-first, desktop via `@media (min-width: 1024px)`):
- `.contact-info``position: relative; background: var(--color-white); padding-block: clamp(2rem, 6vw, 4rem);`. Resets the `.entry-content` container.
- `.contact-info__grid` — on mobile, single column. On `≥1024px`, `display: grid; grid-template-columns: 1fr 1fr; gap: 0;`. The right column extends to the viewport edge via negative right margin matching the page's container offset (the same trick the homepage-hero and the contact-block use to break out of the container on one side).
- `.contact-info__details``padding-inline: clamp(1.5rem, 5vw, 3rem); max-width: 36rem;` so the heading, address, and form are aligned with the logo's left edge and don't span the full half.
- `.contact-info__heading``font-family: var(--font-quincy, 'Quincy', serif); font-weight: 400; font-size: clamp(2.5rem, 6vw, 4.5rem); line-height: 1.05; color: var(--color-cwc-blue-01); margin-bottom: 1.5rem;`. Matches the inner-page hero heading for visual consistency.
- `.contact-info__items``list-style: none; padding: 0; margin: 0 0 2rem 0; display: flex; flex-direction: column; gap: 0.75rem;`.
- `.contact-info__item``display: flex; align-items: flex-start; gap: 0.75rem; color: var(--color-dark); font-size: 1rem; line-height: 1.5;`.
- `.contact-info__icon``display: inline-flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; flex-shrink: 0; color: var(--color-cwc-blue-03);`. Icons inherit the color via `currentColor`.
- `.contact-info__value``text-decoration: none; color: inherit; &:hover { color: var(--color-secondary); }`.
- `.contact-info__form` — Gravity Forms wrapper. Adds a top margin (`mt-8`) and the form-field overrides below.
- `.contact-info__map``background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white); min-height: 24rem; position: relative;`. On `≥1024px`, breaks out to the viewport's right edge by setting `margin-right: calc(50% - 50vw); width: calc(50vw + 0px);`. The right edge is anchored to `100vw`, not the parent grid, so the map fills the right half of the screen on any desktop width.
- Mobile (≤767px): `.contact-info__grid` collapses to one column, the map drops below the form, the right-edge break-out is removed.
### Gravity Forms field overrides (in `contact-info.css`)
Append after the layout rules:
- `.contact-info__form .gfield_label``text-transform: uppercase; color: var(--color-cwc-blue-01); font-size: 0.875rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0.5rem;`.
- `.contact-info__form .gfield_required``color: var(--color-cwc-orange-01); margin-left: 0.25rem; font-weight: 700;`. Hide the default asterisk text and use an `::after` pseudo-element with content `" *"` so the asterisk is right-aligned inside the label (the default `gfield_required` is a trailing span; we override the label's flex layout to push the asterisk to the right with `display: flex; justify-content: space-between;` and put the `::after` on the label itself).
- `.contact-info__form input[type="text"], .contact-info__form input[type="email"], .contact-info__form textarea``background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white); border: 1px solid var(--color-cwc-blue-03); border-radius: 0.375rem; padding: 0.75rem 1rem; font-size: 1rem;`. Override the existing `border-2 border-primary` from `forms.css` (this block has its own scope, so cascade works).
- `.contact-info__form textarea``min-height: 10rem;` so the message field is "substantially taller" than the name/email inputs.
- `.contact-info__form .gform_footer input[type="submit"], .contact-info__form .gform_footer button[type="submit"]` — same visual treatment as the header's Contact button: `class="button dark"` is added by the Gravity Forms markup if we set `gforms form settings → button class` to `button dark`; otherwise we apply `class="button dark"` via jQuery or a CSS rule that targets `.gform_footer input[type="submit"]` and forces the same look. The button gets the secondary-button gradient: `background: var(--background-image-button-gradient); color: var(--color-white); border: 3px solid var(--color-secondary); border-radius: 1.25rem 0.25rem 1.25rem 0.25rem; padding: 0.625rem 2rem; font-weight: 700;`. This matches the existing header Contact button shape.
## Static Assets
Three new SVG icons in `static/img/contact/`:
- `icon-address.svg` — map pin outline, 24×24 viewBox, `stroke="currentColor" fill="none" stroke-width="1.5"`.
- `icon-email.svg` — envelope outline, 24×24, same stroke.
- `icon-phone.svg` — phone outline, 24×24, same stroke.
The SVGs are sourced from the user (the spec says "will provide SVGs"). Until they arrive, the block renders empty `.contact-info__icon` spans so the layout is correct but the icons are blank — and the test suite can still verify the layout.
The Leaflet map pin (orange-01 + white) and the actual Leaflet embed code are not yet available. The right column is a styled placeholder with a comment indicating where Leaflet will mount.
## Data Flow
- **Editor flow**: on the contact page, the editor adds the existing `Contact Info` block. The block renders the static layout. The block has no per-instance fields; everything comes from the global `contact_info` option.
- **Render flow**: `page.php` runs `the_content()` → the editor's `Contact Info` block renders → the block reads the global `contact_info` for address/email/phone → the block renders the Gravity Forms shortcode and the Leaflet placeholder.
- **Gravity Forms shortcode**: `[gravityform id="1" title="false"]` (per the user). The `title="false"` and `description="false"` are added by the block's render template to suppress the form's title/description and let the block's own heading carry that role.
## Error Handling and Edge Cases
- `contact_info.address` empty → the address `<li>` still renders but the value span is empty. Acceptable for a placeholder; the editor should fill the global field.
- `contact_info.email` empty → the email `<li>` is omitted (wraps the value in `if ( $email )`).
- `contact_info.phone` empty → same, omit.
- Gravity Form ID 1 not found → the `[gravityform]` shortcode returns an empty string. The form area shows an empty box. The editor sees a visible gap, which is the right signal to fix the form.
- Leaflet embed not present → the right column is a gray placeholder. Layout is correct; the editor knows to drop in the embed.
- Editor adds the `Contact Info` block to a non-contact page → the block renders the same layout. This is intentional; the block is reusable. Pages that need a different layout (e.g. a 404, a privacy policy) should not add this block.
- Existing pages that use the `Contact Info` block with `InnerBlocks` (if any) lose the InnerBlocks area. A repo-wide search shows no such pages exist, but the change is breaking for any page that was using the block as a wrapper for other blocks. This is a known follow-up: audit all pages using `Contact Info` before merging.
## Out of Scope
- A new page template (`template-contact.php`). The block is the page.
- A new ACF field group for the contact page.
- Per-page override of the heading, the shortcode, or the icon assets.
- The Leaflet embed itself (placeholder only).
- The map pin SVG.
- An audit of all existing pages using the `Contact Info` block (manual follow-up, see Error Handling).
- Changes to the header, footer, or `page.php`.
- The inner-page hero (already done, gated by `hero_style = 'default'`).
## Verification
- Add `tests/contact-page.spec.js` covering:
- Desktop (1280px): the contact page renders with two columns; the left column has the heading, the address/email/phone rows (with icon spans), the Gravity Forms shortcode renders (or, if the form doesn't exist on the test env, the form area is empty — the test asserts the `.contact-info__form` element exists, not its contents), and the right column is the Leaflet placeholder.
- Mobile (402px): single column; the map drops below the form; no horizontal overflow.
- 320px: nothing clips; all text remains inside the viewport.
- Axe scan: no violations.
- Console errors: none.
- Add `tests/services-hero-scope.spec.js` covering the re-scope:
- The Services page itself renders the hero (assuming the fixture has `hero_style = 'default'`).
- A child of the Services page (e.g. Communication Services) renders the hero.
- A grandchild of the Services page renders the hero.
- A page that is NOT a Services descendant (e.g. About, Contact) does NOT render the hero, even if its `hero_style = 'default'`.
- A blog post, archive, search, or 404 still renders the hero (these views bypass the Services gate).
- Use the existing contact page fixture (or seed one in `beforeAll` if no fixture exists, and tear down in `afterAll`).
- Run `npm run build` to regenerate `static/dist/`.
- Run the full Playwright suite (`npx playwright test`) including `tests/mobile-homepage.spec.js`, `tests/site-a11y.spec.js`, `tests/inner-page.spec.js`, and the two new specs to confirm no regressions.
- Run `composer lint` to catch PHPCS issues.
- Capture 1280px and 402px full-page screenshots for visual comparison with the mockup.
## Acceptance Criteria
1. The contact page (`/contact/`) renders the new two-column 50/50 layout.
2. The left column has the heading, address, email, and phone (from the global `contact_info` option), each with a CWC Blue 03 outline icon to the left.
3. The Gravity Forms shortcode `[gravityform id="1" title="false"]` renders below the address items, with custom field styling: uppercase CWC Blue 01 labels, CWC Orange 01 right-aligned required asterisks, 30% opacity CWC Blue 03 input fills with CWC Blue 03 borders, slightly rounded corners, comfortable padding, a substantially taller message textarea, and a submit button matching the header's Contact button.
4. The right column is a placeholder for the Leaflet map, fills 50% of the viewport width on desktop (edge to edge), and drops below the form on mobile.
5. No hero is rendered on the contact page. There is a ~3rem top margin below the header and no page title above the body.
6. The inner-page hero renders on the Services page, on its direct children, and on its grandchildren — and ONLY on those pages. Pages that have `hero_style = 'default'` but are not Services descendants lose their hero. The blog index, archives, single posts, search, and 404 also do **not** render the inner-page hero.
7. No new console errors, axe violations, or PHPCS errors.
8. Existing pages that are Services descendants render with the inner-page hero; all other pages (blog, archives, single posts, search, 404, non-Services `page` posts) render without it.
9. Existing tests (`mobile-homepage`, `site-a11y`, `inner-page`) continue to pass.
10. The block is reusable: adding the `Contact Info` block to another page renders the same layout.
@@ -0,0 +1,128 @@
# Blog Post Card Styling Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restyle the blog post card to match `blog-post-card.png` — a vertical card with a featured image on top and a solid blue content area below containing the title, byline, and a "Read more" link in orange.
**Architecture:** A targeted CSS update to `styles/components/post-list.css` (the card body, title, byline, and a new `.post-list__read-more` rule), and a one-line markup addition in `index.php` to render the "Read more" anchor. No template structure changes. No new Playwright tests beyond a single new assertion in the existing `tests/blog-page.spec.js`.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- The theme's Tailwind build outputs `static/dist/theme.css`; that file is gitignored but committed with `git add -f` per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass. Pre-existing CRLF errors on untouched files are out of scope.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- The card width behavior stays the same: `grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]`. No layout refactor.
- The "Read more" link reuses the post's permalink — it's the same destination as the title link.
- The H1 ("Blog" page title) and `search.php` are explicitly **out of scope** for this change. The working tree currently has the H1 removed and `search.php` untouched; this design does not address either.
## Reference Mockup
`blog-post-card.png` (426×644, vertical 2:3) at the theme root. The mockup shows:
- **Image area** (top ~38%): light gray placeholder. Real implementation uses the post's featured image via `has_post_thumbnail()`.
- **Content area** (bottom ~62%): solid `#90c9e7` background. This color is the hex equivalent of `--color-cwc-blue-03` (`oklch(80.63% 0.0724 230.4)`).
- **Title:** multi-line, dark blue text (matches `--color-cwc-blue-01`).
- **Byline:** smaller dark text near the bottom of the content area.
- **"Read more" link:** orange (`#d24d32`, matches `--color-secondary` / `--color-cwc-orange-01`) at the bottom-left of the card.
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `styles/components/post-list.css` | Add card body, title, byline, and `.post-list__read-more` styles. | Modify |
| `index.php` | Add a "Read more" link after the byline in `.post-list__details`. | Modify |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified (committed with `git add -f`). |
| `tests/blog-page.spec.js` | Add a single assertion that the "Read more" link exists in the first card. | Modify |
---
## Design Details
### CSS additions to `styles/components/post-list.css`
The card already has the following structure (from `index.php`):
```html
<div class="post-list__post flex flex-col border border-secondary rounded-md shadow-lg ...">
<figure class="post-list__img aspect-video border-b border-secondary rounded-t-md ...">
<img class="...">
</figure>
<div class="post-list__details px-4 py-6 flex flex-col grow">
<a href="..."><h2 class="post-list__title ...">Title</h2></a>
<div class="post-list__byline mt-auto">
<span class="post-list__author">Author</span>
<span class="post-list__date">Date</span>
</div>
</div>
</div>
```
The styling changes:
1. **`.post-list__post`** — Override the gray `border-secondary` so the card border disappears (the blue background extends edge-to-edge inside the card). Keep the rounded corners and shadow. The current classes in `index.php` set `border border-secondary`; we override via CSS to `border-color: var(--color-cwc-blue-03)`.
2. **`.post-list__post`** and **`.post-list__img`** — Set `border: 0` (or `border-color: transparent`) on the figure so the existing `border-b border-secondary` does not show. The visual change is that the image and the blue content area flow as separate blocks without a divider line between them. The card itself keeps its `rounded-md` corners; the figure keeps `rounded-t-md` so the image rounds with the card.
3. **`.post-list__title`** — Set `color: var(--color-cwc-blue-01)`, `font-weight: 700` (override the `font-normal` in the markup). Keep the existing `text-25px` size and `line-clamp-4` truncation. The title is the project's standard H2 typography.
4. **`.post-list__byline`** — Set `color: var(--color-cwc-blue-01)`, `font-size: 0.875rem` (smaller than the title, but readable on the blue background). Keep `mt-auto` so the byline sits at the bottom of the available space.
5. **`.post-list__read-more`** (new) — `color: var(--color-secondary)`, `font-weight: 600`, `margin-top: 0.75rem`, `text-decoration: none`. Hover: underline. The text content is "Read more →" (em dash and arrow, no period).
### Markup change in `index.php`
Inside `.post-list__details`, after the `.post-list__byline` div, add:
```html
<a class="post-list__read-more" href="<?php the_permalink(); ?>">Read more &rarr;</a>
```
No other markup changes. The title link, image, byline, and pagination are untouched.
### What stays the same
- Card structure: `figure` for image, `div.post-list__details` for content.
- Grid layout: `grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-6`.
- Image aspect ratio: `aspect-video` (16:9).
- Image hover effect: `hover:prose-img:scale-110` with `duration-500` transition.
- Empty state ("Nothing here yet…").
- Pagination.
- Card padding: `px-4 py-6` on `.post-list__details`.
- The H1 / `search.php` / sidebar — all untouched.
### Test updates
Add a single assertion to the desktop test in `tests/blog-page.spec.js`:
```js
// First card has a "Read more" link.
await expect(firstCard.locator(".post-list__read-more")).toBeVisible();
await expect(firstCard.locator(".post-list__read-more")).toHaveAttribute(
"href",
/\/[a-z0-9-]+\/?$/
);
```
No new spec file. The existing 4 tests stay as they are.
### Out of Scope
- The H1 page title — the working tree currently has the H1 markup removed. The previous blog plan added it (commit `66073c0`) and the design tokens (commit `cb0c886`); both are present in git history. Whether to re-add the H1 is a separate decision.
- `search.php` — still a separate template, still has the old card structure, still has the `$featimg` case-sensitivity bug. Separate work.
- `static/dist/theme.css` pre-existing changes from other work in the working tree (`header.php`, `views/partials/page-hero-services.php`, etc.) are unrelated and not touched.
- Dark mode.
- Animations on the "Read more" link beyond a basic underline-on-hover.
- The H2 font family (Quincy vs sans) — the existing card uses the default font family for the title. The mockup's title appears to be a clean sans-serif weight-700. We do not change the font family.
---
## Self-Review Notes
- **Spec coverage:** One CSS file, one PHP file, one test file, one dist rebuild. Every spec section is covered.
- **Placeholder scan:** No "TBD", "TODO", or vague requirements. The "→" arrow and "Read more" text are spelled out explicitly.
- **Type consistency:** Class names follow the existing BEM convention (`__read-more`).
- **Spec section "Out of Scope"** is explicit: H1, search.php, dark mode, font family changes, animations — all out.
- **Design tokens:** All colors reference existing CSS custom properties. No new colors introduced.
- **Indentation:** Tabs for PHP (project standard).
@@ -0,0 +1,313 @@
# Services Content Blocks Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the missing content-area components for the Mapping services page (and any other service sub-page), matching `Mapping-Services.png`. The hero, header, and footer are already in place and must not be touched.
**Architecture:** One new ACF block (`services-list`) that renders a row of cards. Card count is editor-driven (2, 3, or 4); card size and grid layout adapt to the count. The orange horizontal line that bisects the images is rendered by the block itself as a full-bleed SVG background. The closing "At CWC, mapping is about more than data…" block reuses the existing `pull-quote` block — no new build needed. Editor composes the page in Gutenberg by adding `Services List` and `Pull Quote` blocks to any service sub-page; `page.php` already calls `the_content()`, so no template change is required.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- `static/dist/theme.css` is committed (not gitignored) per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- Reuse the existing post-title accent pattern (blue heading + orange `::after` underline from `.post-title h1` in `styles/base/misc.css`) for consistency. Do not introduce a new accent style.
- Reuse the existing `pull-quote` block for the closing statement — no duplicate block.
- Page template `page.php` is not modified. The page-children block is not used.
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
## Reference Mockup
`Mapping-Services.png` shows three content-area sections on the Mapping services page (URL `/services/mapping/`):
1. **Featured services row** — 2 large cards side-by-side. Each card: image (top), blue heading with orange underline, intro paragraph. In the mockup, the orange horizontal line cuts through the middle of the images.
2. **Service grid** — 4 small cards in a 4-up grid. Each card: square image, blue heading with orange underline, short description. The orange line again bisects the images.
3. **Closing statement** — light-blue full-bleed section with decorative linework, centered text "At CWC, mapping is about more than data. It is about helping people understand and make decisions about their land and communities." with "more than data." colored (likely the orange secondary accent).
The mockup shows a single orange horizontal line that bleeds past the container on both sides, intersecting both rows of images at their vertical midpoint.
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `views/blocks/services-list/` | Folder for the new ACF block. | Create |
| `views/blocks/services-list/services-list.php` | Block template (PHP). | Create |
| `views/blocks/services-list/services-list.css` | Block styles (CSS). | Create |
| `views/blocks/services-list/block.json` | Block registration (ACF block name, category, icon). | Create |
| `acf/group_services_list.json` | ACF field group for the `acf/services-list` block. | Create |
| `static/img/services-list-line.svg` | Decorative orange horizontal line SVG that bisects the images. | Create |
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
| `tests/services-page.spec.js` | Playwright tests for the rendered block. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
---
## Design Details
### 1. New ACF block: `services-list`
**Block name (registration key):** `acf/services-list`
**Editor title:** "Services List"
**Category:** common
**Description (in block.json):** "A row of service cards. Card count (2-4) determines the layout."
#### ACF field group (`acf/group_services_list.json`)
```
Group key: group_services_list
Group title: Services List
Position: normal
Menu order: 0
Location: block == acf/services-list
```
Fields:
- `items` (repeater, layout: block, button label: "Add Service")
- `image` (image, return_format: array, required: 0) — Card image.
- `heading` (text, required: 0) — Card title.
- `description` (textarea, rows: 4, required: 0) — Card body copy.
The block is intentionally minimal. No per-item "size" select, no global layout toggle, no CTA field. The grid layout derives from the item count alone.
#### Block template (`views/blocks/services-list/services-list.php`)
Top of file:
```php
namespace CWC;
$items = get_field( 'items' );
if ( empty( $items ) ) {
return;
}
$count = count( $items );
// Grid columns by count: 2 -> lg:grid-cols-2, 3 -> lg:grid-cols-3, 4 -> lg:grid-cols-4
$grid_class = 'lg:grid-cols-' . max( 2, min( 4, (int) $count ) );
$classes = "services-list mx-break-out relative $grid_class";
$wrapper = blockWrapperAttributes( $classes, $is_preview );
```
Render:
```html
<section class="services-list ..."> <!-- the wrapper from blockWrapperAttributes -->
<div class="services-list__line" aria-hidden="true"></div> <!-- full-bleed orange line, see CSS -->
<div class="container">
<div class="services-list__grid grid grid-cols-1 gap-8 lg:gap-12">
<?php foreach ( $items as $item ) : ?>
<article class="services-list__card">
<?php if ( ! empty( $item['image'] ) ) : ?>
<figure class="services-list__media aspect-square overflow-hidden rounded-md">
<img
src="<?php echo esc_url( $item['image']['url'] ); ?>"
alt="<?php echo esc_attr( $item['image']['alt'] ?? '' ); ?>"
class="services-list__img w-full h-full object-cover"
loading="lazy"
/>
</figure>
<?php endif; ?>
<?php if ( ! empty( $item['heading'] ) ) : ?>
<h3 class="services-list__heading">
<?php echo wp_kses_post( $item['heading'] ); ?>
</h3>
<?php endif; ?>
<?php if ( ! empty( $item['description'] ) ) : ?>
<div class="services-list__desc">
<?php echo wp_kses_post( $item['description'] ); ?>
</div>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
</div>
</section>
```
Notes:
- The `lg:grid-cols-N` class is generated dynamically. Tailwind v4 picks up the class from the source file and emits the rule. No safelist needed.
- Card aspect ratio: `aspect-square` for all counts. Mockup shows square images for both the 2-up and 4-up rows.
- Heading: `<h3>` to sit below the page H1 in document order.
#### Block styles (`views/blocks/services-list/services-list.css`)
```
.services-list {
/* vertical position of the bisecting line, relative to the .container block.
The image is the first child of each card; bisecting it = approx 25% from the top. */
--services-line-top: 25%;
&__line {
position: absolute;
inset-inline: 0;
top: var(--services-line-top);
height: 8px;
pointer-events: none;
z-index: 1;
/* background SVG served from theme root */
background-image: url('/wp-content/themes/community-works-collaborative/static/img/services-list-line.svg');
background-repeat: no-repeat;
background-position: center center;
background-size: 100% 100%;
}
&__grid {
position: relative;
z-index: 2; /* cards stack above the line so images are not visually cut */
}
&__card {
@apply flex flex-col;
}
&__heading {
@apply text-cwc-blue-01 text-24px font-bold leading-none mt-6 mb-4;
/* the orange accent underline from .post-title h1 */
&::after {
background: var(--color-secondary);
content: "";
display: block;
height: 4px;
margin-top: 0.45rem;
width: 3rem;
}
}
&__desc {
@apply text-gray-600 text-16px font-light leading-snug;
}
}
```
The bisecting line is rendered behind the cards (z-index: 1 on the line, 2 on the grid). The 25% top offset aims at the vertical center of the square image; minor visual variation is acceptable. The line itself is a thin orange `<rect>` SVG (8px tall) spanning full width.
#### Block registration (`views/blocks/services-list/block.json`)
```json
{
"name": "acf/services-list",
"title": "Services List",
"description": "A row of service cards (2, 3, or 4). Layout adapts to the count.",
"category": "common",
"icon": "grid-view",
"keywords": ["services", "cards", "mapping"],
"acf": {
"mode": "preview",
"renderTemplate": "services-list.php"
},
"supports": {
"anchor": true,
"align": false
}
}
```
The block is auto-registered via `regACFBlocks()` in `functions.php`, which scans `views/blocks/*` and calls `register_block_type()` on each folder.
### 2. Decorative SVG: `static/img/services-list-line.svg`
A simple orange horizontal line, 1600×8 viewBox, full-bleed:
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 8" preserveAspectRatio="none">
<rect x="0" y="3" width="1600" height="2" fill="#F26B53" />
</svg>
```
The `preserveAspectRatio="none"` lets the line stretch to whatever width the container is. Fill `#F26B53` matches the existing brand orange already used in the icons (`stroke="#F26B53"` on `next.php` / `prev.php`).
### 3. Closing block: reuse `pull-quote`
The closing "At CWC, mapping is about more than data…" block already matches the `pull-quote` block's contract. The editor adds a `Pull Quote` block to the page, sets:
- **Text** (wysiwyg with bold): `At CWC, mapping is about more than data. It is about helping people understand and make decisions about their land and communities.` with **"more than data."** bolded.
- **Background Color**: `#E6F0F5` (the light blue in the mockup) or the default `--color-pullquote-bg` if it's already correct.
- **Background Image**: optional, if the decorative linework is editor-managed; otherwise leave blank — the existing `mobile-pullquote-vector.svg` / `pull-quote__vector--mobile` provides the linework.
If `pull-quote`'s default `bg-(--color-pullquote-bg)` already matches the mockup's light blue, no field tweak is needed. If it does not match, the editor sets the explicit hex. **No code change to the pull-quote block.**
### 4. Page integration
The mapping page (and any future service sub-page) is composed entirely in Gutenberg:
1. Add a `Services List` block, populate with 4 items: Community Mapping Initiatives, Spatial Analysis and GIS Services, Spatial Database Management, Data Collection Services, Cartography, Land Related Support Services. Wait — the mockup has 2 large + 4 small = 6 items across 2 separate rows. **Resolution:** the editor adds **two `Services List` blocks** on the mapping page: one with 2 items, one with 4 items. Each block renders as a single row.
2. Add a `Pull Quote` block, populate with the closing text.
`page.php` already iterates `the_content()` and renders whatever blocks are present. No PHP change.
### 5. CSS import (`styles/blocks/index.css`)
Add at the end of the existing imports:
```css
@import '../../views/blocks/services-list/services-list.css';
```
### 6. Tests (`tests/services-page.spec.js`)
A new Playwright spec covering the rendered block on the mapping page:
```js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Services List block on /services/mapping/", () => {
test("renders the heading and image for each card", async ({ page }) => {
await page.goto("/services/mapping/");
// Both services-list blocks render.
const blocks = page.locator(".services-list");
await expect(blocks).toHaveCount(2);
// First block (featured) has 2 cards; second (grid) has 4.
await expect(blocks.nth(0).locator(".services-list__card")).toHaveCount(2);
await expect(blocks.nth(1).locator(".services-list__card")).toHaveCount(4);
// Each card has an image and a heading.
const firstCard = blocks.nth(0).locator(".services-list__card").first();
await expect(firstCard.locator(".services-list__media img")).toBeVisible();
await expect(firstCard.locator(".services-list__heading")).toBeVisible();
});
test("bisecting line is present and aria-hidden", async ({ page }) => {
await page.goto("/services/mapping/");
const line = page.locator(".services-list__line").first();
await expect(line).toBeAttached();
await expect(line).toHaveAttribute("aria-hidden", "true");
});
test("no axe violations on the block", async ({ page }) => {
await page.goto("/services/mapping/");
const results = await new AxeBuilder({ page })
.include(".services-list")
.analyze();
expect(results.violations).toEqual([]);
});
});
```
### Out of Scope
- `header.php`, `footer.php`, the page hero — explicitly untouched.
- A new template file for service pages — `page.php` already suffices.
- Per-item "size" override (the size is fully driven by item count).
- A custom layout for the closing block — `pull-quote` covers it.
- An editor-uploadable linework SVG — the line is a fixed asset.
- Mobile-specific layout — the responsive `grid-cols-1 lg:grid-cols-N` handles it. The bisecting line is hidden on mobile (below `lg:`) so it does not bisect the stacked single-column cards awkwardly.
- Animations on the cards.
- Dark mode.
- A new CSS color or a new font — everything reuses existing tokens.
## Self-Review Notes
- **Spec coverage:** One new ACF block (4 files), one SVG asset, two file modifications (CSS index, dist rebuild), one test file. Every spec section is covered.
- **Placeholder scan:** No "TBD", "TODO", or vague requirements. The bisecting line CSS, the dynamic `lg:grid-cols-N` class, the card aspect ratio, the title accent reuse — all explicit.
- **Type consistency:** Class names follow the existing BEM convention (`services-list__card`, `__media`, `__heading`, `__desc`, `__line`).
- **Scope check:** Single focused build, no decomposition needed. Block is small (≤4 files), testable in isolation, and composable on any service page.
- **Ambiguity check:** The "2 rows on the mapping page" question is resolved — editor adds 2 instances of the same block, one with 2 items, one with 4. The "orange line bisects images" question is resolved — fixed-positioned SVG with `top: 25%`, behind the cards, hidden on mobile.
@@ -0,0 +1,332 @@
# Team Grid Block Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a new `team-grid` ACF block for the Team page (and any other page that needs a team grid) that renders a responsive grid of team members. Each member shows their photo (rounded rectangle), name, and title. The bio is collapsed by default and expands vertically with a smooth animation when the photo, name, or title is clicked.
**Architecture:** One new ACF block (`acf/team-grid`) with a single repeater of `members` (image, name, title, bio). The block uses native HTML `<details>`/`<summary>` for the click-to-expand interaction, animated with the `grid-template-rows: 0fr → 1fr` CSS pattern (no JavaScript). The block has no built-in background — the editor wraps it in an existing Section block when a dark/light background is needed. The page template is unchanged; the editor adds the block to the Team page in Gutenberg.
**Tech Stack:** WordPress 6.x, PHP 8.x, Tailwind CSS v4, Playwright, axe-core, PHPCS (WordPress standard).
## Global Constraints
- Tabs for PHP indentation (project standard).
- `static/dist/theme.css` is committed (not gitignored) per project convention.
- PHPCS uses the WordPress coding standard; `composer lint` must pass for all touched files.
- All design colors use the project's existing CSS custom properties from `styles/base/colors.css`. No hardcoded hex values for theme colors.
- Reuse existing design tokens where possible (e.g. the `text-cwc-blue-01` heading color, `var(--color-secondary)` accent if needed).
- The headshot uses `rounded-md` to match other image cards in the project (`post-list`, `services-list`).
- All commits follow the project's emoji-prefixed commit-message style (e.g. `✨ feat: …`).
- This work does **not** edit `header.php`, `footer.php`, `views/partials/page-hero.php`, or `views/partials/page-hero-services.php`.
- `page.php` is not modified.
- No JavaScript files are added. The `<details>`/`<summary>` interaction is fully native.
- Reuse the same `regACFBlocks()` auto-registration pattern as `services-list`.
## Reference Mockup
`team-example.png` shows a dark-blue section with 8 team members in a 2-column grid. Each member has a circular headshot, name (bold), title (italic), and a multi-line bio below. All bios are visible in the mockup.
**Intent of the design (per the user):**
- The bio should be a `<details>` element that **opens vertically** when the photo, name, or title is clicked. The mockup shows the open state for every member for context; the live behavior is collapsed by default.
- The headshot should be a **rectangle with rounded corners that match other images in the project**`rounded-md` is the project standard. The mockup's circular headshots are a visual approximation; the live design uses rounded rectangles.
- The block has no built-in background. The editor wraps it in a Section block (`acf/section`) when a dark or light background is needed.
- Layout is responsive: 1 column on mobile, 2 on tablet, 3 on desktop (the user's "3-2-1" choice).
## File Structure
| File | Responsibility | Created/Modified |
| --- | --- | --- |
| `acf/group_team_grid.json` | ACF field group for the `acf/team-grid` block. | Create |
| `views/blocks/team-grid/team-grid.php` | Block template (PHP) — renders the grid. | Create |
| `views/blocks/team-grid/team-grid.css` | Block styles (CSS) — layout, photo, summary, animation. | Create |
| `views/blocks/team-grid/block.json` | Block registration metadata. | Create |
| `styles/blocks/index.css` | Import the new block's CSS. | Modify |
| `tests/team-page.spec.js` | Playwright tests for the rendered block. | Create |
| `static/dist/theme.css` | Rebuilt via `npm run build`. | Modified |
---
## Design Details
### 1. ACF field group: `acf/team-grid`
**Block name (registration key):** `acf/team-grid`
**Editor title:** "Team Grid"
**Category:** common
**Description (in block.json):** "A responsive grid of team members. Click a card to expand the bio."
#### ACF field group (`acf/group_team_grid.json`)
```
Group key: group_team_grid
Group title: Team Grid
Position: normal
Menu order: 0
Location: block == acf/team-grid
```
Fields:
- `members` (repeater, layout: block, button label: "Add Team Member")
- `image` (image, return_format: array, required: 0) — Headshot.
- `name` (text, required: 0) — Person's name (e.g. "Christopher Werner").
- `title` (text, required: 0) — Person's role (e.g. "Principal Consultant & CEO").
- `bio` (wysiwyg, tabs: visual, toolbar: basic, media_upload: 0, required: 0) — Bio body copy.
The block is intentionally minimal. No per-card size, no global layout toggle, no CTA field, no group-level background color. Layout derives from CSS (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`).
#### Block registration (`views/blocks/team-grid/block.json`)
```json
{
"name": "acf/team-grid",
"title": "Team Grid",
"description": "A responsive grid of team members. Click a card to expand the bio.",
"category": "common",
"icon": "groups",
"keywords": ["team", "people", "staff", "bio"],
"acf": {
"mode": "preview",
"renderTemplate": "team-grid.php"
},
"supports": {
"anchor": true,
"align": false
}
}
```
The `icon: "groups"` is a WordPress Dashicon that visually represents a group of people.
#### Block template (`views/blocks/team-grid/team-grid.php`)
```php
<?php
/**
* Block Name: Team Grid
*
* A responsive grid of team members. Each card uses a native <details>
* element so the bio expands when the photo, name, or title is clicked.
*
* @package CWC
*/
namespace CWC;
$members = get_field( 'members' );
if ( empty( $members ) ) {
return;
}
$classes = 'team-grid container my-section mx-auto';
$wrapper = blockWrapperAttributes( $classes, $is_preview );
?>
<section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="team-grid__inner grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 lg:gap-12">
<?php foreach ( $members as $member ) : ?>
<article class="team-grid__card">
<details class="team-grid__details">
<summary class="team-grid__summary">
<?php if ( ! empty( $member['image'] ) ) : ?>
<img
class="team-grid__photo"
src="<?php echo esc_url( $member['image']['url'] ); ?>"
alt="<?php echo esc_attr( $member['image']['alt'] ?? '' ); ?>"
loading="lazy"
/>
<?php endif; ?>
<?php if ( ! empty( $member['name'] ) ) : ?>
<h3 class="team-grid__name">
<?php echo wp_kses_post( $member['name'] ); ?>
</h3>
<?php endif; ?>
<?php if ( ! empty( $member['title'] ) ) : ?>
<p class="team-grid__title">
<?php echo wp_kses_post( $member['title'] ); ?>
</p>
<?php endif; ?>
</summary>
<div class="team-grid__bio-wrap">
<div class="team-grid__bio-inner">
<?php if ( ! empty( $member['bio'] ) ) : ?>
<div class="team-grid__bio">
<?php echo wp_kses_post( $member['bio'] ); ?>
</div>
<?php endif; ?>
</div>
</div>
</details>
</article>
<?php endforeach; ?>
</div>
</section>
```
Notes:
- Native `<details>`/`<summary>`: the summary is the click target (photo + name + title); the body is the bio wrap.
- The `<details>` element is closed by default — no `open` attribute. The user opens it on click.
- The bio uses `wysiwyg` so editors can add links or basic formatting. Output is sanitized via `wp_kses_post`.
- `team-grid__bio-wrap` is a 1-row grid container that animates `grid-template-rows` from `0fr` to `1fr`. The `__bio-inner` inside has `overflow: hidden; min-height: 0` so the bio is clipped during the transition.
#### Block styles (`views/blocks/team-grid/team-grid.css`)
```css
/* Team Grid block */
.team-grid {
&__card {
@apply flex flex-col;
}
&__details {
@apply flex flex-col;
}
/* summary is the click target. Strip the default disclosure triangle. */
&__summary {
@apply flex flex-col items-center text-center cursor-pointer list-none;
&::-webkit-details-marker {
display: none;
}
&::marker {
display: none;
content: '';
}
}
&__photo {
@apply w-32 h-32 sm:w-40 sm:h-40 lg:w-48 lg:h-48 object-cover rounded-md mb-4;
}
&__name {
@apply text-cwc-blue-01 text-22px font-bold leading-tight mb-1;
}
&__title {
@apply text-gray-600 text-16px font-light italic mb-3;
}
/* Animation: animate grid-template-rows 0fr -> 1fr. */
&__bio-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease;
}
&__details[open] &__bio-wrap {
grid-template-rows: 1fr;
}
&__bio-inner {
overflow: hidden;
min-height: 0;
}
&__bio {
@apply text-gray-700 text-16px font-light leading-snug pt-2;
}
}
```
Notes:
- The summary is a flex column with `items-center` so the photo, name, and title stack centered. This matches the mockup.
- The default `<summary>` marker (▶ triangle) is hidden with `list-style: none` plus vendor-prefixed `::-webkit-details-marker` and `::marker` reset.
- The `&__details[open] &__bio-wrap` selector uses CSS nesting to target the bio wrap when the parent details is open. This compiles to `.team-grid__details[open] .team-grid__bio-wrap { grid-template-rows: 1fr; }`.
- The `team-grid__details[open] &__bio-wrap` syntax uses CSS nesting: `&` inside `&__details[open]` is the previous compound selector. Tailwind v4 (lightningcss) unwraps this correctly.
- Photo sizes: `w-32 h-32` (8rem / 128px) on mobile, `w-40 h-40` (10rem / 160px) on small, `w-48 h-48` (12rem / 192px) on large.
### 2. CSS import wiring (`styles/blocks/index.css`)
Append at the end of `styles/blocks/index.css`:
```css
@import '../../views/blocks/team-grid/team-grid.css';
```
After the edit, the file ends with:
```
@import '../../views/blocks/services-list/services-list.css';
@import '../../views/blocks/team-grid/team-grid.css';
```
### 3. Page integration
`page.php` is unchanged. The editor adds a `Team Grid` block to the Team page (`/team/`) in Gutenberg. To get the dark-blue background, the editor wraps the `Team Grid` block in a `Section` block (`acf/section`) and sets the Section's `background_color` to the dark blue.
No PHP template change is required.
### 4. CSS Build
Run `npm run build` to compile Tailwind v4 → `static/dist/theme.css`. The new utilities (e.g. `text-22px`, `text-16px`, `w-32 h-32`, `rounded-md`) must appear in the dist.
### 5. Tests (`tests/team-page.spec.js`)
```js
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Team Grid block on /team/", () => {
test("renders the photo, name, and title for each member", async ({ page }) => {
await page.goto("/team/");
const cards = page.locator(".team-grid__card");
await expect(cards.first()).toBeVisible();
const firstCard = cards.first();
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
});
test("bio is hidden by default and expands on summary click", async ({ page }) => {
await page.goto("/team/");
const firstDetails = page.locator(".team-grid__details").first();
// Bio is closed by default.
await expect(firstDetails).not.toHaveAttribute("open", "");
// Click the summary; the details should open and the bio should be visible.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).toHaveAttribute("open", "");
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
// Click again; the details should close.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).not.toHaveAttribute("open", "");
});
test("no axe violations on the team-grid block", async ({ page }) => {
await page.goto("/team/");
const results = await new AxeBuilder({ page })
.include(".team-grid")
.analyze();
expect(results.violations).toEqual([]);
});
});
```
### Out of Scope
- The Team page template — `page.php` is unchanged. The editor adds the block in Gutenberg.
- The Section block wrapper for the dark-blue background — the editor composes this in Gutenberg.
- A custom open/close icon or arrow indicator — the click target is the entire summary (photo + name + title), so no chevron is needed.
- Animations beyond the bio's expand/collapse transition. No hover transforms, no entry animations.
- Per-card size variants — all cards are the same size.
- A `data-team-member-id` slug for deep-linking to a specific member's expanded bio.
- Dark-mode overrides — the block is color-agnostic and inherits the parent Section's color scheme.
- Per-page header/footer changes.
## Self-Review Notes
- **Spec coverage:** One new ACF block (4 files), one CSS import, dist rebuild, one test file. Every spec section is covered.
- **Placeholder scan:** No TBDs. Every step has a concrete file path, exact content, exact command, and expected output.
- **Type consistency:** Class names use the existing BEM convention (`__card`, `__details`, `__summary`, `__photo`, `__name`, `__title`, `__bio-wrap`, `__bio-inner`, `__bio`). Field names (`members`, `image`, `name`, `title`, `bio`) are used in the JSON, the PHP, and the tests.
- **Scope check:** Single focused build, no decomposition needed. Block is small (≤4 files), testable in isolation, and composable on the Team page (and any other page that needs a team grid).
- **Ambiguity check:** The "rounded rectangle" headshot is concretely specified as `rounded-md`. The "open vertically" animation is concretely specified as `grid-template-rows: 0fr → 1fr` with a 300ms transition. The "3-2-1" responsive grid is concretely specified. The "no background" decision is concretely scoped to the Section block wrapper (editor's responsibility, not this block's).
+49 -11
View File
@@ -19,11 +19,19 @@ $locations = get_nav_menu_locations();
$footerNav = ! empty( $locations['footer_navigation'] ) $footerNav = ! empty( $locations['footer_navigation'] )
? ( wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) ? wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) : array() ) ? ( wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) ? wp_get_nav_menu_items( (int) $locations['footer_navigation'] ) : array() )
: array(); : array();
$services = get_page_by_path( 'services' )->ID;
$svcChild = $post->post_parent === $services ? true : false;
if ( ! is_page( 'contact' ) && ! $svcChild ) {
$marginClass = 'mt-20';
}
?> ?>
</main> </main>
<footer role="contentinfo" class="site-footer bg-cwc-blue-02 text-white pt-16 pb-12 text-base"> <footer role="contentinfo" class="site-footer bg-cwc-blue-02 text-white pt-16 pb-12 text-base <?php echo esc_attr( $marginClass ?? '' ); ?>">
<div class="container mx-auto grid grid-cols-4 gap-6"> <div class="container mx-auto grid grid-cols-4 gap-6">
<div class="max-w-[40ch]"> <div class="max-w-[40ch]">
<h2 id="footer-header" class="max-w-64 h-auto"> <h2 id="footer-header" class="max-w-64 h-auto">
@@ -39,13 +47,13 @@ $footerNav = ! empty( $locations['footer_navigation'] )
</h2> </h2>
</div> </div>
<div id="footer-nav" aria-labelledby="footer-navigation"> <div id="footer-nav">
<?php if ( $footerNav ) : ?> <?php if ( $footerNav ) : ?>
<nav class="site-footer__nav" aria-label="Footer navigation"> <nav class="site-footer__nav" aria-label="Footer navigation">
<ul class="site-footer__nav-list"> <ul class="site-footer__nav-list">
<?php foreach ( $footerNav as $item ) : ?> <?php foreach ( $footerNav as $item ) : ?>
<li class="site-footer__nav-item list-none text-left <?php echo esc_attr( implode( ' ', $item->classes ) ); ?>"> <li class="site-footer__nav-item list-none text-left mb-3 <?php echo esc_attr( implode( ' ', $item->classes ) ); ?>">
<a href="<?php echo esc_url( $item->url ); ?>" class="site-footer__nav-link"> <a href="<?php echo esc_url( $item->url ); ?>" class="site-footer__nav-link font-bold">
<?php echo esc_html( $item->title ); ?> <?php echo esc_html( $item->title ); ?>
</a> </a>
</li> </li>
@@ -55,16 +63,45 @@ $footerNav = ! empty( $locations['footer_navigation'] )
<?php endif; ?> <?php endif; ?>
</div> </div>
<div aria-labelledby="footer-area-1" class="prose-p:text-balance"> <div class="prose-p:text-balance">
<div id="footer-description" aria-label="Footer description"> <div id="footer-description" class="" aria-label="Footer description">
<?php echo wp_kses_post( $footerDesc ); ?> <?php echo wp_kses_post( $footerDesc ); ?>
</div> </div>
<?php dynamic_sidebar( 'footer-1' ); ?>
<div class="contact">
<?php
$address = getFieldValue( 'contact_info.address' ) ? getFieldValue( 'contact_info.address' ) : '';
$phone = getFieldValue( 'contact_info.phone' ) ? getFieldValue( 'contact_info.phone' ) : '';
$email = getFieldValue( 'contact_info.email' ) ? getFieldValue( 'contact_info.email' ) : '';
?>
<?php if ( $address ) : ?>
<div class="contact__item contact__address mt-6">
<p><?php echo wp_kses_post( $address ); ?></p>
</div>
<?php
endif;
if ( $email ) :
?>
<div class="contact__item contact__email mt-4">
<p><a href="mailto:<?php echo esc_attr( $email ); ?>" class="text-cwc-blue-01 hover:underline"><?php echo esc_html( $email ); ?></a></p>
</div>
<?php
endif;
if ( $phone ) :
?>
<div class="contact__item contact__phone mt-4">
<p><a href="tel:<?php echo esc_attr( preg_replace( '/\D+/', '', $phone ) ); ?>" class="text-cwc-blue-01 hover:underline"><?php echo esc_html( $phone ); ?></a></p>
</div>
<?php endif; ?>
</div>
</div> </div>
<div class="text-right flex flex-col justify-end items-end"> <div class="text-right flex flex-col justify-end items-end">
<?php dynamic_sidebar( 'footer-2' ); ?> <?php dynamic_sidebar( 'footer-1' ); ?>
<x-back-to-top></x-back-to-top> <x-back-to-top></x-back-to-top>
@@ -74,14 +111,15 @@ $footerNav = ! empty( $locations['footer_navigation'] )
'views/partials/social-media', 'views/partials/social-media',
null, null,
array( array(
'circle' => true, 'circle' => false,
'classes' => 'social-icons p-0 text-18px', 'classes' => 'social-icons p-0 hover:opacity-80!',
'size' => '24',
) )
); );
?> ?>
</div> </div>
<div class="copyright text-white text-right py-3 text-12px"> <div class="copyright font-light text-white/70 text-right py-3 text-12px">
<?php <?php
if ( $copyright ) { if ( $copyright ) {
echo wp_kses_post( $copyright ); echo wp_kses_post( $copyright );
+14 -17
View File
@@ -12,20 +12,13 @@ global $views;
$headerLogo = getFieldValue( 'header.header_logo.url' ) ? getFieldValue( 'header.header_logo.url' ) : get_theme_file_uri( '/static/img/logo.svg' ); $headerLogo = getFieldValue( 'header.header_logo.url' ) ? getFieldValue( 'header.header_logo.url' ) : get_theme_file_uri( '/static/img/logo.svg' );
// Check conditions for displaying the hero section // Check conditions for displaying the hero section.
$showHero = in_array( // The inner-page hero renders only on the Services page, its direct children,
true, // and its grandchildren. All other views (blog index, archives, single posts,
array( // search, 404) render without the hero.
get_field( 'hero_style' ) === 'default', $services = get_page_by_path( 'services' )->ID;
is_home(),
is_archive(),
is_single(),
is_search(),
is_404(),
),
true
);
$showHero = $post->post_parent === $services ? true : false;
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
@@ -33,7 +26,7 @@ $showHero = in_array(
<head> <head>
<meta charset="<?php bloginfo( 'charset' ); ?>"> <meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<?php wp_head(); ?> <?php wp_head(); ?>
</head> </head>
@@ -56,6 +49,10 @@ $showHero = in_array(
</header> </header>
<main id="maincontent" class="overflow-hidden min-h-[78dvh]"> <main id="maincontent" class="overflow-hidden min-h-[78dvh]">
<?php if ( $showHero ) : ?> <?php
<?php get_template_part( 'views/partials/page-hero' ); ?> if ( $showHero ) :
<?php endif; ?> get_template_part( 'views/partials/page-hero-services' );
elseif ( ! is_front_page() && ! is_page( 'contact' ) ) :
get_template_part( 'views/partials/page-hero' );
endif;
?>
+24 -27
View File
@@ -10,12 +10,20 @@ namespace CWC;
// Determine classes based on sidebar presence // Determine classes based on sidebar presence
if ( hasSidebar() ) { if ( hasSidebar() ) {
$classes = 'container grid grid-cols-1 lg:grid-cols-4 gap-8 xl:gap-16 my-section mx-auto'; $classes = 'container grid grid-cols-1 lg:grid-cols-4 gap-8 xl:gap-16 my-section mx-auto lg:px-0!';
$clsEntry = 'lg:col-span-3'; $clsEntry = 'lg:col-span-3';
} else { } else {
$classes = 'container my-section lg:my-0 mx-auto'; $classes = 'container my-section lg:my-0 mx-auto';
} }
ob_start();
get_template_part( 'views/icons/prev' );
$pagination_prev_icon = ob_get_clean();
ob_start();
get_template_part( 'views/icons/next' );
$pagination_next_icon = ob_get_clean();
get_header(); get_header();
?> ?>
@@ -23,13 +31,13 @@ get_header();
<?php if ( have_posts() ) : ?> <?php if ( have_posts() ) : ?>
<div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>"> <div class="blog-posts <?php echo esc_attr( $clsEntry ); ?>">
<div class="post-list"> <div class="post-list">
<div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-6"> <div class="post-list__posts grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] gap-12">
<?php <?php
while ( have_posts() ) : while ( have_posts() ) :
the_post(); the_post();
?> ?>
<div class="post-list__post flex flex-col border border-secondary rounded-md shadow-lg hover:prose-img:scale-110 hover:prose-img:origin-center hover:prose-img:duration-500"> <div class="post-list__post flex flex-col rounded-md shadow-lg hover:prose-img:scale-110 hover:prose-img:origin-center hover:prose-img:duration-500">
<figure class="post-list__img aspect-video border-b border-secondary rounded-t-md block h-auto w-full overflow-hidden m-0 p-0"> <figure class="post-list__img aspect-video rounded-t-md block h-auto w-full overflow-hidden m-0! p-0!">
<?php if ( has_post_thumbnail() ) : ?> <?php if ( has_post_thumbnail() ) : ?>
<?php <?php
$featImg = get_the_post_thumbnail_url(); $featImg = get_the_post_thumbnail_url();
@@ -41,30 +49,19 @@ get_header();
<?php endif; ?> <?php endif; ?>
</figure> </figure>
<div class="post-list__details px-4 py-8 flex flex-col grow"> <div class="post-list__details p-8 pb-10 flex flex-col justify-between grow">
<div class="post-list__cats"> <h2 class="post-list__title font-normal text-32px mt-0 mx-0">
Posted in: <a href="<?php the_permalink(); ?>" class=""><?php the_title(); ?></a>
<?php </h2>
$categories = get_the_category();
foreach ( $categories as $index => $category ) : <div class="post-list__excerpt line-clamp-5 mb-4">
$separator = $index < count( $categories ) - 1 ? ', ' : ''; <?php the_excerpt(); ?>
?>
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>" class="post-list__category text-14px font-semibold leading-none uppercase mt-0 mb-2.5 mx-0 inline-block"><?php echo esc_html( $category->name ); ?></a><?php echo esc_attr( $separator ); ?>
<?php endforeach; ?>
</div> </div>
<a href="<?php the_permalink(); ?>" class=""> <?php /* translators: %s: Post title. */ ?>
<h2 class="post-list__title font-normal text-25px text-balance line-clamp-4 truncate mt-0 mb-5 mx-0"><?php the_title(); ?></h2> <a class="post-list__read-more border-b-2 border-b-secondary w-fit" href="<?php echo esc_url( $post_permalink ); ?>" aria-label="<?php echo esc_attr( sprintf( __( 'Continue reading %s', 'cwc' ), $post_title ) ); ?>">
<?php echo esc_html__( 'Read More', 'cwc' ); ?>
</a> </a>
<div class="post-list__excerpt mb-6">
<?php customExcerpt( get_the_content(), 15 ); ?>
</div>
<div class="post-list__byline mt-auto">
<span class="post-list__author"><?php echo esc_html( ucfirst( get_the_author() ) ); ?> &mdash;</span>
<span class="post-list__date"><?php echo get_the_date(); ?></span>
</div>
</div> </div>
</div> </div>
<?php endwhile; ?> <?php endwhile; ?>
@@ -75,8 +72,8 @@ get_header();
the_posts_pagination( the_posts_pagination(
array( array(
'mid_size' => 2, 'mid_size' => 2,
'prev_text' => '&laquo; Previous', 'prev_text' => $pagination_prev_icon . '<span class="sr-only">' . esc_html__( 'Previous', 'cwc' ) . '</span>',
'next_text' => 'Next &raquo;', 'next_text' => $pagination_next_icon . '<span class="sr-only">' . esc_html__( 'Next', 'cwc' ) . '</span>',
) )
); );
?> ?>
+13 -8
View File
@@ -143,13 +143,16 @@ class Breadcrumbs {
private static function getCustomPostTypeBreadcrumbs() { private static function getCustomPostTypeBreadcrumbs() {
$breadcrumbs = array(); $breadcrumbs = array();
$postType = get_post_type_object( get_post_type() ); $postType = get_post_type_object( get_post_type() );
if ( $postType && ! in_array( get_post_type(), array( 'post', 'page' ), true ) ) { if ( $postType && ! in_array( get_post_type(), array( 'post', 'page' ), true ) ) {
$breadcrumbs[] = array( $breadcrumbs[] = array(
'url' => get_post_type_archive_link( $postType->name ), 'url' => get_post_type_archive_link( $postType->name ),
'label' => $postType->labels->name, 'label' => $postType->labels->name,
); );
} }
$breadcrumbs[] = array( 'label' => get_the_title() ); $breadcrumbs[] = array( 'label' => get_the_title() );
return $breadcrumbs; return $breadcrumbs;
} }
@@ -165,8 +168,10 @@ class Breadcrumbs {
*/ */
private static function getStaticPageBreadcrumbs( $post ) { private static function getStaticPageBreadcrumbs( $post ) {
$breadcrumbs = array(); $breadcrumbs = array();
if ( $post->post_parent ) { if ( $post->post_parent ) {
$ancestors = array_reverse( get_post_ancestors( $post->ID ) ); $ancestors = array_reverse( get_post_ancestors( $post->ID ) );
foreach ( $ancestors as $ancestor ) { foreach ( $ancestors as $ancestor ) {
$breadcrumbs[] = array( $breadcrumbs[] = array(
'url' => get_permalink( $ancestor ), 'url' => get_permalink( $ancestor ),
@@ -174,7 +179,9 @@ class Breadcrumbs {
); );
} }
} }
$breadcrumbs[] = array( 'label' => get_the_title() ); $breadcrumbs[] = array( 'label' => get_the_title() );
return $breadcrumbs; return $breadcrumbs;
} }
@@ -257,6 +264,7 @@ class Breadcrumbs {
*/ */
private static function getAuthorArchiveBreadcrumb() { private static function getAuthorArchiveBreadcrumb() {
$author = get_queried_object(); $author = get_queried_object();
return array( 'label' => 'Author: ' . $author->display_name ); return array( 'label' => 'Author: ' . $author->display_name );
} }
@@ -315,17 +323,14 @@ class Breadcrumbs {
$activeClass = $isActive ? ' active' : ''; $activeClass = $isActive ? ' active' : '';
?> ?>
<span class="p-2 breadcrumb-item<?php echo esc_attr( $activeClass ); ?>" <span class="p-2 breadcrumb-item<?php echo esc_attr( $activeClass ); ?>"
<?php <?php if ( $isActive ) : ?>
if ( $isActive ) : aria-current="page"
?> <?php endif; ?>>
aria-current="page"<?php endif; ?>>
<?php if ( ! empty( $item['url'] ) ) : ?> <?php if ( ! empty( $item['url'] ) ) : ?>
<a href="<?php echo esc_url( $item['url'] ); ?>"> <a href="<?php echo esc_url( $item['url'] ); ?>">
<?php if ( $index === 0 ) : ?> <?php if ( $index === 0 ) : ?>
<svg class="flex-shrink-0 w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" aria-label="Home"> <?php echo esc_attr( $item['label'] ); ?>
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
</svg>
<span class="sr-only"><?php echo esc_attr( $item['label'] ); ?></span>
<?php else : ?> <?php else : ?>
<?php echo esc_attr( $item['label'] ); ?> <?php echo esc_attr( $item['label'] ); ?>
<?php endif; ?> <?php endif; ?>
+5
View File
@@ -31,8 +31,11 @@ class Enqueue {
*/ */
public function enqFEAssets() { public function enqFEAssets() {
$theme_dir = get_stylesheet_directory(); $theme_dir = get_stylesheet_directory();
$theme_uri = get_stylesheet_directory_uri(); $theme_uri = get_stylesheet_directory_uri();
$leaflet_version = '1.9.4';
/** /**
* CSS * CSS
*/ */
@@ -42,6 +45,8 @@ class Enqueue {
wp_enqueue_style( 'cwc-theme', $theme_uri . $css_path, array(), $version ); wp_enqueue_style( 'cwc-theme', $theme_uri . $css_path, array(), $version );
} }
wp_enqueue_style( 'leaflet', 'https://unpkg.com/leaflet@' . $leaflet_version . '/dist/leaflet.css', array(), $leaflet_version );
$font_ver = gmdate( 'U' ); $font_ver = gmdate( 'U' );
wp_enqueue_style( 'poppins', 'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap', false, $font_ver ); wp_enqueue_style( 'poppins', 'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap', false, $font_ver );
wp_enqueue_style( 'quincy', 'https://use.typekit.net/dnt5chp.css', false, $font_ver ); wp_enqueue_style( 'quincy', 'https://use.typekit.net/dnt5chp.css', false, $font_ver );
+1
View File
@@ -154,6 +154,7 @@ add_filter(
unset( $blocks['core/calendar'] ); unset( $blocks['core/calendar'] );
unset( $blocks['core/buttons'] ); unset( $blocks['core/buttons'] );
unset( $blocks['core/search'] ); unset( $blocks['core/search'] );
unset( $blocks['core/pullquote'] );
// return the new list of allowed blocks // return the new list of allowed blocks
return array_keys( $blocks ); return array_keys( $blocks );
+1
View File
@@ -24,6 +24,7 @@ if ( hasSidebar() ) {
<article class="<?php echo esc_attr( $classes ); ?>"> <article class="<?php echo esc_attr( $classes ); ?>">
<div class="entry-content <?php echo esc_attr( $clsEntry ); ?>"> <div class="entry-content <?php echo esc_attr( $clsEntry ); ?>">
<?php <?php
// Page body content
if ( have_posts() ) { if ( have_posts() ) {
while ( have_posts() ) { while ( have_posts() ) {
the_post(); the_post();
+8 -7
View File
@@ -1,5 +1,5 @@
// @ts-check // @ts-check
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from "@playwright/test";
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -13,7 +13,9 @@ import { defineConfig, devices } from '@playwright/test';
* @see https://playwright.dev/docs/test-configuration * @see https://playwright.dev/docs/test-configuration
*/ */
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: "./tests",
snapshotPathTemplate:
"{testDir}/{testFilePath}-snapshots/{arg}{-projectName}{ext}",
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -23,22 +25,22 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000', // baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: "on-first-retry",
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ {
name: 'chromium', name: "chromium",
use: { ...devices['Desktop Chrome'] }, use: { ...devices["Desktop Chrome"] },
}, },
// { // {
@@ -79,4 +81,3 @@ export default defineConfig({
// reuseExistingServer: !process.env.CI, // reuseExistingServer: !process.env.CI,
// }, // },
}); });
+16 -38
View File
@@ -27,52 +27,30 @@ if ( hasSidebar() ) {
the_post(); the_post();
?> ?>
<div class="post-title mb-0"> <div class="post-img">
<h1 class="mb-0"><?php the_title(); ?></h1> <?php if ( has_post_thumbnail() ) : ?>
<img src="<?php the_post_thumbnail_url( 'full' ); ?>" alt="<?php the_title(); ?>" class="w-full h-auto mb-6">
<?php endif; ?>
</div> </div>
<div class="post-meta text-14px italic mb-6"> <div class="post-title mb-0">
<h1 class=""><?php the_title(); ?></h1>
<div class="post-subtitle text-28px font-quincy font-bold m-0 mb-6 p-0 leading-none text-cwc-blue-02">
<?php the_field( 'subtitle' ); ?>
</div>
</div>
<div class="post-meta text-14px text-cwc-orange-01 uppercase font-bold mb-6">
<div class=""> <div class="">
<span class="post-date">Posted: <?php the_date(); ?></span> | <span class="post-author">Posted by: <?php echo esc_html( ucfirst( get_the_author() ) ); ?></span> <span class="post-date">Posted: <?php the_date(); ?></span> | <span class="post-author">Posted by: <?php echo esc_html( ucfirst( get_the_author() ) ); ?></span>
</div> </div>
<div class="">
<span class="post-categories">
<?php
$categories = get_the_category();
if ( ! empty( $categories ) ) {
echo '<span>Posted in: ';
foreach ( $categories as $ct ) {
echo '<a href="' . esc_url( get_category_link( $ct->cat_ID ) ) . '">' . esc_html( $ct->name ) . '</a>';
if ( count( $categories ) > 1 && $ct !== end( $categories ) ) {
echo ', ';
}
}
echo '</span>';
}
?>
</span>
|
<span class="post-tags">
<?php
$tags = get_the_tags();
if ( ! empty( $tags ) ) {
echo '<span>Tagged: ';
foreach ( $tags as $tg ) {
echo '<a href="' . esc_url( get_tag_link( $tg->term_id ) ) . '">' . esc_html( $tg->name ) . '</a>';
if ( count( $tags ) > 1 && $tg !== end( $tags ) ) {
echo ', ';
}
}
echo '</span>';
}
?>
</span>
</div>
</div> </div>
<div class="post-content font-light">
<?php the_content(); ?>
</div>
<?php <?php
the_content();
} }
} }
?> ?>
+6043
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#8FC9E6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M20 10c0 7-8 12-8 12s-8-5-8-12a8 8 0 0 1 16 0Z"/>
<circle cx="12" cy="10" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#8FC9E6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<rect x="3" y="5" width="18" height="14" rx="2"/>
<path d="m3 7 9 6 9-6"/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#8FC9E6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.8a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.84.57 2.8.7A2 2 0 0 1 22 16.92Z"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.3 0H2.7C1.98392 0 1.29716 0.276562 0.790812 0.768845C0.284464 1.26113 0 1.92881 0 2.625V11.375C0 12.0712 0.284464 12.7389 0.790812 13.2312C1.29716 13.7234 1.98392 14 2.7 14H15.3C16.0161 14 16.7028 13.7234 17.2092 13.2312C17.7155 12.7389 18 12.0712 18 11.375V2.625C18 1.92881 17.7155 1.26113 17.2092 0.768845C16.7028 0.276562 16.0161 0 15.3 0ZM14.697 1.75L9 5.90625L3.303 1.75H14.697ZM15.3 12.25H2.7C2.46131 12.25 2.23239 12.1578 2.0636 11.9937C1.89482 11.8296 1.8 11.6071 1.8 11.375V2.84375L8.46 7.7C8.61579 7.81359 8.80527 7.875 9 7.875C9.19473 7.875 9.38421 7.81359 9.54 7.7L16.2 2.84375V11.375C16.2 11.6071 16.1052 11.8296 15.9364 11.9937C15.7676 12.1578 15.5387 12.25 15.3 12.25Z" fill="#8FC9E6"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="18" height="21" viewBox="0 0 18 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C11.3869 0 13.6761 0.948211 15.364 2.63604C17.0518 4.32387 18 6.61305 18 9C18 12.074 16.324 14.59 14.558 16.395C13.6757 17.2871 12.7129 18.0958 11.682 18.811L11.256 19.101L11.056 19.234L10.679 19.474L10.343 19.679L9.927 19.921C9.64463 20.0822 9.32513 20.1669 9 20.1669C8.67487 20.1669 8.35537 20.0822 8.073 19.921L7.657 19.679L7.137 19.359L6.945 19.234L6.535 18.961C5.42283 18.2085 4.3869 17.3491 3.442 16.395C1.676 14.589 0 12.074 0 9C0 6.61305 0.948211 4.32387 2.63604 2.63604C4.32387 0.948211 6.61305 0 9 0ZM9 2C7.14348 2 5.36301 2.7375 4.05025 4.05025C2.7375 5.36301 2 7.14348 2 9C2 11.322 3.272 13.36 4.871 14.996C5.55853 15.692 6.30166 16.3308 7.093 16.906L7.551 17.232C7.699 17.3353 7.84133 17.4313 7.978 17.52L8.368 17.77L8.711 17.979L9 18.148L9.455 17.879L9.822 17.649C10.0173 17.525 10.2263 17.386 10.449 17.232L10.907 16.906C11.6983 16.3308 12.4415 15.692 13.129 14.996C14.728 13.361 16 11.322 16 9C16 7.14348 15.2625 5.36301 13.9497 4.05025C12.637 2.7375 10.8565 2 9 2ZM9 5C10.0609 5 11.0783 5.42143 11.8284 6.17157C12.5786 6.92172 13 7.93913 13 9C13 10.0609 12.5786 11.0783 11.8284 11.8284C11.0783 12.5786 10.0609 13 9 13C7.93913 13 6.92172 12.5786 6.17157 11.8284C5.42143 11.0783 5 10.0609 5 9C5 7.93913 5.42143 6.92172 6.17157 6.17157C6.92172 5.42143 7.93913 5 9 5ZM9 7C8.46957 7 7.96086 7.21071 7.58579 7.58579C7.21071 7.96086 7 8.46957 7 9C7 9.53043 7.21071 10.0391 7.58579 10.4142C7.96086 10.7893 8.46957 11 9 11C9.53043 11 10.0391 10.7893 10.4142 10.4142C10.7893 10.0391 11 9.53043 11 9C11 8.46957 10.7893 7.96086 10.4142 7.58579C10.0391 7.21071 9.53043 7 9 7Z" fill="#8FC9E6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.50045 0.251886C5.63912 1.08206 6.50102 2.24845 7.2349 3.32019L7.81009 4.16568C7.98872 4.42669 8.07045 4.74192 8.04114 5.05681C8.01183 5.37171 7.87334 5.66645 7.64962 5.89003L6.42709 7.1123L6.55602 7.38632C6.87156 8.0254 7.41611 8.94571 8.23563 9.76596C8.80243 10.3222 9.44025 10.801 10.1325 11.1901L10.4634 11.3677L10.7582 11.5137L10.8898 11.5741L12.1115 10.3519C12.3358 10.1274 12.6319 9.98894 12.9481 9.96076C13.2642 9.93258 13.5802 10.0165 13.8407 10.1977L14.7549 10.8314C15.7935 11.5615 16.878 12.3935 17.721 13.4733C17.8667 13.6607 17.9594 13.8838 17.9894 14.1193C18.0194 14.3548 17.9855 14.594 17.8914 14.8119C17.1368 16.5723 15.2273 18.0713 13.2411 17.9974L12.9707 17.983L12.7606 17.9649L12.528 17.9379L12.2746 17.9036L11.9997 17.8577L11.7058 17.8L11.3938 17.7306L11.0638 17.6458L10.7167 17.5467L10.3543 17.4295C8.69002 16.8652 6.57765 15.7565 4.41029 13.5896C2.24293 11.4227 1.1349 9.31167 0.570521 7.64772L0.453317 7.28536L0.354145 6.93833L0.269398 6.60843L0.199076 6.29655C0.178788 6.19886 0.159854 6.1009 0.142277 6.0027L0.0971989 5.72778L0.0611362 5.47449L0.0349908 5.24193L0.0169594 5.03191L0.00253447 4.7615C-0.0704924 2.78206 1.44504 0.859417 3.19768 0.108566C3.40863 0.0174967 3.63963 -0.0171762 3.86803 0.00794982C4.09642 0.0330758 4.31435 0.117137 4.50045 0.251886ZM3.66469 1.88158C2.69822 2.38636 1.76239 3.54012 1.80476 4.69479L1.8228 4.99045L1.84263 5.17523L1.86878 5.38255L1.90484 5.6106L1.95082 5.86028L2.00942 6.12889L2.08155 6.41733L2.1681 6.7229L2.27087 7.0456C2.30874 7.15556 2.34901 7.26824 2.39168 7.38361L2.53052 7.73696L2.6892 8.10382C3.25538 9.34953 4.17859 10.808 5.68691 12.3151C7.19433 13.8222 8.65306 14.7461 9.89902 15.3122L10.266 15.4708L10.6194 15.6096C10.7348 15.6529 10.8475 15.6931 10.9575 15.7304L11.2802 15.8332L11.5868 15.9197L11.8744 15.9918L12.143 16.0504L12.3928 16.0964L12.6218 16.1324L12.8291 16.1595L13.013 16.1775L13.3087 16.1955C14.4483 16.2379 15.6113 15.3077 16.1144 14.354C15.4725 13.6013 14.6755 12.9839 13.7965 12.3619L13.1221 11.8923L12.9508 12.0717L12.5848 12.471L12.391 12.6747C11.8626 13.2128 11.246 13.6698 10.5067 13.3715L10.3029 13.2859L10.0541 13.1741L9.91525 13.1083L9.61052 12.956L9.27604 12.7757C8.43039 12.3035 7.65199 11.7199 6.96172 11.0405C6.28222 10.3504 5.6985 9.57215 5.22621 8.72667L5.04589 8.39316L4.89263 8.08759L4.71592 7.7L4.62937 7.49448C4.34898 6.80132 4.73485 6.21633 5.22801 5.70975L5.42996 5.51145L5.93213 5.04904L6.11245 4.87777L5.68601 4.25312C5.0522 3.33371 4.43283 2.50444 3.66379 1.88068L3.66469 1.88158ZM11.8356 3.49235L11.9402 3.50407C12.5964 3.6198 13.1992 3.94029 13.6621 4.41958C14.1249 4.89888 14.4242 5.51238 14.5168 6.17216C14.5499 6.40037 14.4942 6.63256 14.3612 6.82097C14.2283 7.00939 14.0281 7.13968 13.802 7.18506C13.5759 7.23044 13.3409 7.18745 13.1456 7.06493C12.9502 6.94241 12.8092 6.74969 12.7516 6.5264L12.7308 6.42274C12.6946 6.16414 12.5842 5.92157 12.413 5.72434C12.2418 5.52711 12.0171 5.38367 11.7662 5.31134L11.6264 5.27979C11.4 5.23967 11.1975 5.1146 11.0603 4.93015C10.9231 4.7457 10.8616 4.51581 10.8883 4.2875C10.915 4.05919 11.028 3.84971 11.2041 3.70191C11.3801 3.5541 11.6061 3.47914 11.8356 3.49235ZM11.8419 0.752152C13.2766 0.752152 14.6525 1.32195 15.6669 2.3362C16.6814 3.35045 17.2513 4.72607 17.2513 6.16044C17.251 6.39018 17.163 6.61116 17.0053 6.77822C16.8476 6.94528 16.632 7.04581 16.4026 7.05927C16.1732 7.07274 15.9473 6.99812 15.7711 6.85066C15.5949 6.7032 15.4816 6.49404 15.4545 6.2659L15.4482 6.16044C15.4481 5.24514 15.0998 4.36414 14.474 3.69609C13.8482 3.02805 12.9916 2.62298 12.0781 2.56303L11.8419 2.55491C11.6028 2.55491 11.3735 2.45995 11.2044 2.29091C11.0353 2.12186 10.9403 1.89259 10.9403 1.65353C10.9403 1.41447 11.0353 1.1852 11.2044 1.01616C11.3735 0.847119 11.6028 0.752152 11.8419 0.752152Z" fill="#8FC9E6"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="342" height="187" viewBox="0 0 342 187" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M-112 188.238C-86.7777 187.909 -56.5316 188.154 -45.5621 174.033C-21.7039 143.302 10.2073 160.42 -12.0704 181.201C-20.575 189.14 -38.5345 191.122 -51.4514 189.76C-64.3683 188.398 -94.351 179.473 -76.495 150.611C-65.2903 132.507 -34.4891 144.429 -34.4891 144.429C-34.5362 126.391 -22.9552 119.692 -14.6481 120.745C-6.10581 121.825 -0.828042 130.929 -0.828042 130.929C10.0474 122.332 12.3241 133.437 12.3241 133.437C14.6666 130.957 27.5177 122.126 40.6321 126.626C62.383 134.095 46.7472 161.772 46.7472 161.772L62.0349 156.85C84.7924 147.849 49.8106 128.063 61.1846 118.415C69.7175 111.171 94.9434 116.319 102.281 109.781C113.458 99.8219 52.5129 109.114 46.5672 96.0357C42.0326 86.077 149.095 85.1281 148.822 94.9459C148.596 103.091 116.807 103.091 124.352 110.325C131.747 117.419 140.966 114.544 144.889 122.389C148.624 129.848 95.3479 128.12 111.661 135.964C149.367 154.097 231.987 151.87 286.58 159.208C312.536 162.693 342.613 164.835 366.857 175.63C387.629 184.884 358.362 190.324 349.424 191.752C249.09 207.864 -32.1936 201.56 20.6217 183.343C50.6703 172.981 111.238 178.806 141.324 174.503C181.947 168.697 69.2883 161.726 105.744 155.421C179.933 142.607 288.669 162.205 266.222 147.013C258.761 141.968 236.493 141.376 246.465 136.397C265.94 126.673 344.617 122.671 359.114 136.942C366.283 143.997 294.323 139.187 306.478 146.139C326.639 157.667 421.526 155.713 399.784 128.89C390.442 117.362 359.82 116.836 344.542 110.814C344.542 110.814 378.372 106.981 394.046 87.7399C397.414 83.6061 397.95 75.47 394.582 70.8665C386.529 59.8555 344.09 59.2261 355.022 78.7677C363.423 93.7903 384.243 71.1765 386.886 64.5718C388.062 61.6406 389.389 58.8879 390.057 55.7687C391.656 48.2809 389.332 41.4507 381.806 38.5383C377.469 36.8566 372.615 36.5747 368.014 36.6593C369.209 32.1215 367.196 27.3488 363.169 25.094C359.321 22.9426 354.429 23.5439 351.155 26.6066C347.881 17.3808 334.974 8.66219 327.589 9.12255C319.62 9.62988 310.26 13.961 312.79 37.1948C312.79 37.1948 301.228 31.1632 292.667 39.7033C280.776 51.5598 292.112 63.6323 292.112 63.6323C292.112 63.6323 279.524 57.4222 274.689 67.6534C271.424 64.7785 269.298 62.8995 264.98 59.0945L263.842 75.8834C285.818 77.5839 315.707 76.1653 337.683 77.8658C334.25 86.9225 326.281 100.292 322.847 109.348C298.058 109.724 273.268 110.1 248.488 110.476C251.517 102.274 254.547 94.0627 257.576 85.8609L222.861 61.34L193.622 59.3294C206.473 70.9322 214.639 74.493 227.49 86.0958C227.49 86.0958 228.271 108.324 227.64 123.563C227.433 128.608 223.162 132.516 218.11 132.3C199.379 131.492 178.296 130.506 160.469 129.773C160.102 129.773 160.102 79.2844 160.102 79.2844L185.616 53.9273L162.962 40.2764C173.611 27.9689 186.961 12.3075 197.601 0L238.459 48.0648C248.987 48.5815 259.514 49.1077 270.041 49.6244C261 37.0821 242.26 17.4559 242.26 17.4559L206.191 10.56C187.958 29.1527 169.726 47.7454 151.503 66.3381M151.503 66.3569C151.503 66.3569 159.989 45.3873 147.081 46.7401C147.081 46.7401 160.977 19.194 135.303 23.8915C135.303 23.8915 131.606 0.216085 115.067 4.41565C98.5277 8.61522 98.3772 25.094 101.096 35.2782C101.096 35.2782 89.3175 29.9513 77.7647 34.7521C66.2119 39.553 60.285 69.0063 93.5792 72.3979C126.883 75.7895 137.26 51.3531 132.584 47.1911C118.087 34.2918 100.927 47.7266 75.4316 65.7556" stroke="#8FC9E6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 32" preserveAspectRatio="none">
<rect x="0" y="3" width="1600" height="32" fill="#F26B53" />
</svg>

After

Width:  |  Height:  |  Size: 162 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.0301C24 5.38947 18.624 0 12 0C5.376 0 0 5.38947 0 12.0301C0 17.8526 4.128 22.7008 9.6 23.8195V15.6391H7.2V12.0301H9.6V9.02256C9.6 6.70075 11.484 4.81203 13.8 4.81203H16.8V8.42105H14.4C13.74 8.42105 13.2 8.96241 13.2 9.62406V12.0301H16.8V15.6391H13.2V24C19.26 23.3985 24 18.2737 24 12.0301Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.3725 0 0 5.3725 0 12C0 18.6275 5.3725 24 12 24C18.6275 24 24 18.6275 24 12C24 5.3725 18.6275 0 12 0ZM9.0625 16.9738H6.6325V9.15375H9.0625V16.9738ZM7.8325 8.19375C7.065 8.19375 6.56875 7.65 6.56875 6.9775C6.56875 6.29125 7.08 5.76375 7.86375 5.76375C8.6475 5.76375 9.1275 6.29125 9.1425 6.9775C9.1425 7.65 8.6475 8.19375 7.8325 8.19375ZM17.9375 16.9738H15.5075V12.64C15.5075 11.6313 15.155 10.9462 14.2763 10.9462C13.605 10.9462 13.2063 11.41 13.03 11.8563C12.965 12.015 12.9488 12.24 12.9488 12.4638V16.9725H10.5175V11.6475C10.5175 10.6713 10.4862 9.855 10.4537 9.1525H12.565L12.6763 10.2388H12.725C13.045 9.72875 13.8288 8.97625 15.14 8.97625C16.7388 8.97625 17.9375 10.0475 17.9375 12.35V16.9738Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 835 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.0037 11.7913L11.1963 10.4813C10.9513 10.3675 10.75 10.495 10.75 10.7662V13.2338C10.75 13.505 10.9513 13.6325 11.1963 13.5188L14.0025 12.2087C14.2488 12.0938 14.2487 11.9063 14.0037 11.7913ZM12 0C5.3725 0 0 5.3725 0 12C0 18.6275 5.3725 24 12 24C18.6275 24 24 18.6275 24 12C24 5.3725 18.6275 0 12 0ZM12 16.875C5.8575 16.875 5.75 16.3213 5.75 12C5.75 7.67875 5.8575 7.125 12 7.125C18.1425 7.125 18.25 7.67875 18.25 12C18.25 16.3213 18.1425 16.875 12 16.875Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 587 B

+98
View File
@@ -0,0 +1,98 @@
/**
* Contact Info map module.
*/
const MAP_SELECTOR = '.contact-info__map[data-lat][data-lng]';
const DEFAULT_ZOOM = 14;
const MARKER_SIZE = [62, 68];
let leafletModulePromise;
function loadLeaflet() {
if (!leafletModulePromise) {
leafletModulePromise = import('https://unpkg.com/leaflet@1.9.4/dist/leaflet-src.esm.js');
}
return leafletModulePromise;
}
function toFloat(value) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
export async function initContactInfoMap() {
const mapContainers = document.querySelectorAll(MAP_SELECTOR);
if (!mapContainers.length) {
return;
}
let Leaflet;
try {
const module = await loadLeaflet();
Leaflet = module.default || module;
} catch (error) {
console.error('Leaflet failed to load for contact map.', error);
return;
}
mapContainers.forEach((container) => {
if (container.dataset.mapReady === 'true') {
return;
}
const latitude = toFloat(container.dataset.lat);
const longitude = toFloat(container.dataset.lng);
const zoom = toFloat(container.dataset.zoom) || DEFAULT_ZOOM;
if (latitude === null || longitude === null) {
return;
}
const map = Leaflet.map(container, {
zoomControl: false,
scrollWheelZoom: false,
dragging: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
tap: false,
attributionControl: true,
}).setView([latitude, longitude], zoom);
Leaflet.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
subdomains: 'abcd',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(map);
const markerSvg = container.dataset.markerSvg;
const markerIcon = markerSvg
? Leaflet.divIcon({
className: 'contact-info__leaflet-pin',
html: markerSvg,
iconSize: MARKER_SIZE,
iconAnchor: [MARKER_SIZE[0] / 2, MARKER_SIZE[1]],
})
: Leaflet.icon({
iconUrl: container.dataset.marker || '',
iconSize: MARKER_SIZE,
iconAnchor: [MARKER_SIZE[0] / 2, MARKER_SIZE[1]],
popupAnchor: [0, -MARKER_SIZE[1] + 8],
className: 'contact-info__leaflet-pin'
});
const marker = Leaflet.marker([latitude, longitude], { icon: markerIcon }).addTo(map);
const popupText = container.dataset.popup;
if (popupText) {
marker.bindPopup(popupText);
}
container.dataset.mapReady = 'true';
});
}
+48
View File
@@ -0,0 +1,48 @@
const mobileViewport = window.matchMedia("(max-width: 640px)");
const initializeCarousel = (carousel) => {
const cards = Array.from(carousel.querySelectorAll(".recent-posts__card"));
const dots = Array.from(carousel.querySelectorAll(".recent-posts__dot"));
const previous = carousel.querySelector(".recent-posts__control--previous");
const next = carousel.querySelector(".recent-posts__control--next");
if (cards.length < 2 || !previous || !next) {
return;
}
let activeIndex = 0;
const render = () => {
cards.forEach((card, index) => {
const isActive = index === activeIndex;
card.classList.toggle("is-active", isActive);
card.hidden = mobileViewport.matches && !isActive;
});
dots.forEach((dot, index) => {
const isActive = index === activeIndex;
dot.classList.toggle("is-active", isActive);
dot.toggleAttribute("aria-current", isActive);
});
};
const showArticle = (index) => {
activeIndex = (index + cards.length) % cards.length;
render();
};
previous.addEventListener("click", () => showArticle(activeIndex - 1));
next.addEventListener("click", () => showArticle(activeIndex + 1));
dots.forEach((dot, index) =>
dot.addEventListener("click", () => showArticle(index)),
);
mobileViewport.addEventListener("change", render);
render();
};
const initRecentPostsCarousels = () => {
document.querySelectorAll(".recent-posts").forEach(initializeCarousel);
};
export default initRecentPostsCarousels;
+8
View File
@@ -7,6 +7,8 @@ import { registerBackToTopButton } from './components/backToTop.js';
import GetHeaderHeight from './modules/GetHeaderHeight.js'; import GetHeaderHeight from './modules/GetHeaderHeight.js';
import tagExternalLinks from './modules/TagExternalLinks.js'; import tagExternalLinks from './modules/TagExternalLinks.js';
import Navigation from './modules/Navigation.js'; import Navigation from './modules/Navigation.js';
import initRecentPostsCarousels from './modules/RecentPostsCarousel.js';
import { initContactInfoMap } from './modules/ContactInfoMap.js';
// Add passive event listeners // Add passive event listeners
! function (e) { ! function (e) {
@@ -68,6 +70,12 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize Header Height // Initialize Header Height
GetHeaderHeight(); GetHeaderHeight();
// Initialize mobile Recent Insights carousels.
initRecentPostsCarousels();
// Initialize Contact Info Leaflet maps.
initContactInfoMap();
// Add Back to Top button to body // Add Back to Top button to body
// const backToTop = document.createElement('x-back-to-top'); // const backToTop = document.createElement('x-back-to-top');
// document.body.appendChild(backToTop); // document.body.appendChild(backToTop);
+1
View File
@@ -1,2 +1,3 @@
/* Light admin styles; avoid overriding core wp-admin UI */ /* Light admin styles; avoid overriding core wp-admin UI */
#adminmenu ol, ul, li ul, ol li { margin: 0; }
+1
View File
@@ -8,6 +8,7 @@
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-base);
h1, h2, h3, h1, h2, h3,
h4, h5, h6 { h4, h5, h6 {
+2 -1
View File
@@ -4,7 +4,7 @@
--color-black: oklch(0% 0 0); --color-black: oklch(0% 0 0);
--color-white: oklch(100% 0 0); --color-white: oklch(100% 0 0);
--color-background: oklch(89.75% 0 0); --color-background: oklch(100% 0 0);
--color-text: var(--color-black); --color-text: var(--color-black);
--color-cwc-blue-01: oklch(47.31% 0.1141 242.9); --color-cwc-blue-01: oklch(47.31% 0.1141 242.9);
@@ -24,6 +24,7 @@
--color-primary-900: color-mix(in oklch, var(--color-primary) 90%, white); --color-primary-900: color-mix(in oklch, var(--color-primary) 90%, white);
--color-secondary: var(--color-cwc-orange-01); --color-secondary: var(--color-cwc-orange-01);
--color-secondary-accessible: #c43c2b;
--color-secondary-100: color-mix(in oklch, var(--color-secondary) 10%, white); --color-secondary-100: color-mix(in oklch, var(--color-secondary) 10%, white);
--color-secondary-200: color-mix(in oklch, var(--color-secondary) 20%, white); --color-secondary-200: color-mix(in oklch, var(--color-secondary) 20%, white);
--color-secondary-300: color-mix(in oklch, var(--color-secondary) 30%, white); --color-secondary-300: color-mix(in oklch, var(--color-secondary) 30%, white);
+1 -1
View File
@@ -26,7 +26,7 @@ main#maincontent {
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-text); color: var(--color-text);
margin: 0; margin: 0;
padding: 0 0 4rem; padding: 0 0 5rem;
} }
.container { .container {
+17
View File
@@ -1 +1,18 @@
/* Miscellaneous extra styles */ /* Miscellaneous extra styles */
.social-icons svg {
@apply bg-transparent fill-white rounded-full block;
}
.post-title h1 {
@apply mb-8 text-cwc-blue-01 leading-none;
&::after {
background: var(--color-secondary);
content: "";
display: block;
height: 4px;
margin-top: 0.45rem;
width: 3rem;
}
}
+23 -15
View File
@@ -43,19 +43,19 @@
--text-base: 1rem; --text-base: 1rem;
--text-12px: round(down, clamp(0.625rem, 0.5962rem + 0.1282vw, 0.75rem), 1px); --text-12px: round(down, clamp(0.625rem, 0.5962rem + 0.1282vw, 0.75rem), 1px);
--text-14px: round(up, clamp(0.75rem, 0.7212rem + 0.1282vw, 0.875rem), 2px); --text-14px: round(up, clamp(0.75rem, 0.7212rem + 0.1282vw, 0.875rem), 1px);
--text-16px: round(up, clamp(0.875rem, 0.8462rem + 0.1282vw, 1rem), 2px); --text-16px: round(up, clamp(0.875rem, 0.8462rem + 0.1282vw, 1rem), 1px);
--text-18px: round(up, clamp(1rem, 0.9712rem + 0.1282vw, 1.125rem), 2px); --text-18px: round(up, clamp(1rem, 0.9712rem + 0.1282vw, 1.125rem), 1px);
--text-20px: round(up, clamp(1.125rem, 1.0962rem + 0.1282vw, 1.25rem), 2px); --text-20px: round(up, clamp(1.125rem, 1.0962rem + 0.1282vw, 1.25rem), 1px);
--text-22px: round(up, clamp(1.25rem, 1.2212rem + 0.1282vw, 1.375rem), 2px); --text-22px: round(up, clamp(1.25rem, 1.2212rem + 0.1282vw, 1.375rem), 1px);
--text-24px: round(up, clamp(1.375rem, 1.3462rem + 0.1282vw, 1.5rem), 2px); --text-24px: round(up, clamp(1.375rem, 1.3462rem + 0.1282vw, 1.5rem), 1px);
--text-28px: round(up, clamp(1.625rem, 1.5962rem + 0.1282vw, 1.75rem), 2px); --text-28px: round(up, clamp(1.625rem, 1.5962rem + 0.1282vw, 1.75rem), 1px);
--text-30px: round(up, clamp(1.75rem, 1.7212rem + 0.1282vw, 1.875rem), 2px); --text-30px: round(up, clamp(1.75rem, 1.7212rem + 0.1282vw, 1.875rem), 1px);
--text-32px: round(up, clamp(1.875rem, 1.8462rem + 0.1282vw, 2rem), 2px); --text-32px: round(up, clamp(1.875rem, 1.8462rem + 0.1282vw, 2rem), 1px);
--text-34px: round(up, clamp(2rem, 1.9712rem + 0.1282vw, 2.125rem), 2px); --text-34px: round(up, clamp(2rem, 1.9712rem + 0.1282vw, 2.125rem), 1px);
--text-40px: round(up, clamp(2.25rem, 2.1923rem + 0.2564vw, 2.5rem), 2px); --text-40px: round(up, clamp(2.25rem, 2.1923rem + 0.2564vw, 2.5rem), 1px);
--text-54px: round(up, clamp(2.375rem, 2.1442rem + 1.0256vw, 3.375rem), 2px); --text-54px: round(up, clamp(2.375rem, 2.1442rem + 1.0256vw, 3.375rem), 1px);
--text-64px: round(up, clamp(3rem, 2.7692rem + 1.0256vw, 4rem), 2px); --text-64px: round(up, clamp(3rem, 2.7692rem + 1.0256vw, 4rem), 1px);
} }
:root { :root {
@@ -140,9 +140,17 @@ p {
} }
ul { list-style-type: disc; } ul {
list-style-type: disc;
list-style-position: inside;
padding: 0 1rem;
}
ol { list-style-type: decimal; } ol {
list-style-type: decimal;
list-style-position: inside;
padding: 0 1rem;
}
li ul, li ol { margin: 0 1rem; } li ul, li ol { margin: 0 1rem; }
+8 -8
View File
@@ -96,7 +96,7 @@
.btn:hover, .button:hover, .acf-block-preview .button:hover { .btn:hover, .button:hover, .acf-block-preview .button:hover {
background: var(--button-hover-bg); background: var(--button-hover-bg);
border-color: var(--button-hover-border-color); border-color: var(--button-hover-border-color);
box-shadow: var(--button-hover-shadow); box-shadow: none;
color: var(--button-hover-color); color: var(--button-hover-color);
} }
@@ -143,15 +143,15 @@ x-button:has(.button[data-button-width="full"]) { @apply w-full; }
* like the ones below and changing the color values. * like the ones below and changing the color values.
*/ */
.btn[data-button-color="secondary"], .button[data-button-color="secondary"] { .btn[data-button-color="secondary"], .button[data-button-color="secondary"] {
--button-bg: var(--color-secondary); --button-bg: var(--color-secondary-accessible);
--button-color: var(--color-white); --button-color: var(--color-white);
--button-border-color: var(--color-secondary); --button-border-color: var(--color-secondary-accessible);
--button-outline-color: oklch(0.48 0.0136 252.2); --button-outline-color: var(--color-secondary-accessible);
--button-hover-bg: var(--color-dark); --button-hover-bg: color-mix(in oklch, var(--color-secondary) 74%);
--button-hover-border-color: var(--color-dark); --button-hover-border-color: color-mix(in oklch, var(--color-secondary) 74%);
--button-hover-color: var(--color-white); --button-hover-color: var(--color-white);
--button-active-bg: var(--color-dark); --button-active-bg: color-mix(in oklch, var(--color-secondary) 84%);
--button-active-border-color: var(--color-dark); --button-active-border-color: color-mix(in oklch, var(--color-secondary) 84%);
} }
.btn[data-button-color="light"], .button[data-button-color="light"] { .btn[data-button-color="light"], .button[data-button-color="light"] {
+5
View File
@@ -2,3 +2,8 @@
@import './buttons.css'; @import './buttons.css';
@import './core.css'; @import './core.css';
@import './misc.css';
@import './page-hero.css';
@import '../../views/blocks/contact-info/contact-info.css';
@import '../../views/blocks/services-list/services-list.css';
@import '../../views/blocks/team-grid/team-grid.css';
+24
View File
@@ -0,0 +1,24 @@
/* Misc block styles */
.logo-soup.layout-carousel {
/* Apply a gradient mask that fades 40px on both the left and right sides */
mask-image: linear-gradient(
to right,
transparent 0px,
black 40px,
black calc(100% - 40px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
transparent 0px,
black 40px,
black calc(100% - 40px),
transparent 100%
);
/* Ensures the mask doesn't break scrolling functionality */
mask-composite: intersect;
-webkit-mask-composite: intersect;
}
+101
View File
@@ -0,0 +1,101 @@
.page-hero__heading {
@apply text-cwc-blue-01;
line-height: 1;
margin: 0;
text-align: left;
em {
color: var(--color-secondary);
font-style: normal;
}
}
.page-hero__intro {
font-weight: 300;
line-height: 1.3;
text-align: left;
}
/* Page Hero partial styles (page-hero-services.php)
* Scoped to .page-hero-services to avoid affecting the homepage-hero block. */
.page-hero-services {
background: linear-gradient(73.83deg, #8FC9E6 -8.7%, #006196 21.51%, #032F46 60.7%);
isolation: isolate;
margin-bottom: 10rem;
min-height: clamp(31rem, 38.5417vw, 46.25rem);
position: relative;
}
.page-hero-services__content {
padding-block: clamp(2rem, 6vw, 4rem);
position: relative;
z-index: 10;
}
.page-hero-services__heading {
color: #fff;
line-height: 1;
margin: 3rem 0 1.5rem;
max-width: 36rem;
text-align: left;
em {
color: var(--color-secondary);
font-style: normal;
}
}
.page-hero-services__intro {
color: #fff;
font-size: clamp(1rem, 1.5vw, 1.5rem);
font-weight: 300;
line-height: 1.4;
max-width: 32rem;
opacity: 0.95;
text-align: left;
}
.page-hero-services__media {
@apply rounded-xl;
bottom: -5%;
height: auto;
max-height: none;
pointer-events: none;
position: absolute;
right: 28vw;
top: 3rem;
width: auto;
z-index: 1;
/* Set width via max-width so the image's natural aspect ratio is preserved. */
max-width: clamp(20rem, 30vw, 28rem);
}
.page-hero-services__vector {
/* bottom: clamp(1.5rem, 4vw, 3rem); */
bottom: 0;
height: auto;
left: 0;
pointer-events: none;
position: absolute;
width: 100%;
z-index: 5;
}
@media (max-width: 767px) {
.page-hero-services {
min-height: clamp(28rem, 130vw, 40rem);
}
.page-hero-services__media {
bottom: -8%;
right: 0;
top: auto;
max-width: 60vw;
}
.page-hero-services__vector {
bottom: clamp(1rem, 3vw, 2rem);
width: 100%;
}
}
+6 -12
View File
@@ -5,31 +5,25 @@
@apply hidden md:flex items-center justify-center mt-12; @apply hidden md:flex items-center justify-center mt-12;
.page-numbers { .page-numbers {
@apply hidden md:flex items-center justify-center h-10 p-4 text-base font-medium transition duration-300 rounded stroke-primary text-primary hover:bg-info hover:text-light hover:stroke-info focus-visible:bg-info focus-visible:text-light focus-visible:stroke-info focus-visible:outline-none; @apply hidden md:flex items-center justify-center h-16 w-16 text-32px font-quincy font-medium transition duration-300 rounded stroke-primary text-primary hover:bg-info hover:text-light hover:stroke-info focus-visible:bg-info focus-visible:text-light focus-visible:stroke-info focus-visible:outline-none;
} }
.page-numbers.current { .page-numbers.current { @apply text-light whitespace-nowrap bg-info ring-offset-2 hover:bg-primary hover:stroke-primary focus-visible:bg-info; }
@apply text-light whitespace-nowrap bg-info ring-offset-2 hover:bg-primary hover:stroke-primary focus-visible:bg-info;
}
.prev, .next { .prev, .next { @apply flex gap-4 w-fit; }
@apply flex gap-4;
}
} }
& a, & span { & a, & span {
border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
color: #3857BC !important; color: var(--color-cwc-blue-01) !important;
margin: 0 5px; margin: 0 5px;
padding: .25rem .5rem; padding: .25rem .5rem;
text-decoration: none; text-decoration: none;
} }
& .current, & a:hover { & .current, & a:hover {
background: #3857BC !important; background: color-mix(in oklch, var(--color-cwc-blue-03), white 50%) !important;
border-color: #3857BC !important; color: var(--color-cwc-blue-01) !important;
color: #fff !important;
padding: .25rem .5rem; padding: .25rem .5rem;
} }
} }
+49 -33
View File
@@ -1,37 +1,53 @@
/* Blog/post index listing styles */ /* Blog/post index listing styles */
.post-list { .post-list__post { overflow: hidden; }
/* Original styles, if needed
* .post-list__posts { .post-list__img { border-bottom: 0; }
* }
* .post-list__h1 { .post-list__details {
* } background: color-mix(in oklch, var(--color-cwc-blue-03) 60%, white);
* .post-list__post { color: var(--color-cwc-blue-01);
* &:hover {
* img {
* }
* }
* .post-list__title {
* }
* .post-list__details {
* }
* .post-list__byline {
* }
* .post-list__author {
* }
* .post-list__sep {
* }
* .post-list__date {
* }
* }
*/
} }
/* Original styles, if needed .post-list__title {
* .no-posts { color: var(--color-cwc-blue-01);
* .no-posts__title { font-weight: 700;
* } line-height: 1.1;
* .no-posts__desc {
* } a {
* } color: var(--color-cwc-blue-01);
*/ text-decoration: none;
&::after {
background: var(--color-secondary);
content: "";
display: block;
height: 4px;
margin-top: 0.45rem;
width: 3rem;
}
}
}
.post-list__excerpt {
color: var(--color-dark);
font-size: var(--text-18px);
font-weight: 300;
line-height: 1.5;
}
.post-list__byline {
color: var(--color-cwc-blue-01);
font-size: 0.875rem;
}
.post-list__read-more {
color: var(--color-secondary);
font-weight: 600;
margin-top: 0.75rem;
text-decoration: none;
}
.post-list__read-more:hover {
}
+147 -13
View File
@@ -1,28 +1,162 @@
/* Sidebar styles */ /* Sidebar styles */
.sidebar{ .sidebar{
.widget { @apply pt-1;
/* Widget styles */
@apply mb-8;
h3 { .widget {
/* Widget title styles */ @apply mb-10;
@apply m-0 mb-2 font-semibold;
&:last-child { @apply mb-0; }
h2,
h3,
.wp-block-heading {
@apply m-0 mb-4 pb-0;
color: var(--color-cwc-blue-01);
font-family: var(--font-sans);
font-size: var(--text-14px);
font-weight: 600;
letter-spacing: 0.11em;
line-height: 1;
text-transform: uppercase;
} }
ul { ul {
/* List styles */ @apply list-none m-0 p-0;
@apply list-none m-0 p-0 pl-3 border-l-3 border-secondary-400;
li { li {
/* List item styles */ @apply m-0 leading-none!;
@apply text-balance;
&:first-of-type { &:not(:last-child) { @apply mb-4; }
/* First list item styles */
@apply leading-none mb-1; a {
color: var(--color-dark);
font-size: var(--text-16px);
font-weight: 300;
&:hover { color: var(--color-cwc-blue-01); }
} }
} }
} }
select {
@apply px-5 py-3 rounded-sm border-2 border-cwc-blue-03;
background-color: color-mix(in oklch, var(--color-cwc-blue-03) 40%, white);
color: var(--color-dark);
font-size: var(--text-16px);
font-weight: 300;
line-height: 1.1;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='7' viewBox='0 0 12 7' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.25L6 5.75L11 1.25' stroke='%23F26B53' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-position: right 1rem center;
background-repeat: no-repeat;
background-size: 0.75rem auto;
padding-right: 2.75rem;
&:focus-visible {
border-color: var(--color-cwc-blue-01);
outline-offset: 2px;
}
}
.wp-block-search__label,
.screen-reader-text {
@apply absolute left-5 top-1/2 pointer-events-none;
color: var(--color-dark);
font-size: var(--text-16px);
font-weight: 300;
line-height: 1;
transform: translateY(-50%);
z-index: 2;
&::before {
content: "";
display: inline-block;
width: 1rem;
height: 1rem;
margin-right: 0.6rem;
transform: translateY(1px);
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.25 12.25C13.4167 11.0833 14 9.66667 14 8C14 6.33333 13.4167 4.91667 12.25 3.75C11.0833 2.58333 9.66667 2 8 2C6.33333 2 4.91667 2.58333 3.75 3.75C2.58333 4.91667 2 6.33333 2 8C2 9.66667 2.58333 11.0833 3.75 12.25C4.91667 13.4167 6.33333 14 8 14C9.66667 14 11.0833 13.4167 12.25 12.25ZM8 16C5.76667 16 3.875 15.225 2.325 13.675C0.775 12.125 0 10.2333 0 8C0 5.76667 0.775 3.875 2.325 2.325C3.875 0.775 5.76667 0 8 0C10.2333 0 12.125 0.775 13.675 2.325C15.225 3.875 16 5.76667 16 8C16 8.93333 15.8542 9.8125 15.5625 10.6375C15.2708 11.4625 14.8583 12.2167 14.325 12.9L20 18.6L18.6 20L12.9 14.325C12.2167 14.8583 11.4625 15.2708 10.6375 15.5625C9.8125 15.8542 8.93333 16 8 16Z' fill='%23F26B53'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: contain;
}
}
.wp-block-search {
@apply relative;
&:focus-within {
.wp-block-search__label { opacity: 0; }
}
.wp-block-search__inside-wrapper { @apply relative; }
.wp-block-search__input {
@apply rounded-sm border-2 border-cwc-blue-03 px-5 py-3;
background-color: color-mix(in oklch, var(--color-cwc-blue-03) 40%, white);
color: var(--color-dark);
font-size: var(--text-16px);
font-weight: 300;
line-height: 1.1;
padding-left: 8.8rem;
}
.wp-block-search__button { @apply hidden; }
&:has(.wp-block-search__input:not(:placeholder-shown)) { .wp-block-search__label { opacity: 0; } }
}
&.widget_search {
@apply mb-9;
.wp-block-search__input {
&:focus-visible {
border-color: var(--color-cwc-blue-01);
outline-offset: 2px;
}
}
}
&:has(ul) {
h2,
h3,
.wp-block-heading {
@apply mb-2;
&::after {
@apply block mt-2;
content: "";
height: 2px;
width: 100%;
background: var(--color-secondary);
}
}
}
.wp-block-archives__label {
@apply sr-only;
}
}
@media (max-width: 1023px) {
.widget {
ul a { font-size: var(--text-16px); }
.wp-block-search__label,
.screen-reader-text {
font-size: var(--text-16px);
}
.wp-block-search .wp-block-search__input {
font-size: var(--text-16px);
padding-left: 7.7rem;
}
}
} }
} }
+159 -26
View File
@@ -1,53 +1,186 @@
/* Footer styles */ /* Footer styles */
.site-footer { .site-footer {
border-bottom: 1rem solid var(--color-orange, #F26B53); border-bottom: 1rem solid var(--color-orange, #f26b53);
a { @apply text-white transition-colors duration-300 hover:text-secondary-400 focus-visible:text-secondary-400; } a {
@apply text-white transition-colors duration-300 hover:text-secondary-400 focus-visible:text-secondary-400;
.widget {
li { @apply text-16px my-1 mb-2 leading-4; }
h4 { @apply font-bold text-18px text-secondary mb-2; }
} }
.menu-footer-menu-container { @apply grow; } .widget {
li {
@apply text-16px my-1 mb-2 leading-4;
}
h4 {
@apply font-bold text-18px text-secondary mb-2;
}
}
.menu-footer-menu-container {
@apply grow;
}
.copyright { .copyright {
p { @apply text-white leading-none m-0 p-0; } p {
@apply text-white leading-none m-0 p-0;
}
a { @apply text-white hover:text-primary-500 underline underline-offset-2; } a {
@apply text-white hover:text-primary-500 underline underline-offset-2;
}
}
.contact__item {
@apply flex justify-start items-start font-light text-white text-14px mb-2;
p {
@apply m-0 p-0;
}
a {
@apply text-white hover:text-secondary-400 focus-visible:text-secondary-400;
}
&::before {
@apply inline-block mr-2;
content: "";
height: 1.5rem;
width: 1.5rem;
}
&.contact__phone::before {
background: url("../../static/img/footer-phone.svg") no-repeat
center center;
}
&.contact__email::before {
background: url("../../static/img/footer-email.svg") no-repeat
center center;
}
&.contact__address::before {
background: url("../../static/img/footer-map.svg") no-repeat center
center;
padding-top: 2rem;
}
} }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.site-footer { .site-footer {
padding-block: 2.25rem 1.25rem; border-bottom-width: clamp(4.0625rem, 20.4vw, 5.125rem);
font-size: clamp(0.875rem, 3.98vw, 1rem);
min-height: clamp(34rem, 167.4vw, 42.0625rem);
padding-block: clamp(2.375rem, 11.94vw, 3rem)
clamp(1.125rem, calc(12.2vw - 1.3125rem), 1.75rem);
} }
.site-footer > .container { .site-footer > .container {
gap: 1.5rem 1rem; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 clamp(0.8125rem, 3.98vw, 1rem);
padding-inline: 1rem; grid-template-columns: minmax(0, 1fr) auto;
padding-inline: clamp(1.59375rem, 7.96vw, 2rem);
} }
.site-footer .site-footer__logo { .site-footer > .container > div:first-child {
max-width: 6rem; grid-column: 1;
grid-row: 1;
} }
.site-footer .widget li { .site-footer #footer-header {
font-size: var(--text-14px); margin: 0;
line-height: 1.1; max-width: clamp(9.75rem, 48.76vw, 12.25rem);
margin-bottom: 0.4rem;
} }
.site-footer .widget h4 { .site-footer #footer-nav {
font-size: var(--text-16px); grid-column: 1 / -1;
margin-bottom: 0.5rem; grid-row: 2;
margin-top: clamp(1.5625rem, 7.71vw, 1.9375rem);
} }
.site-footer .text-right { .site-footer .site-footer__nav-list {
align-items: flex-start; display: grid;
text-align: left; gap: clamp(0.625rem, 3.23vw, 0.8125rem) clamp(1.59375rem, 7.96vw, 2rem);
grid-auto-flow: column;
grid-template-rows: repeat(3, auto);
padding-inline-start: 0;
}
.site-footer .site-footer__nav-item {
margin-bottom: 0;
}
.site-footer #footer-nav + div {
grid-column: 1 / -1;
grid-row: 3;
margin-top: clamp(1.25rem, 6.22vw, 1.5625rem);
}
.site-footer #footer-description {
font-size: clamp(1rem, 4.98vw, 1.25rem);
line-height: 1.2;
max-width: 21rem;
}
.site-footer .contact__item {
font-size: clamp(0.75rem, 3.48vw, 0.875rem);
}
.site-footer .contact__address {
margin-top: clamp(0.875rem, 4.48vw, 1.125rem);
}
.site-footer .contact__email,
.site-footer .contact__phone {
margin-top: clamp(0.8125rem, 3.98vw, 1rem);
}
.site-footer .contact__item::before {
height: clamp(1.1875rem, 5.97vw, 1.5rem);
margin-right: clamp(0.375rem, 1.99vw, 0.5rem);
width: clamp(1.1875rem, 5.97vw, 1.5rem);
}
.site-footer .contact__address::before {
padding-top: clamp(1.59375rem, 7.96vw, 2rem);
}
.site-footer > .container > .text-right {
display: contents;
}
.site-footer x-back-to-top {
align-self: start;
grid-column: 2;
grid-row: 1;
}
.site-footer #backToTopBtn {
height: clamp(2rem, 9.95vw, 2.5rem);
padding: 0;
width: clamp(2rem, 9.95vw, 2.5rem);
}
.site-footer .social-links {
grid-column: 1 / -1;
grid-row: 4;
justify-content: flex-end;
margin-top: clamp(0.5rem, 2.49vw, 0.625rem);
}
.site-footer .social-icons {
--size: clamp(1.1875rem, 5.97vw, 1.5rem);
}
.site-footer .social-links a {
margin-right: clamp(0.8125rem, 3.98vw, 1rem);
}
.site-footer .copyright {
grid-column: 1 / -1;
grid-row: 5;
margin-top: clamp(0.8125rem, 4.23vw, 1.0625rem);
padding-block: 0;
text-align: center;
} }
} }
+7 -6
View File
@@ -74,18 +74,19 @@
.site-header .nav-main__toggle { .site-header .nav-main__toggle {
align-items: center; align-items: center;
background: var(--color-secondary); background: var(--color-secondary);
border-radius: 0.375rem; border-radius: 1.4rem 0.35rem 1.4rem 0.35rem;
display: inline-flex; display: inline-flex;
height: 2.25rem; height: 2.8125rem;
justify-content: center; justify-content: center;
margin-block: -0.125rem;
margin-right: 1rem; margin-right: 1rem;
padding: 0.45rem; padding: 0.65rem 1rem;
width: 2.25rem; width: 4.5rem;
} }
.site-header .nav-main__toggle svg { .site-header .nav-main__toggle svg {
height: 1.15rem; height: 1.5rem;
width: 1.15rem; width: 1.5rem;
} }
.site-header .nav-main__toggle svg, .site-header .nav-main__toggle svg,
+8 -7
View File
@@ -52,19 +52,20 @@ Lineicons regular icon font
.circular-icon { .circular-icon {
@apply box-content bg-secondary rounded-full text-white flex justify-center items-center; @apply box-content bg-secondary rounded-full text-white flex justify-center items-center;
--size: 2rem; --size: 1.5rem;
height: var(--size); height: var(--size);
text-align: center; text-align: center;
width: var(--size); width: var(--size);
i { i {
display: inline-block; align-items: center;
height: var(--size); display: flex;
height: 100%;
justify-content: center;
line-height: 1; line-height: 1;
margin-top: 0.5rem; margin: 0;
text-align: center; padding: 0;
vertical-align: middle; width: 100%;
width: var(--size);
} }
&i::before, span { line-height: var(--size) !important; } &i::before, span { line-height: var(--size) !important; }
+2 -2
View File
@@ -29,8 +29,8 @@
/* Mobile Navigation Style - Choose one of the following: */ /* Mobile Navigation Style - Choose one of the following: */
/* Accordion/dropdown style */ /* Accordion/dropdown style */
/* @import "./nav-mobile-accordion.css"; */ @import "./nav-mobile-accordion.css";
/* Sliding viewport style */ /* Sliding viewport style */
@import "./nav-mobile-sliding.css"; /* @import "./nav-mobile-sliding.css"; */
@import "./nav-footer.css"; @import "./nav-footer.css";
+2 -2
View File
@@ -43,7 +43,7 @@
} }
/* Mobile */ /* Mobile */
@media screen and (max-width: 62.5rem) { @media screen and (max-width: 79.9375rem) {
#app:has(.nav-main__toggle[aria-expanded="true"]) { height: calc(100vh - var(--hgtHeader)); } #app:has(.nav-main__toggle[aria-expanded="true"]) { height: calc(100vh - var(--hgtHeader)); }
.nav-main__toggle { .nav-main__toggle {
@@ -81,7 +81,7 @@
} }
/* Desktop */ /* Desktop */
@media screen and (min-width: 62.5rem) { @media screen and (min-width: 80rem) {
.nav-main__toggle { .nav-main__toggle {
@apply hidden; @apply hidden;
} }
+12 -5
View File
@@ -11,7 +11,7 @@
*/ */
/* desktop */ /* desktop */
@media screen and (min-width: 62.5rem) { @media screen and (min-width: 80rem) {
.nav-main .menu-vdi { .nav-main .menu-vdi {
@apply flex items-center justify-end p-0 m-0 h-full; @apply flex items-center justify-end p-0 m-0 h-full;
@@ -31,6 +31,7 @@
/* Hover state: triggered by li so it persists when moving into the submenu. /* Hover state: triggered by li so it persists when moving into the submenu.
Also triggered when a submenu is open (e.g. via keyboard/click). */ Also triggered when a submenu is open (e.g. via keyboard/click). */
&:hover, &:hover,
&:has(a[aria-current="true"]),
&:has(.menu-vdi__toggle[aria-expanded="true"]) { &:has(.menu-vdi__toggle[aria-expanded="true"]) {
>a, >a,
>.menu-vdi__toggle { >.menu-vdi__toggle {
@@ -50,17 +51,23 @@
>a { >a {
@apply ml-2 mr-0 my-0; @apply ml-2 mr-0 my-0;
height: auto;
background: linear-gradient(93.03deg, #F26B53 0%, #D24D32 100%); background: linear-gradient(93.03deg, #F26B53 0%, #D24D32 100%);
border: 3px solid transparent;
border-radius: 1.25rem 0.25rem; border-radius: 1.25rem 0.25rem;
height: auto;
padding: .5rem 2rem; padding: .5rem 2rem;
&:hover {
border: 3px solid #D24D32;
}
} }
} }
} }
} }
} }
@media screen and (min-width: 62.5rem) { @media screen and (min-width: 80rem) {
.menu-vdi { .menu-vdi {
.menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; } .menu-vdi__toggle { @apply flex items-center gap-2 hover:cursor-pointer; }
@@ -68,9 +75,9 @@
.menu-vdi__submenu { .menu-vdi__submenu {
top: calc(100% + 1rem); top: calc(100% + 1rem);
z-index: 100; z-index: 10000;
@apply bg-cwc-blue-02 shadow-lg left-0 w-64 flex-col rounded-b-lg ml-0; @apply bg-cwc-blue-02 shadow-lg left-0 w-84 flex-col rounded-b-lg ml-0;
>li { >li {
@apply w-full border-b border-[#D24D32]; @apply w-full border-b border-[#D24D32];
+37 -16
View File
@@ -14,39 +14,60 @@
} }
.menu-vdi { .menu-vdi {
@apply flex-col bg-white w-[95%] right-0 z-10 py-6; @apply relative right-0 py-8 flex flex-col;
@apply absolute hidden; /* Hidden by default */ @apply absolute hidden; /* Hidden by default */
top: var(--hgtHeader);
min-height: calc(100vh - var(--hgtHeader));
.menu-vdi__submenu { /* Allow the menu itself to scroll when content overflows the viewport */
@apply py-2 px-7 flex-col; overflow-y: auto;
overscroll-behavior: contain;
background: var(--color-cwc-blue-02);
color: var(--color-white);
/* Cap the menu height to the visible viewport area so overflowing items scroll inside the menu instead of letting the menu grow taller than the screen. */
height: calc(100vh - var(--hgtHeader));
top: var(--hgtHeader);
width: 95%;
z-index: 100;
&, ul.menu-vdi {
list-style: none;
padding-left: 0;
margin: 0;
} }
.menu-vdi__submenu { @apply py-2 px-7 flex-col; }
.menu-vdi__item { .menu-vdi__item {
a, list-style: none;
button {
a, button {
/* text */ /* text */
@apply font-bold text-20px text-black hover:text-light no-underline leading-snug; @apply font-bold text-20px text-white hover:underline leading-snug;
/* spacing & display */ /* spacing & display */
@apply block w-full p-4; @apply block w-full p-4;
/* interaction */ /* interaction */
@apply focus-visible:bg-secondary-200 hover:bg-secondary-200; @apply focus-visible:bg-secondary-200 hover:bg-secondary-200;
} }
a { a { @apply block w-full; }
@apply block w-full;
}
button { button { @apply flex w-full justify-between; }
@apply flex w-full justify-between;
}
} }
} }
/* Show menu when toggle button is expanded */ /* Show menu when toggle button is expanded */
.nav-main__toggle[aria-expanded="true"] ~ .menu-vdi:not(.menu-vdi--sliding) { .nav-main__toggle[aria-expanded="true"] ~ .menu-vdi:not(.menu-vdi--sliding) { @apply !flex; /* Use !important to override hidden */ }
@apply !flex; /* Use !important to override hidden */
} }
/* Lock the page from scrolling while the menu is open so the touch-scroll
gesture only scrolls the menu (or doesn't scroll at all if the menu
fits). Also stop the layout from jumping when the scrollbar disappears. */
html:has(.nav-main__toggle[aria-expanded="true"]) body,
body:has(.nav-main__toggle[aria-expanded="true"]) {
overflow: hidden;
overscroll-behavior: contain;
touch-action: none;
} }
} }
+169 -37
View File
@@ -15,10 +15,27 @@
} }
.menu-vdi--sliding { .menu-vdi--sliding {
@apply relative overflow-hidden bg-white w-[95%] right-0 z-10 py-6; @apply relative overflow-hidden right-0 py-8 flex flex-col;
@apply absolute hidden; /* Hidden by default */ @apply absolute hidden; /* Hidden by default */
z-index: 100;
top: var(--hgtHeader); top: var(--hgtHeader);
min-height: calc(100vh - var(--hgtHeader)); min-height: calc(100vh - var(--hgtHeader));
width: 95%;
background: var(--color-cwc-blue-02);
color: var(--color-white);
/* Suppress default <ul> bullets and indent inherited from base typography.
The <ul> element IS .menu-vdi--sliding, so we use & to target the parent selector. */
&,
ul.menu-vdi {
list-style: none;
padding-left: 0;
margin: 0;
}
.menu-vdi__item {
list-style: none;
}
/* Container for all navigation levels */ /* Container for all navigation levels */
.menu-vdi__viewport { .menu-vdi__viewport {
@@ -34,7 +51,7 @@
/* Back button for secondary levels */ /* Back button for secondary levels */
.menu-vdi__back { .menu-vdi__back {
@apply flex items-center gap-2 p-4 border-b border-gray-200 font-bold text-black hover:bg-secondary-200 focus-visible:bg-secondary-200 cursor-pointer; @apply flex items-center gap-2 p-4 border-b border-gray-200 font-bold text-white hover:bg-white/10 focus-visible:bg-white/10 cursor-pointer;
svg { svg {
@apply w-5 h-5; @apply w-5 h-5;
@@ -43,36 +60,102 @@
/* Level indicator for context */ /* Level indicator for context */
.menu-vdi__level-title { .menu-vdi__level-title {
@apply p-4 border-b border-gray-200 font-bold text-lg text-center bg-gray-50; @apply p-4 border-b border-white/20 font-bold text-lg text-center text-white bg-white/10;
} }
/* Navigation items in sliding mode */ /* Top-level menu items */
.menu-vdi__item { .menu-vdi__level--main > .menu-vdi__level-items > .menu-vdi__item {
list-style: none;
a, a,
button { button {
/* text */ /* text */
@apply font-bold text-20px text-black hover:text-light no-underline leading-snug; color: var(--color-white) !important;
font-weight: 700;
font-size: 1.375rem;
line-height: 1.375;
text-decoration: none;
/* spacing & display */ /* spacing & display */
@apply block w-full p-4; display: flex;
align-items: center;
width: 100%;
padding: 1.25rem 1.5rem;
/* interaction */ /* interaction */
@apply focus-visible:bg-secondary-200 hover:bg-secondary-200; background: transparent;
}
a:hover,
a:focus-visible,
button:hover,
button:focus-visible {
background: rgba(255, 255, 255, 0.1);
} }
a { a {
@apply block w-full; justify-content: flex-start;
} }
button { button {
@apply flex w-full justify-between items-center; justify-content: space-between;
}
/* Arrow indicator for items with children */ /* Parent items keep their orange chevron visible */
svg { button svg {
@apply w-5 h-5; fill: none;
stroke: var(--color-secondary);
stroke-width: 2.5;
width: 0.875rem;
height: 0.875rem;
transition: transform 0.2s ease;
}
/* Rotate the chevron when the parent's submenu is expanded */
button[aria-expanded="true"] svg {
transform: rotate(180deg);
}
}
/* Child items: orange bottom border only, no chevron, no rounded corners */
.menu-vdi__submenu {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
.menu-vdi__item--child {
font-weight: 700;
line-height: 1.375;
border-bottom: 2px solid var(--color-secondary);
border-radius: 0;
background: transparent;
list-style: none;
span {
display: block;
width: 100%;
color: var(--color-white);
font-size: 1rem;
padding: 0.75rem 1.5rem;
}
a {
display: block;
width: 100%;
color: var(--color-white) !important;
font-size: 1rem;
font-weight: 700;
padding: 0.75rem 1.5rem;
text-decoration: none;
}
&:hover,
&:focus-visible {
background: rgba(255, 255, 255, 0.1);
} }
} }
} }
/* Hide submenu toggles in sliding mode as they become navigation buttons */ /* Hide traditional submenus in sliding mode as their parent items become navigation buttons */
.menu-vdi__toggle { .menu-vdi__toggle {
/* Override the accordion toggle behavior */ /* Override the accordion toggle behavior */
&[aria-expanded="true"] { &[aria-expanded="true"] {
@@ -86,40 +169,80 @@
} }
} }
/* Hide traditional submenus in sliding mode */
.menu-vdi__submenu {
@apply hidden;
}
/* Ensure menu items in sliding viewport have proper styling */ /* Ensure menu items in sliding viewport have proper styling */
.menu-vdi__level-items { .menu-vdi__level-items {
@apply flex-col; display: flex;
flex-direction: column;
.menu-vdi__item { gap: 0;
@apply w-full; list-style: none;
padding: 0;
a { margin: 0;
@apply p-4 block w-full no-underline;
}
span {
@apply p-4 block w-full font-bold;
} }
} }
/* Style nested items that were converted from submenus */ /* Footer area: Contact button + social icons below the menu. The footer is a
.menu-vdi__nested-items { sibling of .menu-vdi--sliding inside .nav-main, so it cannot be a descendant
@apply block; selector of the menu. */
.menu-vdi__footer {
display: none;
}
.menu-vdi__item { .nav-main__toggle[aria-expanded="true"] ~ .menu-vdi__footer {
@apply w-full; display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 2rem 1.5rem 1.5rem;
inset: auto 2.5% 0;
position: fixed;
max-width: 95%;
}
a { .menu-vdi__contact-button {
@apply p-4 pl-8 block w-full no-underline; /* Add indent for nested items */ display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
text-align: center;
text-decoration: none;
background: var(--color-secondary);
border-radius: 1.25rem 0.25rem;
color: var(--color-white);
padding: 0.75rem 2.5rem;
width: 100%;
max-width: 20rem;
} }
.menu-vdi__contact-button:hover,
.menu-vdi__contact-button:focus-visible {
filter: brightness(1.1);
} }
.menu-vdi__social {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
} }
.menu-vdi__social-icon {
align-items: center !important;
display: inline-flex !important;
height: 2.75rem !important;
justify-content: center !important;
padding: 0 !important;
width: 2.75rem !important;
} }
.menu-vdi__social-icon svg {
fill: var(--color-white);
height: 1.5rem;
width: 1.5rem;
}
.menu-vdi__social-icon:hover,
.menu-vdi__social-icon:focus-visible {
opacity: 0.8;
} }
/* Show sliding menu when toggle button is expanded and menu has sliding class */ /* Show sliding menu when toggle button is expanded and menu has sliding class */
@@ -128,3 +251,12 @@
} }
} }
} }
/* The mobile menu footer (Contact button + social icons) only renders inside
the open mobile menu. Hide it on desktop where it would otherwise leak into
the DOM and trigger accessibility warnings. */
@media screen and (min-width: 62.5rem) {
.menu-vdi__footer {
display: none !important;
}
}
+189
View File
@@ -0,0 +1,189 @@
import { expect, test } from "@playwright/test";
/**
* Regression coverage for the four mobile-blog layout bugs:
*
* 1. Hero text too close to the header.
* 2. Gap between title and intro too big (80px instead of ~24px).
* 3. Intro runs to the viewport edge on the left.
* 4. Post card runs to the viewport edge on the right.
*
* Tests run against the local Herd site where the rebuilt theme.css is
* served. The live deployment (solowebdesigns.net) will pick up the
* rebuilt CSS when the user deploys.
*/
const blogUrl = "http://community-works-collaborative.test/blog/";
test.describe("blog page mobile layout", () => {
test.use({ viewport: { width: 402, height: 874 } });
test("hero has top padding so it isn't glued to the header", async ({
page,
}) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const data = await page.evaluate(() => {
const header = document.querySelector(".site-header");
const hero = document.querySelector(".page-hero");
const heading = document.querySelector(".page-hero__heading");
const cs = hero ? getComputedStyle(hero) : null;
return {
headerBottom: header ? Math.round(header.getBoundingClientRect().bottom) : null,
heroTop: hero ? Math.round(hero.getBoundingClientRect().top) : null,
heroPaddingTop: cs ? cs.paddingTop : null,
headingTop: heading ? Math.round(heading.getBoundingClientRect().top) : null,
};
});
// The hero must have some top padding (>= 8px) on mobile.
const pt = parseFloat(data.heroPaddingTop) || 0;
expect(
pt,
`hero paddingTop should be >= 8px on mobile, got ${pt}`,
).toBeGreaterThanOrEqual(8);
// The heading shouldn't be touching the bottom of the header.
const gap = data.headingTop - data.headerBottom;
expect(
gap,
`heading should sit at least 8px below the header, got ${gap}px`,
).toBeGreaterThanOrEqual(8);
});
test("title-to-intro gap is ~24px (not 80px) on mobile", async ({
page,
}) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const gap = await page.evaluate(() => {
const heading = document.querySelector(".page-hero__heading");
const intro = document.querySelector(".page-hero__intro");
if (!heading || !intro) return null;
const hRect = heading.getBoundingClientRect();
const iRect = intro.getBoundingClientRect();
return Math.round(iRect.top - hRect.bottom);
});
expect(gap, `title-to-intro gap should be ~24px, got ${gap}px`).toBeLessThanOrEqual(40);
});
test("hero intro is padded from the left edge", async ({ page }) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const data = await page.evaluate(() => {
const intro = document.querySelector(".page-hero__intro");
const heading = document.querySelector(".page-hero__heading");
if (!intro || !heading) return null;
return {
introX: Math.round(intro.getBoundingClientRect().x),
headingX: Math.round(heading.getBoundingClientRect().x),
winW: window.innerWidth,
};
});
// The intro should have at least 16px of left padding from the viewport edge.
expect(
data.introX,
`intro x=${data.introX} should be >= 16`,
).toBeGreaterThanOrEqual(16);
expect(
data.headingX,
`heading x=${data.headingX} should be >= 16`,
).toBeGreaterThanOrEqual(16);
});
test("intro has bottom margin so it doesn't sit on the first card", async ({
page,
}) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const data = await page.evaluate(() => {
const intro = document.querySelector(".page-hero__intro");
const firstCard = document.querySelector(".post-list__post");
if (!intro || !firstCard) return null;
const cs = intro ? getComputedStyle(intro) : null;
const iRect = intro.getBoundingClientRect();
const cRect = firstCard.getBoundingClientRect();
return {
introMarginBottom: cs ? cs.marginBottom : null,
introBottom: Math.round(iRect.bottom),
cardTop: Math.round(cRect.top),
gap: Math.round(cRect.top - iRect.bottom),
};
});
// The intro must have a non-zero bottom margin.
const mb = parseFloat(data.introMarginBottom) || 0;
expect(
mb,
`intro marginBottom should be >= 16px, got ${mb}`,
).toBeGreaterThanOrEqual(16);
// The intro should sit at least 16px above the first card.
expect(
data.gap,
`intro-to-card gap should be >= 16px, got ${data.gap}px`,
).toBeGreaterThanOrEqual(16);
});
test("post card fits within the viewport on mobile", async ({ page }) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const data = await page.evaluate(() => {
const card = document.querySelector(".post-list__post");
if (!card) return null;
const r = card.getBoundingClientRect();
return {
x: Math.round(r.x),
w: Math.round(r.width),
right: Math.round(r.right),
winW: window.innerWidth,
};
});
// The card must not overflow the viewport on either side.
expect(
data.x,
`card x=${data.x} should be >= 0`,
).toBeGreaterThanOrEqual(0);
expect(
data.right,
`card right=${data.right} should be <= ${data.winW}`,
).toBeLessThanOrEqual(data.winW);
});
test("no horizontal overflow on the blog page at 402", async ({
page,
}) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const data = await page.evaluate(() => ({
docW: document.documentElement.scrollWidth,
winW: window.innerWidth,
}));
expect(
data.docW,
`docW=${data.docW} exceeds winW=${data.winW}`,
).toBeLessThanOrEqual(data.winW + 1);
});
});
test.describe("blog page mobile layout (xs 360)", () => {
test.use({ viewport: { width: 360, height: 800 } });
test("post card fits within 360px viewport", async ({ page }) => {
await page.goto(blogUrl, { waitUntil: "networkidle" });
const data = await page.evaluate(() => {
const card = document.querySelector(".post-list__post");
if (!card) return null;
const r = card.getBoundingClientRect();
return { x: Math.round(r.x), w: Math.round(r.width), right: Math.round(r.right), winW: window.innerWidth };
});
expect(data.x).toBeGreaterThanOrEqual(0);
expect(data.right).toBeLessThanOrEqual(data.winW);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test";
const AxeBuilder = require("@axe-core/playwright").default;
const blogUrl =
process.env.TEST_BLOG_URL ||
"http://community-works-collaborative.test/blog/";
test.describe("blog page", () => {
test("desktop: Blog title, post grid, no inner-page hero, sidebar", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(blogUrl);
// No inner-page hero.
expect(await page.locator(".page-hero").count()).toBe(0);
// Page title is "Blog".
const heading = page.locator(".post-list__h1").first();
await expect(heading).toBeVisible();
await expect(heading).toHaveText("Blog");
// Post grid renders at least one card.
const posts = page.locator(".post-list__post");
const postCount = await posts.count();
expect(postCount).toBeGreaterThanOrEqual(1);
// First card: image, title link, byline.
const firstCard = posts.first();
await expect(firstCard.locator(".post-list__img img")).toBeVisible();
await expect(firstCard.locator(".post-list__title")).toBeVisible();
await expect(firstCard.locator(".post-list__byline")).toBeVisible();
// First card has a "Read more" link.
const readMore = firstCard.locator(".post-list__read-more");
await expect(readMore).toBeVisible();
await expect(readMore).toHaveText(/^Read more\s*\S+$/);
await expect(readMore).toHaveAttribute("href", /\/[a-z0-9-]+\/?$/);
// First card does NOT have a categories row or an excerpt.
expect(await firstCard.locator(".post-list__cats").count()).toBe(0);
expect(await firstCard.locator(".post-list__excerpt").count()).toBe(0);
// Sidebar renders.
await expect(page.locator("#secondary, .sidebar, aside").first()).toBeAttached();
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: single-column grid, no horizontal overflow", async ({
page,
}) => {
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(blogUrl);
const heading = page.locator(".post-list__h1").first();
await expect(heading).toBeVisible();
const headingBox = await heading.boundingBox();
const block = page.locator(".post-list").first();
const blockBox = await block.boundingBox();
expect(headingBox).not.toBeNull();
expect(blockBox).not.toBeNull();
expect(headingBox.x + headingBox.width).toBeLessThanOrEqual(402 + 3);
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(402 + 3);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(blogUrl);
const block = page.locator(".post-list").first();
await expect(block).toBeVisible();
const blockBox = await block.boundingBox();
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(320 + 3);
});
test("axe: no violations", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(blogUrl);
const results = await new AxeBuilder({ page })
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
expect(results.violations).toEqual([]);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from "@playwright/test";
const AxeBuilder = require("@axe-core/playwright").default;
const contactUrl =
process.env.TEST_CONTACT_URL ||
"http://community-works-collaborative.test/contact/";
test.describe("contact page", () => {
test("desktop: two-column layout with form area and map placeholder", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(contactUrl);
// No hero on the contact page.
expect(await page.locator(".page-hero").count()).toBe(0);
// Block renders.
const block = page.locator(".contact-info").first();
await expect(block).toBeVisible();
// Left column has heading, items, and form area.
const heading = page.locator(".contact-info__heading").first();
await expect(heading).toBeVisible();
await expect(heading).toHaveText(/contact/i);
const items = page.locator(".contact-info__item");
const itemsCount = await items.count();
expect(itemsCount).toBeGreaterThanOrEqual(1);
const formArea = page.locator(".contact-info__form").first();
await expect(formArea).toBeAttached();
// Right column has the Leaflet placeholder.
const map = page.locator(".contact-info__map").first();
await expect(map).toBeAttached();
await expect(map).toHaveId("contact-map");
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: single column, map drops below form, no horizontal overflow", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(contactUrl);
const details = page.locator(".contact-info__details").first();
const map = page.locator(".contact-info__map").first();
await expect(details).toBeVisible();
await expect(map).toBeAttached();
const detailsBox = await details.boundingBox();
const mapBox = await map.boundingBox();
expect(detailsBox).not.toBeNull();
expect(mapBox).not.toBeNull();
expect(mapBox.x).toBeGreaterThanOrEqual(detailsBox.x - 1);
expect(mapBox.x + mapBox.width).toBeLessThanOrEqual(402 + 3);
expect(pageErrors).toEqual([]);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(contactUrl);
const block = page.locator(".contact-info").first();
await expect(block).toBeVisible();
const blockBox = await block.boundingBox();
expect(blockBox.x + blockBox.width).toBeLessThanOrEqual(320 + 3);
});
test("axe: no violations", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(contactUrl);
const results = await new AxeBuilder({ page })
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze();
await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(results, null, 2),
contentType: "application/json",
});
expect(results.violations).toEqual([]);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test";
/**
* Regression coverage for the 1024-1200px header-nav overflow.
*
* Bug: at viewport widths between 1000 and ~1200, the inline main-navigation
* was wider than the header container, pushing doc.scrollWidth past
* window.innerWidth and producing a horizontal scrollbar.
*
* Fix: the desktop-nav breakpoint moved from 62.5rem (1000px) to 80rem
* (1280px), so the hamburger now covers 1000-1279 and the inline nav
* appears at 1280+.
*
* These tests assert:
* - no horizontal overflow at any of the previously-broken widths
* - the hamburger toggle is visible below 1280
* - the inline nav is visible at 1280+
*
* Tests run against the local Herd site where the rebuilt theme.css is
* served. The live deployment (solowebdesigns.net) will pick up the
* rebuilt CSS when the user deploys.
*/
const site = "http://community-works-collaborative.test/";
const widths = [1000, 1023, 1024, 1100, 1200, 1279, 1280, 1366, 1440];
for (const w of widths) {
test(`no horizontal overflow at width ${w}`, async ({ browser }) => {
const ctx = await browser.newContext({
viewport: { width: w, height: 800 },
});
const page = await ctx.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const { docW, winW } = await page.evaluate(() => ({
docW: document.documentElement.scrollWidth,
winW: window.innerWidth,
}));
expect(
docW,
`viewport ${w}: docW=${docW} exceeds winW=${winW} by ${docW - winW}px`,
).toBeLessThanOrEqual(winW + 1);
await ctx.close();
});
}
for (const w of [1000, 1023, 1024, 1100, 1200, 1279]) {
test(`hamburger visible below 1280 at width ${w}`, async ({ browser }) => {
const ctx = await browser.newContext({
viewport: { width: w, height: 800 },
});
const page = await ctx.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const toggleVisible = await page.evaluate(() => {
const t = document.querySelector(".nav-main__toggle");
if (!t) return false;
const cs = getComputedStyle(t);
const r = t.getBoundingClientRect();
return (
cs.display !== "none" &&
cs.visibility !== "hidden" &&
r.width > 0 &&
r.height > 0
);
});
expect(
toggleVisible,
`width ${w}: hamburger should be visible`,
).toBe(true);
await ctx.close();
});
}
for (const w of [1280, 1366, 1440, 1920]) {
test(`inline nav visible at 1280+ at width ${w}`, async ({ browser }) => {
const ctx = await browser.newContext({
viewport: { width: w, height: 800 },
});
const page = await ctx.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const result = await page.evaluate(() => {
const toggle = document.querySelector(".nav-main__toggle");
const menu = document.querySelector(".nav-main .menu-vdi");
const nav = document.querySelector(".nav-main");
return {
toggleDisplay: toggle ? getComputedStyle(toggle).display : null,
menuDisplay: menu ? getComputedStyle(menu).display : null,
navRight: nav ? Math.round(nav.getBoundingClientRect().right) : null,
docW: document.documentElement.scrollWidth,
winW: window.innerWidth,
};
});
// Hamburger hidden
expect(
result.toggleDisplay,
`width ${w}: hamburger should be hidden at 1280+`,
).toBe("none");
// Inline menu visible (display: flex from the @apply)
expect(
result.menuDisplay,
`width ${w}: inline menu should be visible at 1280+`,
).toBe("flex");
// Nav stays inside the viewport
expect(
result.docW,
`width ${w}: docW=${result.docW} exceeds winW=${result.winW}`,
).toBeLessThanOrEqual(result.winW + 1);
await ctx.close();
});
}
+188
View File
@@ -0,0 +1,188 @@
import { expect, test } from "@playwright/test";
// Default rich fixture: all fields populated (hero_image, hero_vector, intro).
const pageUrl =
process.env.TEST_PAGE_URL ||
"http://community-works-collaborative.test/sample-page/";
// No-intro fixture: `intro` ACF field intentionally empty.
const noIntroUrl =
process.env.TEST_PAGE_URL_NO_INTRO ||
"http://community-works-collaborative.test/inner-page-no-intro/";
// No-asset fixture: `hero_image` and `hero_vector` ACF fields intentionally empty.
const noAssetUrl =
process.env.TEST_PAGE_URL_NO_ASSET ||
"http://community-works-collaborative.test/inner-page-no-asset/";
const getBox = async (locator) => {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return box;
};
const expectNear = (actual, expected, tolerance = 3) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
test.describe("inner page layout", () => {
test("desktop: hero, intro, body render with intro inside hero", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-hero__intro").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
await expect(content).toBeVisible();
// Intro lives INSIDE the hero.
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
const contentBox = await getBox(content);
// Intro is a descendant of the hero (same x, y is between hero top and bottom).
expect(introBox.x).toBeGreaterThanOrEqual(heroBox.x - 1);
expect(introBox.y).toBeGreaterThanOrEqual(heroBox.y - 1);
expect(introBox.y + introBox.height).toBeLessThanOrEqual(
heroBox.y + heroBox.height + 1,
);
// Body renders below the hero.
expect(contentBox.y).toBeGreaterThanOrEqual(heroBox.y + heroBox.height - 1);
// Heading is rendered inside the hero.
await expect(hero.locator("h1").first()).toBeVisible();
// Page renders without JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 402: hero, intro, body order and intro width", async ({
page,
}) => {
const pageErrors = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
await page.setViewportSize({ width: 402, height: 874 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-hero__intro").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
await expect(content).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
const contentBox = await getBox(content);
// Intro inside the hero.
expect(introBox.x).toBeGreaterThanOrEqual(heroBox.x - 1);
expect(introBox.y).toBeGreaterThanOrEqual(heroBox.y - 1);
expect(introBox.y + introBox.height).toBeLessThanOrEqual(
heroBox.y + heroBox.height + 1,
);
// Body below the hero.
expect(contentBox.y).toBeGreaterThanOrEqual(heroBox.y + heroBox.height - 1);
// Intro fits within the mobile viewport.
expect(introBox.x + introBox.width).toBeLessThanOrEqual(402 + 3);
// No JS errors.
expect(pageErrors).toEqual([]);
});
test("mobile 320: nothing clips horizontally", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-hero__intro").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(320 + 3);
expect(introBox.x + introBox.width).toBeLessThanOrEqual(320 + 3);
});
test("mobile 767: layout still holds before the desktop breakpoint", async ({
page,
}) => {
await page.setViewportSize({ width: 767, height: 900 });
await page.goto(pageUrl);
const hero = page.locator(".page-hero").first();
const intro = page.locator(".page-hero__intro").first();
await expect(hero).toBeVisible();
await expect(intro).toBeVisible();
const heroBox = await getBox(hero);
const introBox = await getBox(intro);
expect(heroBox.x + heroBox.width).toBeLessThanOrEqual(767 + 3);
expect(introBox.x + introBox.width).toBeLessThanOrEqual(767 + 3);
});
// Spec line 113: "A page with no `intro` field renders hero + body with no empty intro wrapper."
test("no-intro: hero and body render with no .page-hero__intro wrapper", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(noIntroUrl);
const hero = page.locator(".page-hero").first();
const content = page.locator(".entry-content").first();
await expect(hero).toBeVisible();
await expect(content).toBeVisible();
// No intro wrapper rendered when the intro field is empty.
expect(await page.locator(".page-hero__intro").count()).toBe(0);
// Hero and body still render in the expected order.
const heroBox = await getBox(hero);
const contentBox = await getBox(content);
expect(contentBox.y).toBeGreaterThanOrEqual(heroBox.y + heroBox.height - 1);
// Heading is still inside the hero.
await expect(hero.locator("h1").first()).toBeVisible();
});
// Spec line 114: "A page with no `hero_image` and no `hero_vector` renders the existing dark text-only hero unchanged."
test("no-asset: dark text-only hero renders without image or vector", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(noAssetUrl);
const hero = page.locator(".page-hero").first();
await expect(hero).toBeVisible();
// No media layers when both assets are empty.
expect(await page.locator(".page-hero__media").count()).toBe(0);
expect(await page.locator(".page-hero__vector").count()).toBe(0);
// Heading still renders inside the hero.
await expect(hero.locator("h1").first()).toBeVisible();
// Hero still exposes the dark text-only class hook.
await expect(hero).toHaveClass(/page-hero/);
});
});
+800
View File
@@ -0,0 +1,800 @@
import { expect, test } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
const homepage = "http://community-works-collaborative.test/";
const getBox = async (locator) => {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return box;
};
const expectNear = (actual, expected, tolerance = 3) => {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance);
};
test.use({ viewport: { width: 402, height: 874 } });
test("mobile header preserves its layout and uses the designed hamburger", async ({
page,
}) => {
await page.goto(homepage);
const header = await getBox(page.locator(".site-header"));
const logo = await getBox(page.locator(".site-header__logo"));
const toggle = page.locator(".nav-main__toggle").first();
const toggleBox = await getBox(toggle);
const hamburgerIcon = toggle.locator(".nav-toggle-hamburger svg");
const closeIcon = toggle.locator(".nav-toggle-x svg");
expectNear(header.height, 70, 2);
expectNear(logo.x, 16, 2);
expectNear(logo.width, 116, 2);
expectNear(logo.height, 36, 2);
expectNear(toggleBox.width, 72, 2);
expectNear(toggleBox.height, 45, 2);
await expect(toggle).toHaveCSS(
"background-color",
"oklch(0.6353 0.1751 29.61)",
);
await expect(toggle).toHaveCSS("border-radius", "22.4px 5.6px");
await expect(hamburgerIcon).toHaveCSS("fill", "oklch(1 0 0)");
await expect(closeIcon).toHaveCSS("stroke", "oklch(1 0 0)");
});
test("mobile hero matches the 402px reference composition", async ({
page,
}) => {
await page.goto(homepage);
const hero = await getBox(page.locator(".homepage-hero"));
const heading = await getBox(page.locator(".homepage-hero .intro h1"));
const vector = await getBox(
page.locator(".homepage-hero .heroVector .vector"),
);
const buttons = page.locator(".homepage-hero .reset .button");
const firstButton = await getBox(buttons.nth(0));
const secondButton = await getBox(buttons.nth(1));
expectNear(hero.height, 752, 8);
expectNear(heading.x, 32, 3);
expect(heading.width).toBeGreaterThanOrEqual(330);
expectNear(vector.x, -414, 8);
expectNear(firstButton.y, secondButton.y, 2);
expectNear(firstButton.width, 148, 4);
expectNear(secondButton.width, 156, 4);
await expect(page.locator(".homepage-hero__location")).toBeVisible();
});
test("mobile hero CTAs remain fully visible at 320px", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(homepage);
const vector = await getBox(
page.locator(".homepage-hero .heroVector .vector"),
);
const buttons = page.locator(".homepage-hero .reset .button");
const firstButton = await getBox(buttons.nth(0));
const secondButton = await getBox(buttons.nth(1));
expectNear(firstButton.y, secondButton.y, 2);
expect(secondButton.x + secondButton.width).toBeLessThanOrEqual(320);
expect(vector.x + vector.width).toBeGreaterThanOrEqual(316);
});
test("mobile hero vector covers the 767px composition", async ({ page }) => {
await page.setViewportSize({ width: 767, height: 900 });
await page.goto(homepage);
const vector = await getBox(
page.locator(".homepage-hero .heroVector .vector"),
);
expect(vector.x).toBeLessThan(0);
expect(vector.x + vector.width).toBeGreaterThanOrEqual(760);
});
test("mobile pull quote matches the reference scale", async ({ page }) => {
await page.goto(homepage);
const quote = await getBox(page.locator(".pull-quote"));
const text = await getBox(page.locator(".pull-quote__text"));
const desktopVector = page.locator(".pull-quote__vector--bg");
const mobileVector = page.locator(".pull-quote__vector--mobile");
const vector = await getBox(mobileVector);
const bandHeight = await page
.locator(".pull-quote")
.evaluate((element) =>
Number.parseFloat(getComputedStyle(element, "::after").height),
);
expectNear(quote.height, 361, 6);
expectNear(bandHeight, 16, 1);
expectNear(text.x - quote.x, 32, 3);
expect(text.width).toBeGreaterThanOrEqual(330);
expectNear(text.height, 216, 6);
expectNear(
text.y + text.height / 2,
quote.y + (quote.height - bandHeight) / 2,
8,
);
expectNear(vector.x, quote.x, 3);
expectNear(vector.width, quote.width, 3);
expectNear(vector.height, quote.height - bandHeight, 3);
await expect(mobileVector).toBeVisible();
await expect(desktopVector).toBeHidden();
await expect(mobileVector).toHaveCSS("object-fit", "cover");
});
test("mobile pull quote scales proportionally through 767px", async ({
page,
}) => {
await page.setViewportSize({ width: 767, height: 900 });
await page.goto(homepage);
const quote = await getBox(page.locator(".pull-quote"));
const text = await getBox(page.locator(".pull-quote__text"));
expectNear(quote.height, 767 * 0.898, 6);
expect(text.y).toBeGreaterThanOrEqual(quote.y);
expect(text.y + text.height).toBeLessThanOrEqual(quote.y + quote.height);
expectNear(text.y + text.height / 2, quote.y + (quote.height - 16) / 2, 8);
});
test("mobile pull quote keeps text above the band at 320px", async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(homepage);
const quote = await getBox(page.locator(".pull-quote"));
const content = await getBox(page.locator(".pull-quote__content"));
const text = await getBox(page.locator(".pull-quote__text"));
expectNear(quote.height, 320 * 0.898, 6);
expectNear(content.height, quote.height - 16, 3);
expect(text.y - content.y).toBeGreaterThanOrEqual(12);
expect(
content.y + content.height - (text.y + text.height),
).toBeGreaterThanOrEqual(12);
});
test("mobile pull quote grows when its text needs more room", async ({
page,
}) => {
await page.goto(homepage);
const quoteLocator = page.locator(".pull-quote");
const initialQuote = await getBox(quoteLocator);
await page.locator(".pull-quote__text p").evaluate((paragraph) => {
paragraph.textContent = `${paragraph.textContent} ${paragraph.textContent} ${paragraph.textContent}`;
});
const quote = await getBox(quoteLocator);
const content = await getBox(page.locator(".pull-quote__content"));
const text = await getBox(page.locator(".pull-quote__text"));
const vector = await getBox(page.locator(".pull-quote__vector--mobile"));
expect(quote.height).toBeGreaterThan(initialQuote.height);
expectNear(content.height, quote.height - 16, 3);
expectNear(vector.height, quote.height - 16, 3);
expect(text.y).toBeGreaterThanOrEqual(content.y);
expect(text.y + text.height).toBeLessThanOrEqual(
content.y + content.height,
);
});
test("pull quote restores the configured artwork at 768px", async ({
page,
}) => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(homepage);
const desktopVector = page.locator(".pull-quote__vector--bg");
const mobileVector = page.locator(".pull-quote__vector--mobile");
const quote = await getBox(page.locator(".pull-quote"));
const vector = await getBox(desktopVector);
const borderWidth = await page
.locator(".pull-quote")
.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).borderBottomWidth),
);
await expect(desktopVector).toBeVisible();
await expect(mobileVector).toBeHidden();
expectNear(vector.x, quote.x, 3);
expectNear(vector.width, quote.width, 3);
expectNear(vector.height, quote.height - borderWidth, 3);
await expect(desktopVector).toHaveCSS("object-fit", "cover");
});
test("mobile pull quote matches the supplied visual reference", async ({
page,
}) => {
await page.goto(homepage);
await expect(page.locator(".pull-quote")).toHaveScreenshot(
"pullquote-reference.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.071,
threshold: 0.05,
},
);
});
test("mobile pull quote linework render remains stable", async ({ page }) => {
await page.goto(homepage);
await page.locator(".pull-quote__content").evaluate((content) => {
content.style.visibility = "hidden";
});
await expect(page.locator(".pull-quote__vector--mobile")).toHaveScreenshot(
"pullquote-linework-render.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.001,
threshold: 0.02,
},
);
});
test("mobile media text matches the 402px reference composition", async ({
page,
}) => {
await page.goto(homepage);
const blockLocator = page.locator(
".wp-block-acf-media-text.media-cols.has-two-ctas",
);
const block = await getBox(blockLocator);
const content = await getBox(
blockLocator.locator(":scope > .content-wrapper"),
);
const media = await getBox(blockLocator.locator(":scope > .media-wrapper"));
const buttons = blockLocator.locator(".reset .button");
const services = await getBox(buttons.nth(0));
const about = await getBox(buttons.nth(1));
const contact = await getBox(page.locator(".contact-block.home-contact"));
expect(content.y).toBeLessThan(media.y);
expectNear(content.y - block.y, 80, 4);
expectNear(services.y, about.y, 2);
expectNear(services.width, 148, 4);
expectNear(about.width, 124, 4);
expectNear(media.x, 32, 3);
expectNear(media.y - block.y, 458, 4);
expectNear(media.width, 338, 3);
expectNear(media.height, 244, 4);
expectNear(contact.y - (media.y + media.height), 232, 5);
});
test("media text keeps content first in the DOM and marks its CTA pair", async ({
page,
}) => {
await page.goto(homepage);
const block = page.locator(".wp-block-acf-media-text.media-cols");
const childOrder = await block
.locator(":scope > div")
.evaluateAll((children) =>
children.map((child) =>
child.classList.contains("content-wrapper")
? "content"
: "media",
),
);
expect(childOrder).toEqual(["content", "media"]);
await expect(block).toHaveClass(/\bhas-two-ctas\b/);
});
test("mobile media text remains contained at responsive endpoints", async ({
page,
}) => {
for (const width of [320, 767]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(homepage);
const block = page.locator(
".wp-block-acf-media-text.media-cols.has-two-ctas",
);
const content = await getBox(
block.locator(":scope > .content-wrapper"),
);
const media = await getBox(block.locator(":scope > .media-wrapper"));
const buttons = block.locator(".reset .button");
const services = await getBox(buttons.nth(0));
const about = await getBox(buttons.nth(1));
expect(content.y).toBeLessThan(media.y);
expectNear(services.y, about.y, 2);
expect(media.x).toBeGreaterThanOrEqual(0);
expect(media.x + media.width).toBeLessThanOrEqual(width);
expect(services.x).toBeGreaterThanOrEqual(content.x);
expect(about.x + about.width).toBeLessThanOrEqual(
content.x + content.width,
);
}
});
test("media text keeps its configured ordering at 768px", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(homepage);
const block = page.locator(".wp-block-acf-media-text.media-cols");
const contentLocator = block.locator(":scope > .content-wrapper");
const mediaLocator = block.locator(":scope > .media-wrapper");
const content = await getBox(contentLocator);
const media = await getBox(mediaLocator);
expect(media.y).toBeLessThan(content.y);
expectNear(media.x, 70, 3);
expectNear(media.width, 627, 3);
await expect(block).toHaveCSS("padding-top", "0px");
await expect(contentLocator.locator(".font-light")).toHaveCSS(
"font-size",
"18px",
);
await expect(contentLocator.locator(".reset")).toHaveCSS("display", "flex");
});
test("mobile contact card matches the 402px reference geometry", async ({
page,
}) => {
await page.goto(homepage);
const cardLocator = page.locator(".contact-block.home-contact");
const card = await getBox(cardLocator);
const heading = await getBox(cardLocator.locator(".contact-block__text"));
const cta = await getBox(cardLocator.locator(".contact-block__cta"));
const linework = await getBox(
cardLocator.locator(".contact-block__linework--mobile"),
);
await cardLocator
.locator(".contact-block__linework--mobile")
.evaluate(
(image) =>
/** @type {HTMLImageElement} */ (image).complete ||
new Promise((resolve) => {
/** @type {HTMLImageElement} */ (image).addEventListener(
"load",
() => resolve(undefined),
{ once: true },
);
}),
);
const backgroundImage = cardLocator.locator(".contact-block__bg-image");
const bandHeight = await cardLocator.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).borderBottomWidth),
);
expectNear(card.width, 343, 3);
expectNear(card.height, 502, 5);
expectNear(card.x, (402 - card.width) / 2, 2);
expectNear(bandHeight, 16, 1);
expectNear(heading.x - card.x, 33, 3);
expectNear(heading.y - card.y, 31, 4);
expectNear(cta.x - card.x, 33, 3);
expectNear(cta.y - card.y, 219, 5);
expectNear(cta.width, 173, 4);
expectNear(linework.y - card.y, 299, 5);
expectNear(linework.height, 188, 5);
expectNear(linework.width, card.width, 3);
await expect(backgroundImage).toHaveCSS("object-fit", "cover");
const overlay = await cardLocator
.locator(".contact-block__media")
.evaluate(
(element) => getComputedStyle(element, "::after").backgroundImage,
);
expect(overlay).toContain("linear-gradient");
await expect(cardLocator).toHaveCSS("border-radius", "16px 16px 0px 0px");
const reusableStyles = await cardLocator.evaluate((element) => {
const clone = element.cloneNode(true);
clone.classList.remove("home-contact");
document.body.append(clone);
const media = clone.querySelector(".contact-block__media");
const content = clone.querySelector(".contact-block__content");
const linework = clone.querySelector(
".contact-block__linework--mobile",
);
const lineworkBox = linework.getBoundingClientRect();
const styles = {
borderRadius: getComputedStyle(clone).borderRadius,
contentPaddingLeft: getComputedStyle(content).paddingLeft,
lineworkRatio: lineworkBox.width / lineworkBox.height,
maskImage: getComputedStyle(media).maskImage,
overlay: getComputedStyle(media, "::after").backgroundImage,
};
clone.remove();
return styles;
});
expect(reusableStyles.borderRadius).toBe("12px 12px 0px 0px");
expect(reusableStyles.contentPaddingLeft).toBe("24px");
expectNear(reusableStyles.lineworkRatio, 342 / 187, 0.03);
expect(reusableStyles.maskImage).toContain("linear-gradient");
expect(reusableStyles.overlay).toBe("none");
});
test("mobile contact card scales proportionally and remains contained", async ({
page,
}) => {
for (const width of [320, 767]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(homepage);
const cardLocator = page.locator(".contact-block.home-contact");
const card = await getBox(cardLocator);
const heading = await getBox(
cardLocator.locator(".contact-block__text"),
);
expect(card.x).toBeGreaterThanOrEqual(0);
expect(card.x + card.width).toBeLessThanOrEqual(width);
expectNear(card.width / width, 343 / 402, 0.015);
expectNear(card.height / card.width, 502 / 343, 0.025);
expectNear((heading.x - card.x) / card.width, 33 / 343, 0.01);
}
});
test("contact card restores the desktop layout at 768px", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(homepage);
const card = page.locator(".contact-block.home-contact");
await expect(card).toHaveCSS("position", "absolute");
await expect(card).toHaveCSS("border-bottom-width", "16px");
await expect(card).toHaveCSS("border-radius", "24px 24px 0px 0px");
});
test("mobile contact card render remains stable", async ({ page }) => {
await page.goto(homepage);
await expect(page.locator(".contact-block.home-contact")).toHaveScreenshot(
"contact-card-render.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.025,
threshold: 0.08,
},
);
});
test("mobile Our Work contains both reference images", async ({ page }) => {
await page.goto(homepage);
const block = await getBox(page.locator(".our-work"));
const left = await getBox(page.locator(".our-work__media--left"));
const bottom = await getBox(page.locator(".our-work__media--bottom"));
expectNear(block.height, 888, 12);
expectNear(left.width, 189, 5);
expect(bottom.width).toBeGreaterThanOrEqual(335);
expect(bottom.x).toBeGreaterThanOrEqual(60);
expect(bottom.x + bottom.width).toBeLessThanOrEqual(402);
});
test("mobile Our Work uses its dedicated linework composition", async ({
page,
}) => {
await page.goto(homepage);
const mobileVector = page.locator(".our-work__vector-mobile");
const mobileImage = mobileVector.locator("img");
await expect(mobileVector).toBeVisible();
await expect(mobileImage).toBeVisible();
await expect(page.locator(".our-work__vector-desktop")).toBeHidden();
await mobileImage.evaluate(
(image) =>
/** @type {HTMLImageElement} */ (image).complete ||
new Promise((resolve) => {
/** @type {HTMLImageElement} */ (image).addEventListener(
"load",
() => resolve(undefined),
{ once: true },
);
}),
);
expect(
await mobileImage.evaluate(
(image) => /** @type {HTMLImageElement} */ (image).currentSrc,
),
).toContain("mobile-work-vector.svg");
});
test("mobile Our Work reserves reference height only for both images", async ({
page,
}) => {
await page.goto(homepage);
const minHeights = await page.locator(".our-work").evaluate((block) => {
const measureVariant = (removedClasses) => {
const clone = block.cloneNode(true);
clone.classList.remove(...removedClasses);
if (removedClasses.includes("has-left-image")) {
clone.querySelector(".our-work__media--left")?.remove();
}
if (removedClasses.includes("has-bottom-image")) {
clone.querySelector(".our-work__media--bottom")?.remove();
}
document.body.append(clone);
const measurements = {
height: clone.getBoundingClientRect().height,
minHeight: Number.parseFloat(getComputedStyle(clone).minHeight),
};
clone.remove();
return measurements;
};
return {
bothImages: {
height: block.getBoundingClientRect().height,
minHeight: Number.parseFloat(getComputedStyle(block).minHeight),
},
bottomOnly: measureVariant(["has-left-image"]),
leftOnly: measureVariant(["has-bottom-image"]),
noImages: measureVariant(["has-left-image", "has-bottom-image"]),
};
});
expectNear(minHeights.bothImages.minHeight, 888, 3);
expectNear(minHeights.bothImages.height, 888, 12);
for (const variant of [
minHeights.bottomOnly,
minHeights.leftOnly,
minHeights.noImages,
]) {
expect(variant.minHeight).toBe(0);
expect(variant.height).toBeLessThan(minHeights.bothImages.height);
}
});
test("mobile Our Work linework render remains stable", async ({ page }) => {
await page.goto(homepage);
await expect(page.locator(".our-work")).toHaveScreenshot(
"our-work-linework-render.png",
{
animations: "disabled",
maxDiffPixelRatio: 0.025,
threshold: 0.08,
},
);
});
test("mobile Our Work follows the reference content and overlap order", async ({
page,
}) => {
await page.goto(homepage);
const block = await getBox(page.locator(".our-work"));
const content = await getBox(page.locator(".our-work__content"));
const left = await getBox(page.locator(".our-work__media--left"));
const bottom = await getBox(page.locator(".our-work__media--bottom"));
const paragraph = page.locator(".our-work__content p");
const paragraphBox = await getBox(paragraph);
const buttons = page.locator(".our-work .button");
const primary = await getBox(buttons.nth(0));
const secondary = await getBox(buttons.nth(1));
expectNear(content.x - block.x, 32, 3);
expectNear(content.y - block.y, 68, 8);
expectNear(paragraphBox.y - block.y, 146, 4);
expect(content.y).toBeLessThan(left.y);
expectNear(left.y - block.y, 428, 12);
expectNear(bottom.y - block.y, 595, 12);
expectNear(primary.y, secondary.y, 2);
expectNear(primary.y - block.y, 321, 4);
expectNear(primary.height, 44, 2);
expectNear(secondary.height, 44, 2);
await expect(paragraph).toHaveCSS("text-wrap", "wrap");
});
test("mobile Our Work remains contained at responsive endpoints", async ({
page,
}) => {
for (const width of [320, 767]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(homepage);
const block = await getBox(page.locator(".our-work"));
const left = await getBox(page.locator(".our-work__media--left"));
const bottom = await getBox(page.locator(".our-work__media--bottom"));
const buttons = page.locator(".our-work .button");
const primary = await getBox(buttons.nth(0));
const secondary = await getBox(buttons.nth(1));
for (const item of [left, bottom, primary, secondary]) {
expect(item.x).toBeGreaterThanOrEqual(block.x);
expect(item.x + item.width).toBeLessThanOrEqual(
block.x + block.width,
);
}
expectNear(primary.y, secondary.y, 2);
expect(left.y).toBeLessThan(bottom.y);
const mobileImage = page.locator(".our-work__vector-mobile img");
await expect(mobileImage).toBeVisible();
await mobileImage.evaluate(
(image) =>
/** @type {HTMLImageElement} */ (image).complete ||
new Promise((resolve) => {
/** @type {HTMLImageElement} */ (image).addEventListener(
"load",
() => resolve(undefined),
{ once: true },
);
}),
);
}
});
test("Our Work restores its tablet layout at 768px", async ({ page }) => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(homepage);
const block = page.locator(".our-work");
const inner = block.locator(".our-work__inner");
const vector = block.locator(".our-work__vector-desktop img");
await expect(block).toHaveCSS("min-height", "0px");
await expect(block).toHaveCSS("padding-top", "64px");
await expect(inner).toHaveCSS("padding-left", "38.4px");
await expect(vector).toHaveCSS("width", "768px");
await expect(block.locator(".our-work__vector-mobile")).toBeHidden();
// Force lazy-loaded image to start fetching before reading its source.
await expect(vector).toBeVisible();
await vector.evaluate(
(image) =>
/** @type {HTMLImageElement} */ (image).complete ||
new Promise((resolve) => {
/** @type {HTMLImageElement} */ (image).addEventListener(
"load",
() => resolve(undefined),
{ once: true },
);
}),
);
expect(
await vector.evaluate(
(image) => /** @type {HTMLImageElement} */ (image).currentSrc,
),
).toContain("our-work-vector.svg");
});
test("mobile recent posts exposes previous and next controls", async ({
page,
}) => {
await page.goto(homepage);
await expect(
page.getByRole("button", { name: "Previous article" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Next article" }),
).toBeVisible();
});
test("mobile recent posts advances to the next article", async ({ page }) => {
await page.goto(homepage);
const cards = page.locator(".recent-posts__card");
await expect(cards.nth(0)).toBeVisible();
await expect(cards.nth(1)).toBeHidden();
await page.getByRole("button", { name: "Next article" }).click();
await expect(cards.nth(0)).toBeHidden();
await expect(cards.nth(1)).toBeVisible();
});
test("configured footer channels use the prepared SVG icons", async ({
page,
}) => {
await page.goto(homepage);
await expect(
page.locator('.social-links img[src*="social-facebook.svg"]'),
).toBeVisible();
await expect(
page.locator('.social-links img[src*="social-linkedin.svg"]'),
).toBeVisible();
});
test("mobile footer matches the reference hierarchy", async ({ page }) => {
await page.goto(homepage);
const footer = page.locator(".site-footer");
const footerBox = await getBox(footer);
const logo = await getBox(footer.locator(".site-footer__logo-image"));
const backToTop = await getBox(footer.locator("#backToTopBtn"));
const nav = await getBox(footer.locator("#footer-nav"));
const firstNavLink = await getBox(
footer.locator(".site-footer__nav-link").first(),
);
const contact = await getBox(footer.locator(".contact"));
const firstContactItem = await getBox(
footer.locator(".contact__item").first(),
);
expectNear(footerBox.height, 673, 12);
expectNear(logo.y, backToTop.y, 8);
expect(nav.y).toBeGreaterThan(logo.y + logo.height);
expect(contact.y).toBeGreaterThan(nav.y + nav.height);
expectNear(firstNavLink.x, logo.x, 2);
expectNear(firstNavLink.x, firstContactItem.x, 2);
});
test("mobile footer scales down and remains contained", async ({ page }) => {
for (const width of [320, 767]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(homepage);
const footer = page.locator(".site-footer");
const footerBox = await getBox(footer);
const logo = await getBox(footer.locator(".site-footer__logo-image"));
const backToTop = await getBox(footer.locator("#backToTopBtn"));
const nav = await getBox(footer.locator("#footer-nav"));
const contact = await getBox(footer.locator(".contact"));
const social = await getBox(footer.locator(".social-links"));
const copyright = await getBox(footer.locator(".copyright"));
const expectedHeight = Math.min(width, 402) * (673 / 402);
expectNear(footerBox.height, expectedHeight, 12);
expectNear(logo.y, backToTop.y, 8);
expect(nav.y).toBeGreaterThan(logo.y + logo.height);
expect(contact.y).toBeGreaterThan(nav.y + nav.height);
expect(social.y).toBeGreaterThan(contact.y + contact.height);
expect(copyright.y).toBeGreaterThan(social.y + social.height);
expect(footerBox.x).toBeGreaterThanOrEqual(0);
expect(footerBox.x + footerBox.width).toBeLessThanOrEqual(width);
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBeLessThanOrEqual(width);
}
});
test("footer restores its four-column desktop layout at 768px", async ({
page,
}) => {
await page.setViewportSize({ width: 768, height: 900 });
await page.goto(homepage);
const footer = page.locator(".site-footer");
const outer = footer.locator(":scope > .container");
await expect(footer).toHaveCSS("border-bottom-width", "16px");
await expect(footer).toHaveCSS("padding-top", "64px");
await expect(outer).toHaveCSS("grid-template-columns", /.+ .+ .+ .+/);
await expect(outer.locator(":scope > .text-right")).toHaveCSS(
"display",
"flex",
);
});
test("mobile homepage has no automated accessibility violations", async ({
page,
}) => {
await page.goto(homepage);
const results = await new AxeBuilder({ page })
.withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze();
expect(results.violations).toEqual([]);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+301
View File
@@ -0,0 +1,301 @@
import { expect, test } from "@playwright/test";
const site = "http://community-works-collaborative.test/";
// Viewports to audit (mobile / tablet / desktop / wide)
const viewports = [
{ name: "xs-360", width: 360, height: 800 },
{ name: "sm-402", width: 402, height: 874 },
{ name: "md-768", width: 768, height: 1024 },
{ name: "lg-1024", width: 1024, height: 768 },
{ name: "xl-1280", width: 1280, height: 800 },
{ name: "2xl-1440", width: 1440, height: 900 },
{ name: "3xl-1920", width: 1920, height: 1080 },
];
// Common selectors to audit for overflow / alignment
const selectors = {
header: ".site-header",
logo: ".site-header__logo",
navToggle: ".nav-main__toggle",
main: "main, .site-main, .page-content",
body: "body",
hero: ".homepage-hero, .page-hero",
cards: ".card, .post-card",
sections: "section",
footer: ".site-footer, footer",
grid: ".grid, [class*='grid']",
};
// Check for horizontal overflow at every viewport
for (const vp of viewports) {
test(`no horizontal overflow at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const overflow = await page.evaluate(() => {
const docWidth = document.documentElement.scrollWidth;
const winWidth = window.innerWidth;
const offenders = [];
// Find any element wider than the viewport or that pushes the doc to overflow
if (docWidth > winWidth + 1) {
const all = document.querySelectorAll("*");
for (const el of all) {
const rect = el.getBoundingClientRect();
if (rect.right > winWidth + 1) {
offenders.push({
tag: el.tagName.toLowerCase(),
class: el.className?.toString?.().slice(0, 80),
right: Math.round(rect.right),
width: Math.round(rect.width),
});
if (offenders.length >= 10) break;
}
}
}
return { docWidth, winWidth, offenders };
});
expect(
overflow.docWidth,
`Doc width ${overflow.docWidth} > viewport ${overflow.winWidth}. Offenders: ${JSON.stringify(overflow.offenders)}`,
).toBeLessThanOrEqual(overflow.winWidth + 1);
await context.close();
});
}
// Header alignment per viewport
for (const vp of viewports) {
test(`header alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const header = page.locator(selectors.header).first();
await expect(header).toBeVisible();
const box = await header.boundingBox();
expect(box).not.toBeNull();
// Header must start at the left edge (allow 1px sub-pixel)
expect(Math.abs(box.x)).toBeLessThanOrEqual(1);
expect(box.width).toBeGreaterThan(vp.width * 0.8);
await context.close();
});
}
// Main content padding/alignment per viewport
for (const vp of viewports) {
test(`main content padding at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const main = page.locator(selectors.main).first();
const mainCount = await page.locator(selectors.main).count();
if (mainCount > 0) {
const box = await main.boundingBox();
expect(box).not.toBeNull();
// Check for symmetric horizontal padding (allow 1px)
const leftPad = box.x;
const rightPad = vp.width - (box.x + box.width);
expect(Math.abs(leftPad - rightPad)).toBeLessThanOrEqual(2);
}
await context.close();
});
}
// Section spacing audit
for (const vp of viewports) {
test(`section vertical rhythm at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
// Gather all top-level sections and report top/bottom paddings
const sectionInfo = await page.evaluate(() => {
const sections = Array.from(
document.querySelectorAll("body > * section, main > section"),
);
return sections.slice(0, 15).map((s) => {
const cs = getComputedStyle(s);
const rect = s.getBoundingClientRect();
return {
class: s.className?.toString?.().slice(0, 60),
top: Math.round(rect.top),
height: Math.round(rect.height),
paddingTop: cs.paddingTop,
paddingBottom: cs.paddingBottom,
marginTop: cs.marginTop,
marginBottom: cs.marginBottom,
};
});
});
// No section should have 0 vertical padding (visual breathing room)
for (const s of sectionInfo) {
const pt = parseFloat(s.paddingTop) || 0;
const pb = parseFloat(s.paddingBottom) || 0;
// At least one of top/bottom padding should be > 8px for breathing room
// (except narrow edge cases)
if (s.height > 50) {
expect(
Math.max(pt, pb),
`Section "${s.class}" has insufficient vertical padding (pt=${pt}, pb=${pb})`,
).toBeGreaterThan(8);
}
}
await context.close();
});
}
// Card grid alignment at common breakpoints
for (const vp of viewports) {
test(`card alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const cardCount = await page.locator(selectors.cards).count();
if (cardCount < 2) {
test.skip();
return;
}
const boxes = await page
.locator(selectors.cards)
.evaluateAll((els) =>
els
.slice(0, 12)
.map((e) => {
const r = e.getBoundingClientRect();
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
};
}),
);
// All visible cards should be within the viewport
for (const b of boxes) {
expect(b.x).toBeGreaterThanOrEqual(0);
expect(b.x + b.w).toBeLessThanOrEqual(vp.width + 1);
}
// Group by row (similar y) and verify alignment
const rows = {};
for (const b of boxes) {
const rowKey = Math.round(b.y / 10); // tolerance for sub-row alignment
if (!rows[rowKey]) rows[rowKey] = [];
rows[rowKey].push(b);
}
for (const row of Object.values(rows)) {
if (row.length < 2) continue;
// Same-row items should share a common left or right edge
// (allow 2px tolerance for grid gap math)
const xs = row.map((b) => b.x);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
// All should be left-aligned to the same x OR span the row
// We just want to ensure they're not wildly misaligned
expect(
maxX - minX,
`Cards in same row misaligned: ${JSON.stringify(row)}`,
).toBeLessThanOrEqual(vp.width * 0.95);
}
await context.close();
});
}
// Footer alignment
for (const vp of viewports) {
test(`footer alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const footer = page.locator(selectors.footer).first();
const count = await page.locator(selectors.footer).count();
if (count === 0) {
test.skip();
return;
}
const box = await footer.boundingBox();
expect(box).not.toBeNull();
// Footer should not overflow horizontally
expect(box.x + box.width).toBeLessThanOrEqual(vp.width + 1);
await context.close();
});
}
// Image / media overflow
for (const vp of viewports) {
test(`images fit viewport at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const overflows = await page.evaluate(() => {
const imgs = Array.from(document.querySelectorAll("img"));
const out = [];
for (const img of imgs) {
const r = img.getBoundingClientRect();
if (r.right > window.innerWidth + 1 || r.width > window.innerWidth + 1) {
out.push({
src: img.src.slice(-60),
width: Math.round(r.width),
right: Math.round(r.right),
});
}
}
return out;
});
expect(overflows.length, `Images overflow: ${JSON.stringify(overflows)}`).toBe(0);
await context.close();
});
}
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
const servicesUrl =
process.env.TEST_SERVICES_URL ||
"http://community-works-collaborative.test/services/";
const childUrl =
process.env.TEST_SERVICES_CHILD_URL ||
"http://community-works-collaborative.test/services/engagement/";
const grandchildUrl =
process.env.TEST_SERVICES_GRANDCHILD_URL ||
"http://community-works-collaborative.test/services/engagement/sample-grandchild/";
const nonServicesUrl =
process.env.TEST_NON_SERVICES_URL ||
"http://community-works-collaborative.test/about/";
const blogUrl =
process.env.TEST_BLOG_URL ||
"http://community-works-collaborative.test/blog/";
test.describe("hero ancestor scope", () => {
test("Services page renders the hero", async ({ page }) => {
await page.goto(servicesUrl);
await expect(page.locator(".page-hero").first()).toBeVisible();
});
test("child of Services renders the hero", async ({ page }) => {
await page.goto(childUrl);
await expect(page.locator(".page-hero").first()).toBeVisible();
});
// TODO(hero-scope): No grandchild page exists on the live site — the
// fixture URL 404s. Marked test.fail() so CI surfaces the missing
// fixture as a known issue instead of a silent pass. When a real
// grandchild page is seeded on the live site (e.g. an
// `Engagement > Stakeholder Mapping` child), remove the test.fail()
// annotation and the test should go green.
test("grandchild of Services renders the hero", async ({ page }) => {
await page.goto(grandchildUrl);
test.fail();
await expect(page.locator(".page-hero").first()).toBeVisible();
});
test("non-Services page does NOT render the hero", async ({ page }) => {
await page.goto(nonServicesUrl);
expect(await page.locator(".page-hero").count()).toBe(0);
});
test("blog index does NOT render the hero (the hero is Services-only)", async ({
page,
}) => {
await page.goto(blogUrl);
expect(await page.locator(".page-hero").count()).toBe(0);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Services List block on /services/mapping/", () => {
test("renders the heading and image for each card", async ({ page }) => {
await page.goto("/services/mapping/");
const blocks = page.locator(".services-list");
await expect(blocks).toHaveCount(2);
await expect(blocks.nth(0).locator(".services-list__card")).toHaveCount(2);
await expect(blocks.nth(1).locator(".services-list__card")).toHaveCount(4);
await expect(blocks.nth(0)).toHaveClass(/lg:grid-cols-2/);
await expect(blocks.nth(1)).toHaveClass(/lg:grid-cols-4/);
const firstCard = blocks.nth(0).locator(".services-list__card").first();
await expect(firstCard.locator(".services-list__media img")).toBeVisible();
await expect(firstCard.locator(".services-list__heading")).toBeVisible();
});
test("bisecting line is present and aria-hidden", async ({ page }) => {
await page.goto("/services/mapping/");
const line = page.locator(".services-list__line").first();
await expect(line).toBeAttached();
await expect(line).toHaveAttribute("aria-hidden", "true");
});
test("no axe violations on the services-list block", async ({ page }) => {
await page.goto("/services/mapping/");
const results = await new AxeBuilder({ page })
.include(".services-list")
.analyze();
expect(results.violations).toEqual([]);
});
});
+47 -19
View File
@@ -1,58 +1,86 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require("@playwright/test");
const AxeBuilder = require('@axe-core/playwright').default; const AxeBuilder = require("@axe-core/playwright").default;
const domain = 'http://domain.local/'; const domain =
process.env.TEST_BASE_URL || "http://community-works-collaborative.test/";
test.use({ test.use({
viewport: { width: 1920, height: 1080 }, viewport: { width: 1920, height: 1080 },
}); });
test.describe('site-test', () => { test.describe("site-test", () => {
test('Homepage Test', async ({ page }, testInfo) => { test("Homepage Test", async ({ page }, testInfo) => {
await page.goto(domain); await page.goto(domain);
await page.screenshot({ path: 'test-results/homepage.png', fullPage: true }); await page.screenshot({
path: "test-results/homepage.png",
fullPage: true,
});
const accessibilityScanResults = await new AxeBuilder({ page }) const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa']) .withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze(); .analyze();
await testInfo.attach('accessibility-scan-results', { await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(accessibilityScanResults, null, 2), body: JSON.stringify(accessibilityScanResults, null, 2),
contentType: 'application/json' contentType: "application/json",
}); });
expect(accessibilityScanResults.violations).toEqual([]); expect(accessibilityScanResults.violations).toEqual([]);
}); });
test('Blog Index Page Test', async ({ page }, testInfo) => { test("Blog Index Page Test", async ({ page }, testInfo) => {
await page.goto(`${domain}news/`); await page.goto(`${domain}news/`);
await page.screenshot({ path: 'test-results/blog-index.png', fullPage: true }); await page.screenshot({
path: "test-results/blog-index.png",
fullPage: true,
});
const accessibilityScanResults = await new AxeBuilder({ page }) const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa']) .withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze(); .analyze();
await testInfo.attach('accessibility-scan-results', { await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(accessibilityScanResults, null, 2), body: JSON.stringify(accessibilityScanResults, null, 2),
contentType: 'application/json' contentType: "application/json",
}); });
expect(accessibilityScanResults.violations).toEqual([]); expect(accessibilityScanResults.violations).toEqual([]);
}); });
test('404 Page Test', async ({ page }, testInfo) => { test("404 Page Test", async ({ page }, testInfo) => {
await page.goto(`${domain}yaya/`); await page.goto(`${domain}yaya/`);
await page.screenshot({ path: 'test-results/404.png', fullPage: true }); await page.screenshot({ path: "test-results/404.png", fullPage: true });
const accessibilityScanResults = await new AxeBuilder({ page }) const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa']) .withTags([
"wcag2a",
"wcag2aa",
"wcag21a",
"wcag21aa",
"wcag22a",
"wcag22aa",
])
.analyze(); .analyze();
await testInfo.attach('accessibility-scan-results', { await testInfo.attach("accessibility-scan-results", {
body: JSON.stringify(accessibilityScanResults, null, 2), body: JSON.stringify(accessibilityScanResults, null, 2),
contentType: 'application/json' contentType: "application/json",
}); });
expect(accessibilityScanResults.violations).toEqual([]); expect(accessibilityScanResults.violations).toEqual([]);
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("Team Grid block on /team/", () => {
test("renders the photo, name, and title for each member", async ({ page }) => {
await page.goto("/team/");
const cards = page.locator(".team-grid__card");
await expect(cards.first()).toBeVisible();
const firstCard = cards.first();
await expect(firstCard.locator(".team-grid__photo")).toBeVisible();
await expect(firstCard.locator(".team-grid__name")).toBeVisible();
await expect(firstCard.locator(".team-grid__title")).toBeVisible();
});
test("bio is hidden by default and expands on summary click", async ({ page }) => {
await page.goto("/team/");
const firstDetails = page.locator(".team-grid__details").first();
// Bio is closed by default.
await expect(firstDetails).not.toHaveAttribute("open", "");
// Click the summary; the details should open and the bio should be visible.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).toHaveAttribute("open", "");
await expect(firstDetails.locator(".team-grid__bio")).toBeVisible();
// Click again; the details should close.
await firstDetails.locator(".team-grid__summary").click();
await expect(firstDetails).not.toHaveAttribute("open", "");
});
test("no axe violations on the team-grid block", async ({ page }) => {
await page.goto("/team/");
const results = await new AxeBuilder({ page })
.include(".team-grid")
.analyze();
expect(results.violations).toEqual([]);
});
});
+1 -1
View File
@@ -15,6 +15,6 @@ $classes = 'align-with-content my-[1.2em]';
$wrapper = blockWrapperAttributes( $classes, $is_preview ); $wrapper = blockWrapperAttributes( $classes, $is_preview );
?> ?>
<div id="<?php echo esc_attr( $block['id'] ); ?>" <?php echo esc_attr( $wrapper ); ?>> <div id="<?php echo esc_attr( $block['id'] ); ?>" <?php echo wp_kses_post( $wrapper ); ?>>
<InnerBlocks className="<?php echo esc_attr( $ibClasses ); ?>" /> <InnerBlocks className="<?php echo esc_attr( $ibClasses ); ?>" />
</div> </div>
+97 -12
View File
@@ -2,17 +2,24 @@
.contact-block { .contact-block {
background-color: var(--color-navy, #0d1f35); background-color: var(--color-navy, #0d1f35);
border-bottom: 1rem solid var(--color-orange, #F26B53); border-bottom: 1rem solid var(--color-orange, #f26b53);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
em { color: var(--color-secondary); font-style: normal; } em {
color: var(--color-secondary);
font-style: normal;
}
.contact-block__media { .contact-block__media {
inset-block: 0; inset-block: 0;
inset-inline-end: 0; inset-inline-end: 0;
mask-image: linear-gradient(to right, transparent 0%, black 30%); mask-image: linear-gradient(to right, transparent 0%, black 30%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 30%); -webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 30%
);
position: absolute; position: absolute;
} }
@@ -30,9 +37,9 @@
width: auto; width: auto;
} }
.contact-block__content { .contact-block__linework--mobile { display: none; }
} .contact-block__content { padding-inline: 1rem; }
&.home-contact { &.home-contact {
bottom: -25%; bottom: -25%;
@@ -51,16 +58,25 @@
.contact-block .contact-block__media { .contact-block .contact-block__media {
inset: 0; inset: 0;
mask-image: linear-gradient(to bottom, rgb(0 0 0 / 0.9) 0%, rgb(0 0 0 / 0.45) 100%); mask-image: linear-gradient(
-webkit-mask-image: linear-gradient(to bottom, rgb(0 0 0 / 0.9) 0%, rgb(0 0 0 / 0.45) 100%); to bottom,
rgb(0 0 0 / 0.9) 0%,
rgb(0 0 0 / 0.45) 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
rgb(0 0 0 / 0.9) 0%,
rgb(0 0 0 / 0.45) 100%
);
} }
.contact-block .contact-block__bg-image { .contact-block .contact-block__bg-image { object-fit: cover; }
object-fit: cover;
}
.contact-block .contact-block__linework { .contact-block .contact-block__linework { display: none; }
.contact-block .contact-block__linework--mobile {
bottom: 0.75rem; bottom: 0.75rem;
display: block;
height: auto; height: auto;
max-width: 112%; max-width: 112%;
right: -32%; right: -32%;
@@ -79,12 +95,81 @@
} }
.contact-block.home-contact { .contact-block.home-contact {
background-color: #032f46;
border-bottom-width: calc(100vw * 16 / 402);
border-radius: calc(100vw * 16 / 402) calc(100vw * 16 / 402) 0 0;
box-sizing: border-box;
bottom: auto; bottom: auto;
height: calc(100vw * 502 / 402);
left: 50%; left: 50%;
margin: 2.5rem auto -3.75rem; margin: 2.5rem auto -3.75rem;
max-width: none; max-width: none;
padding-inline: 0 !important;
padding-top: 0 !important;
position: relative; position: relative;
transform: translateX(-50%); transform: translateX(-50%);
width: min(calc(100vw - 2rem), 22.5rem); width: calc(100vw * 343 / 402);
} }
.contact-block.home-contact .contact-block__media {
inset: 0;
mask-image: none;
-webkit-mask-image: none;
}
.contact-block.home-contact .contact-block__media::after {
background: linear-gradient(
to right,
rgb(3 47 70 / 0.98) 0%,
rgb(3 47 70 / 0.96) 40%,
rgb(3 47 70 / 0.8) 52%,
rgb(3 47 70 / 0.1) 72%,
transparent 100%
);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
z-index: 1;
}
.contact-block.home-contact .contact-block__bg-image {
filter: brightness(1.5);
object-fit: cover;
object-position: 82% center;
position: relative;
z-index: 0;
}
.contact-block.home-contact .contact-block__linework { display: none; }
.contact-block.home-contact .contact-block__linework--mobile {
bottom: 0;
display: block;
height: auto;
left: 0;
max-width: none;
overflow: visible;
right: auto;
width: 100%;
z-index: 2;
}
.contact-block.home-contact .contact-block__content {
margin: 0 !important;
max-width: none !important;
min-height: 0;
padding: calc(100vw * 31 / 402) calc(100vw * 33 / 402) 0 !important;
width: 100% !important;
z-index: 2;
}
.contact-block.home-contact .contact-block__text {
font-size: calc(100vw * 28 / 402);
line-height: 1.08;
margin-bottom: calc(100vw * 37 / 402);
max-width: calc(100vw * 236 / 402) !important;
}
.contact-block.home-contact .contact-block__cta { padding-block: calc(100vw * 8.5 / 402); }
} }
+3 -1
View File
@@ -31,9 +31,11 @@ $wrapper = blockWrapperAttributes( $classes, $is_preview );
<?php endif; ?> <?php endif; ?>
<?php get_template_part( '/views/blocks/contact-block/linework' ); ?> <?php get_template_part( '/views/blocks/contact-block/linework' ); ?>
<img class="contact-block__linework contact-block__linework--mobile" src="<?php echo esc_url( get_theme_file_uri( '/static/img/mobile-contact-vector.svg' ) ); ?>" alt="" loading="lazy" role="presentation" />
</div> </div>
<div class="contact-block__content container px-4! pt-12 pb-16 relative z-10"> <div class="contact-block__content container pt-12 pb-16 relative z-10">
<?php if ( $text ) : ?> <?php if ( $text ) : ?>
<h2 class="contact-block__text font-quincy font-normal leading-none text-white max-w-4/5 mb-8"> <h2 class="contact-block__text font-quincy font-normal leading-none text-white max-w-4/5 mb-8">
<?php echo wp_kses_post( $text ); ?> <?php echo wp_kses_post( $text ); ?>
+281
View File
@@ -0,0 +1,281 @@
/* Contact Info block styles */
main#maincontent {
padding: 0;
}
.contact-info {
background: var(--color-white);
position: relative;
}
.contact-info__grid {
display: grid;
gap: 2rem;
grid-template-columns: 1fr;
position: relative;
}
.contact-info__details {
padding: 5rem 0;
max-width: 36rem;
}
.contact-info__heading {
@apply text-cwc-blue-02 font-bold text-34px/9;
margin: 0 0 1rem;
span { @apply text-cwc-blue-01; }
em { @apply text-secondary; }
}
.contact-info__items {
display: flex;
flex-direction: column;
font-weight: 300;
gap: 0.75rem;
list-style: none;
margin: 0 0 2.25rem;
padding: 0;
}
.contact-info__item {
align-items: flex-start;
color: var(--color-dark);
display: flex;
font-size: 1rem;
gap: 0.75rem;
line-height: 1.5;
}
.contact-info__icon {
align-items: center;
color: var(--color-cwc-blue-03);
display: inline-flex;
flex-shrink: 0;
height: 1.5rem;
justify-content: center;
width: 1.5rem;
}
.contact-info__icon img {
display: block;
height: 100%;
width: 100%;
}
.contact-info__value {
color: inherit;
text-decoration: none;
}
a.contact-info__value:hover {
color: var(--color-secondary);
}
.contact-info__map {
background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
height: 100%;
min-height: 24rem;
overflow: hidden;
position: relative;
}
.contact-info__map .leaflet-container {
height: 100%;
isolation: isolate;
width: 100%;
}
.contact-info__map .leaflet-tile-pane {
filter: grayscale(0.35) saturate(0.7) brightness(1.05) contrast(0.98);
}
.contact-info__map::after {
background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, transparent);
content: '';
inset: 0;
mix-blend-mode: normal;
pointer-events: none;
position: absolute;
z-index: 410;
}
.contact-info__map .leaflet-control-attribution {
color: color-mix(in oklch, var(--color-cwc-blue-01) 45%, black);
font-size: 0.625rem;
opacity: 0.65;
}
.contact-info__leaflet-pin {
display: block;
filter: drop-shadow(0 0.2rem 0.45rem rgba(0, 0, 0, 0.08));
}
.contact-info__leaflet-pin svg {
display: block;
height: 100%;
width: 100%;
}
@media (min-width: 1024px) {
.contact-info__grid {
grid-template-columns: 1fr 1fr;
}
.contact-info__details {
padding-inline: clamp(1.5rem, 5vw, 3rem) 0;
}
.contact-info__map {
align-self: stretch;
border-radius: 0;
margin-right: calc(50% - 50vw);
margin-left: 0;
min-height: 32rem;
width: calc(50vw - 0px);
}
}
@media (max-width: 767px) {
.contact-info {
padding-block: 0;
}
.contact-info__details {
max-width: none;
padding: 2.75rem 0 2rem;
padding-inline: clamp(1.5rem, 5vw, 3rem);
}
.contact-info__heading {
margin-bottom: 1.375rem;
}
.contact-info__map {
margin-inline: 0;
min-height: 30rem;
}
.contact-info__form .gfield {
margin-bottom: 1rem;
}
.contact-info__form .gfield_label {
letter-spacing: 0.08em;
margin-bottom: 0.35rem;
}
.contact-info__form input[type="text"],
.contact-info__form input[type="email"],
.contact-info__form input[type="tel"],
.contact-info__form input[type="url"],
.contact-info__form select {
min-height: 2.875rem;
padding: 0.45rem 0.75rem;
}
.contact-info__form textarea {
min-height: 13.5rem;
padding: 0.7rem 0.75rem;
}
.contact-info__form .gform_footer {
margin-top: 1.25rem;
}
.contact-info__form .gform_footer input[type="submit"],
.contact-info__form .gform_footer button[type="submit"] {
border-radius: 1rem 0.25rem;
font-size: 1.125rem;
line-height: 1;
min-width: 7.75rem;
padding: 0.65rem 1.4rem;
}
}
/* Gravity Forms field overrides (scoped to .contact-info__form) */
.contact-info__form .gform_wrapper {
margin: 0;
max-width: none;
.gform_required_legend { display: none; }
}
.contact-info__form .gfield { margin-bottom: 1.25rem; }
.contact-info__form .gfield_label {
color: var(--color-cwc-blue-01);
display: flex;
font-size: 0.875rem;
font-weight: 700;
justify-content: space-between;
letter-spacing: 0.05em;
line-height: 1.2;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.contact-info__form .gfield_required {
color: var(--color-secondary-accessible);
font-weight: 700;
margin-left: 0.25rem;
}
.contact-info__form input[type="text"],
.contact-info__form input[type="email"],
.contact-info__form input[type="tel"],
.contact-info__form input[type="url"],
.contact-info__form textarea,
.contact-info__form select {
background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
border: 2px solid var(--color-cwc-blue-03);
border-radius: 0.25rem;
color: var(--color-dark);
font-size: 1rem;
line-height: 1;
padding: 0.25rem 0.5rem;
width: 100%;
}
.contact-info__form input[type="text"]:focus,
.contact-info__form input[type="email"]:focus,
.contact-info__form input[type="tel"]:focus,
.contact-info__form input[type="url"]:focus,
.contact-info__form textarea:focus,
.contact-info__form select:focus {
border-color: var(--color-cwc-blue-01);
outline: 2px solid var(--color-cwc-blue-01);
outline-offset: 2px;
}
.contact-info__form textarea {
min-height: 10rem;
padding: 0.5rem;
resize: vertical;
}
.contact-info__form .gform_footer {
margin-top: 1.5rem;
}
.contact-info__form .gform_footer input[type="submit"],
.contact-info__form .gform_footer button[type="submit"] {
background: var(--background-image-button-gradient);
border: 3px solid transparent;
border-radius: 1.25rem 0.25rem;
color: var(--color-white);
cursor: pointer;
font-size: 1.125rem;
font-weight: 700;
padding: 0.625rem 2rem;
transition: background 200ms, border-color 200ms, color 200ms;
}
.contact-info__form .gform_footer input[type="submit"]:hover,
.contact-info__form .gform_footer button[type="submit"]:hover {
border: 3px solid #D24D32;
}
+81 -16
View File
@@ -2,36 +2,101 @@
/** /**
* Block Name: Contact Info * Block Name: Contact Info
* *
* Display contact information from global fields with icons and optional form. * Two-column contact page layout: contact info, address/email/phone,
* and a Gravity Forms contact form on the left; Leaflet map on the right.
* Contact details read from global `contact_info`; map coordinates/zoom are
* block-level ACF fields.
* *
* @package CWC * @package CWC
*/ */
namespace CWC; namespace CWC;
$classes = 'contact-info'; $info = get_field( 'contact_info', 'option' );
$address = $info['address'] ?? '';
$email = $info['email'] ?? '';
$phone = $info['phone'] ?? '';
$heading = get_field( 'heading' );
$map_lat_value = get_field( 'map_latitude' );
$map_lng_value = get_field( 'map_longitude' );
$map_zoom_value = get_field( 'map_zoom' );
$map_lat = is_numeric( $map_lat_value ) ? (float) $map_lat_value : 49.8951;
$map_lng = is_numeric( $map_lng_value ) ? (float) $map_lng_value : -97.1384;
$map_zoom = is_numeric( $map_zoom_value ) ? (int) $map_zoom_value : 14;
$map_zoom = max( 1, min( 19, $map_zoom ) );
$map_popup = $address ? wp_strip_all_tags( $address ) : 'Our office';
$marker_svg_path = get_theme_file_path( '/views/blocks/contact-info/map-marker.svg' );
$map_marker_svg = '';
if ( file_exists( $marker_svg_path ) ) {
$map_marker_svg = file_get_contents( $marker_svg_path ); // phpcs:ignore
}
$map_marker = get_theme_file_uri( '/views/blocks/contact-info/map-marker.svg' );
$classes = 'contact-info mx-break-out';
$wrapper = blockWrapperAttributes( $classes, $is_preview ); $wrapper = blockWrapperAttributes( $classes, $is_preview );
?> ?>
<section <?php echo esc_attr( $wrapper ); ?>> <section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="flex flex-col lg:flex-row"> <div class="contact-info__grid">
<div class="w-full lg:w-1/2 p-6"> <div class="container contact-info__details">
<h2 class="text-2xl font-bold mb-4">Contact Information</h2> <?php if ( $heading ) : ?>
<h1 class="contact-info__heading"><?php echo wp_kses_post( $heading ); ?></h1>
<?php endif; ?>
<div class="not-prose text-black my-4"> <ul class="contact-info__items">
<h3 class="mb-0"><?php echo esc_html__( 'Mailing Address', 'cwc' ); ?></h3> <?php if ( $address ) : ?>
<p class="my-0.5"><?php echo wp_kses_post( get_field( 'contact_info', 'option' )['address'] ); ?></p> <li class="contact-info__item contact-info__item--address">
<span class="contact-info__icon" aria-hidden="true">
<img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-address.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
</span>
<span class="contact-info__value"><?php echo wp_kses_post( $address ); ?></span>
</li>
<?php endif; ?>
<h3 class="mb-0"><?php echo esc_html__( 'Email', 'cwc' ); ?></h3> <?php if ( $email ) : ?>
<p class="my-0.5"><a class="hover:opacity-80 transition-colors duration-100" href="mailto:<?php echo esc_html( get_field( 'contact_info', 'option' )['email'] ); ?>"><?php echo esc_html( get_field( 'contact_info', 'option' )['email'] ); ?></a></p> <li class="contact-info__item contact-info__item--email">
<span class="contact-info__icon" aria-hidden="true">
<img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-email.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
</span>
<a class="contact-info__value" href="mailto:<?php echo esc_attr( $email ); ?>"><?php echo esc_html( $email ); ?></a>
</li>
<?php endif; ?>
<h3 class="mb-0"><?php echo esc_html__( 'Phone', 'cwc' ); ?></h3> <?php if ( $phone ) : ?>
<p class="my-0.5"><a class="hover:opacity-80 transition-colors duration-100" href="tel:<?php echo esc_html( get_field( 'contact_info', 'option' )['phone'] ); ?>"><?php echo esc_html( get_field( 'contact_info', 'option' )['phone'] ); ?></a></p> <li class="contact-info__item contact-info__item--phone">
</div> <span class="contact-info__icon" aria-hidden="true">
</div> <img src="<?php echo esc_url( get_theme_file_uri( '/static/img/contact/icon-phone.svg' ) ); ?>" alt="" width="24" height="24" loading="lazy" />
</span>
<a class="contact-info__value" href="tel:<?php echo esc_attr( $phone ); ?>"><?php echo esc_html( $phone ); ?></a>
</li>
<?php endif; ?>
</ul>
<div class="w-full lg:w-1/2 p-6"> <div class="contact-info__form">
<InnerBlocks /> <InnerBlocks />
</div> </div>
</div> </div>
<div
class="contact-info__map"
id="contact-map"
aria-label="Map of downtown Winnipeg"
role="region"
data-lat="<?php echo esc_attr( $map_lat ); ?>"
data-lng="<?php echo esc_attr( $map_lng ); ?>"
data-zoom="<?php echo esc_attr( $map_zoom ); ?>"
data-popup="<?php echo esc_attr( $map_popup ); ?>"
data-marker="<?php echo esc_url( $map_marker ); ?>"
data-marker-svg="<?php echo esc_attr( $map_marker_svg ); ?>"
>
<!-- Leaflet map mounts in this container. -->
</div>
</div>
</section> </section>
+10
View File
@@ -0,0 +1,10 @@
<svg width="79" height="86" viewBox="0 0 79 86" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5 0C49.9547 0 59.9945 4.11839 67.4072 11.4697C74.8221 18.8233 79 28.8109 79 39.2393C79 52.9415 71.4913 63.5903 64.6484 70.5264L64.6328 70.541L64.6172 70.5566C61.1816 74.0014 57.4333 77.1243 53.4209 79.8848L53.3926 79.9043L53.3633 79.9238L51.8721 80.9307L51.8369 80.9551L51.8008 80.9785L51.1006 81.4404L51.0342 81.4844L50.9668 81.5264L49.6475 82.3594L49.583 82.4004L49.5176 82.4395L48.3418 83.1514L48.2705 83.1943L48.1982 83.2363L46.7422 84.0762L46.7139 84.0918L46.6855 84.1084C44.4927 85.3497 42.0162 86 39.5 86C36.9838 86 34.5073 85.3497 32.3145 84.1084L32.2861 84.0918L32.2578 84.0762L30.8018 83.2363L30.7158 83.1865L30.6318 83.1348L28.8115 82.0244L28.7256 81.9717L28.6406 81.916L27.9678 81.4824L27.8994 81.4375L26.4639 80.4902L26.415 80.457C22.0909 77.5556 18.0623 74.2411 14.3867 70.5605L14.3506 70.5244C7.51024 63.587 4.87832e-06 52.9422 0 39.2393C0 28.8109 4.17793 18.8233 11.5928 11.4697C19.0055 4.11839 29.0453 0 39.5 0Z" fill="url(#paint0_linear_2533_1456)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.5 8C47.8543 8 55.8665 11.2913 61.7739 17.1498C67.6813 23.0083 71 30.9541 71 39.2392C71 49.9092 65.134 58.6423 58.953 64.9075C55.8649 68.0039 52.4952 70.8111 48.887 73.2935L47.396 74.3001L46.696 74.7617L45.3765 75.5948L44.2005 76.3063L42.7445 77.1463C41.7562 77.7058 40.6379 78 39.5 78C38.3621 78 37.2438 77.7058 36.2555 77.1463L34.7995 76.3063L32.9795 75.1956L32.3075 74.7617L30.8725 73.8141C26.9799 71.2023 23.3542 68.2192 20.047 64.9075C13.866 58.6388 8 49.9092 8 39.2392C8 30.9541 11.3187 23.0083 17.2261 17.1498C23.1335 11.2913 31.1457 8 39.5 8ZM39.5 14.9421C33.0022 14.9421 26.7705 17.5019 22.1759 22.0585C17.5812 26.6151 15 32.7952 15 39.2392C15 47.299 19.452 54.3729 25.0485 60.0515C27.4549 62.4673 30.0558 64.6846 32.8255 66.6812L34.4285 67.8127C34.9465 68.1714 35.4447 68.5046 35.923 68.8124L37.288 69.6802L38.4885 70.4056L39.5 70.9922L41.0925 70.0585L42.377 69.2602C43.0607 68.8298 43.7922 68.3473 44.5715 67.8127L46.1745 66.6812C48.9442 64.6846 51.5451 62.4673 53.9515 60.0515C59.548 54.3764 64 47.299 64 39.2392C64 32.7952 61.4188 26.6151 56.8241 22.0585C52.2295 17.5019 45.9978 14.9421 39.5 14.9421ZM39.5 25.3551C43.213 25.3551 46.774 26.8179 49.3995 29.4217C52.025 32.0255 53.5 35.5569 53.5 39.2392C53.5 42.9215 52.025 46.453 49.3995 49.0568C46.774 51.6606 43.213 53.1234 39.5 53.1234C35.787 53.1234 32.226 51.6606 29.6005 49.0568C26.975 46.453 25.5 42.9215 25.5 39.2392C25.5 35.5569 26.975 32.0255 29.6005 29.4217C32.226 26.8179 35.787 25.3551 39.5 25.3551ZM39.5 32.2972C37.6435 32.2972 35.863 33.0286 34.5503 34.3305C33.2375 35.6324 32.5 37.3981 32.5 39.2392C32.5 41.0804 33.2375 42.8461 34.5503 44.148C35.863 45.4499 37.6435 46.1813 39.5 46.1813C41.3565 46.1813 43.137 45.4499 44.4497 44.148C45.7625 42.8461 46.5 41.0804 46.5 39.2392C46.5 37.3981 45.7625 35.6324 44.4497 34.3305C43.137 33.0286 41.3565 32.2972 39.5 32.2972Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_2533_1456" x1="0" y1="0" x2="82.9794" y2="4.02829" gradientUnits="userSpaceOnUse">
<stop stop-color="#F26B53"/>
<stop offset="1" stop-color="#D24D32"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+73 -21
View File
@@ -33,6 +33,10 @@
svg { svg {
} }
.hero-vector--mobile {
display: none;
}
} }
.intro { .intro {
@@ -51,28 +55,38 @@
} }
} }
.homepage-hero__location {
display: none;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.homepage-hero { .homepage-hero {
--hgtHero: 20.75rem; --hgtHero: clamp(37.5rem, 187vw, 47rem);
--hero-content-inset: clamp(1.25rem, 8vw, 2rem);
/* Preserve the 402px crop, then grow the linework to the 767px edge. */
--hero-vector-inline-start: max(-8vw, calc(63.8vw - 18.04rem));
--hero-vector-width: calc(100vw + 22.875rem);
min-height: var(--hgtHero); min-height: var(--hgtHero);
} }
.homepage-hero .content-wrapper {
padding-inline: 1rem;
}
.homepage-hero .content-wrapper > div { .homepage-hero .content-wrapper > div {
max-width: 20rem; margin-left: var(--hero-content-inset);
padding-top: 2.25rem; padding-top: clamp(3rem, 14vw, 3.75rem);
text-align: left; text-align: left;
width: min(
21.25rem,
calc(
100vw - var(--hero-content-inset) - var(--hero-content-inset)
)
);
} }
.homepage-hero .intro h1 { .homepage-hero .intro h1 {
font-size: clamp(1.8rem, 8vw, 2.2rem); font-size: clamp(1.75rem, 8vw, 2rem);
letter-spacing: 0; letter-spacing: 0;
line-height: 0.98; line-height: 1.24;
margin-bottom: 0.75rem; margin-bottom: 1.75rem;
} }
.homepage-hero .intro h1 strong { .homepage-hero .intro h1 strong {
@@ -84,31 +98,69 @@
font-size: var(--text-14px); font-size: var(--text-14px);
line-height: 1.3; line-height: 1.3;
margin: 0; margin: 0;
max-width: 16rem; max-width: 18rem;
} }
.homepage-hero .reset { .homepage-hero .reset {
gap: 0.7rem; flex-wrap: nowrap;
gap: clamp(0.75rem, 5.5vw, 1.4rem);
justify-content: flex-start; justify-content: flex-start;
margin-top: 1rem; margin-top: 2rem;
} }
.homepage-hero .button { .homepage-hero .button {
font-size: var(--text-14px); font-size: var(--text-18px);
min-height: 2.25rem; min-height: 2.75rem;
min-width: 6.35rem; min-width: clamp(7.5rem, 36.82vw, 9.25rem);
padding: 0.5rem 0.8rem; padding: 0.65rem 1rem;
}
.homepage-hero .reset x-button:nth-child(2) .button {
min-width: clamp(7.75rem, 38.8vw, 9.75rem);
} }
.homepage-hero .heroVector .vector { .homepage-hero .heroVector .vector {
bottom: 0.15rem; bottom: 6.25rem;
left: 50%; left: var(--hero-vector-inline-start);
transform: translateX(-50%); transform: translateX(-50%);
width: 170%; width: var(--hero-vector-width);
}
.homepage-hero .heroVector .vector > svg {
display: none;
}
.homepage-hero .heroVector .hero-vector--mobile {
bottom: 6.25rem;
display: block;
height: auto;
left: 0;
position: absolute;
width: 100vw;
} }
.homepage-hero .heroMedia img, .homepage-hero .heroMedia img,
.homepage-hero .heroMedia video { .homepage-hero .heroMedia video {
object-position: center top; object-position: 58% center;
}
.homepage-hero .homepage-hero__location {
align-items: center;
bottom: 5.5rem;
color: var(--color-cwc-blue-03);
display: flex;
font-size: var(--text-14px);
gap: 0.5rem;
left: 2rem;
position: absolute;
z-index: 30;
}
.homepage-hero .homepage-hero__location svg {
fill: none;
height: 1.25rem;
stroke: currentColor;
stroke-width: 1.75;
width: 1.25rem;
} }
} }
@@ -81,8 +81,17 @@ $wrapper = blockWrapperAttributes( $classes, $is_preview );
<div class="vector absolute bottom-8 w-full"> <div class="vector absolute bottom-8 w-full">
<?php get_template_part( 'views/partials/hero-vector' ); ?> <?php get_template_part( 'views/partials/hero-vector' ); ?>
</div> </div>
<?php get_template_part( 'views/partials/hero-vector-mobile' ); ?>
</div> </div>
<!-- <div class="homepage-hero__location" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M12 21s7-5.6 7-12a7 7 0 1 0-14 0c0 6.4 7 12 7 12Z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
<span><?php echo esc_html__( 'Location / Nation Information', 'cwc' ); ?></span>
</div> -->
<div class="topGradient absolute inset-0 z-1"><!-- Rectangle 88 --></div> <div class="topGradient absolute inset-0 z-1"><!-- Rectangle 88 --></div>
<div class="bgVector absolute inset-0 z-1"><!-- Rectangle 83 --></div> <div class="bgVector absolute inset-0 z-1"><!-- Rectangle 83 --></div>
+42
View File
@@ -0,0 +1,42 @@
@media (max-width: 767px) {
.wp-block-acf-media-text.media-cols {
gap: 3rem;
grid-template-columns: minmax(0, 1fr);
padding: 0;
padding-inline: 0 !important;
}
.wp-block-acf-media-text.media-cols > .media-wrapper {
order: 2;
}
.wp-block-acf-media-text.media-cols > .media-wrapper img {
display: block;
height: auto;
width: 100%;
}
.wp-block-acf-media-text.media-cols > .content-wrapper {
order: 1;
padding: 0;
}
.wp-block-acf-media-text.media-cols > .content-wrapper .font-light {
font-size: 1rem;
line-height: 1.5;
}
.wp-block-acf-media-text.media-cols.has-two-ctas > .content-wrapper .reset {
display: grid;
gap: 1.375rem;
grid-template-columns: minmax(0, 148fr) minmax(0, 124fr);
width: min(100%, 18.375rem);
}
.wp-block-acf-media-text.media-cols.has-two-ctas
> .content-wrapper
.reset
.button {
width: 100%;
}
}
+18 -13
View File
@@ -27,6 +27,10 @@ $mediaClass = $mediaLeft ? 'order-first' : 'order-last';
$contentClass = $mediaLeft ? 'order-last pr-8' : 'order-first pl-8'; $contentClass = $mediaLeft ? 'order-last pr-8' : 'order-first pl-8';
$classes = 'media-cols grid grid-cols-1 lg:grid-cols-2 gap-24 items-center mb-8 p-0 container'; $classes = 'media-cols grid grid-cols-1 lg:grid-cols-2 gap-24 items-center mb-8 p-0 container';
if ( 2 === count( $ctas ) ) {
$classes .= ' has-two-ctas';
}
if ( $isDark ) { if ( $isDark ) {
$classes .= ' dark text-white'; $classes .= ' dark text-white';
} }
@@ -41,31 +45,23 @@ if ( $vidBlock && strpos( $vidBlock, '<iframe' ) !== false ) {
?> ?>
<div <?php echo wp_kses_post( $wrapper ); ?>> <div <?php echo wp_kses_post( $wrapper ); ?>>
<div class="<?php echo esc_attr( $mediaClass ); ?> <?php echo $vidBlock ? 'aspect-video' : ''; ?>">
<?php if ( $vidBlock ) : ?>
<?php echo wp_kses( $vidBlock, escEmbeds() ); ?>
<?php else : ?>
<img src="<?php echo esc_url( $imgBlock['url'] ); ?>" alt="<?php echo esc_attr( $imgBlock['alt'] ); ?>" class="">
<?php endif; ?>
</div>
<div class="content-wrapper text-balance <?php echo esc_attr( $contentClass ); ?>"> <div class="content-wrapper text-balance <?php echo esc_attr( $contentClass ); ?>">
<?php if ( ! empty( $heading ) ) : ?> <?php if ( ! empty( $heading ) ) : ?>
<h2 class=""><?php echo esc_html( $heading ); ?></h2> <h2 class=""><?php echo esc_html( $heading ); ?></h2>
<?php endif; ?> <?php endif; ?>
<?php if ( ! empty( $subHeading ) ) : ?> <?php if ( ! empty( $subHead ) ) : ?>
<h3 class=""><?php echo esc_html( $subHeading ); ?></h3> <h3 class=""><?php echo esc_html( $subHead ); ?></h3>
<?php endif; ?> <?php endif; ?>
<?php if ( ! empty( $content ) ) : ?> <?php if ( ! empty( $content ) ) : ?>
<div><?php echo wp_kses_post( $content ); ?></div> <div class="font-light"><?php echo wp_kses_post( $content ); ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ( ! empty( $ctas ) ) : ?> <?php if ( ! empty( $ctas ) ) : ?>
<div class="reset flex flex-wrap justify-center lg:justify-start gap-2 mt-8"> <div class="reset flex flex-wrap justify-center lg:justify-start gap-6 mt-8">
<?php foreach ( $ctas as $cta ) : ?>
<?php <?php
foreach ( $ctas as $cta ) :
$ctaLink = $cta['link']; $ctaLink = $cta['link'];
$ctaUrl = $ctaLink['url'] ?? '#'; $ctaUrl = $ctaLink['url'] ?? '#';
$ctaTarget = $ctaLink['target'] ?? '_self'; $ctaTarget = $ctaLink['target'] ?? '_self';
@@ -80,6 +76,7 @@ if ( $vidBlock && strpos( $vidBlock, '<iframe' ) !== false ) {
$ctaURL = '#'; $ctaURL = '#';
} }
?> ?>
<x-button <x-button
btnclasses="button text-center" btnclasses="button text-center"
element="a" element="a"
@@ -95,4 +92,12 @@ if ( $vidBlock && strpos( $vidBlock, '<iframe' ) !== false ) {
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="media-wrapper <?php echo esc_attr( $mediaClass ); ?> <?php echo $vidBlock ? 'aspect-video' : ''; ?>">
<?php if ( $vidBlock ) : ?>
<?php echo wp_kses( $vidBlock, escEmbeds() ); ?>
<?php else : ?>
<img src="<?php echo esc_url( $imgBlock['url'] ); ?>" alt="<?php echo esc_attr( $imgBlock['alt'] ); ?>" class="">
<?php endif; ?>
</div>
</div> </div>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 315 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 352 KiB

+66 -23
View File
@@ -1,8 +1,7 @@
/* Our Work block styles */ /* Our Work block styles */
.our-work { .our-work {
background: background: linear-gradient(
linear-gradient(
90deg, 90deg,
var(--color-cwc-blue-01) 0%, var(--color-cwc-blue-01) 0%,
var(--color-cwc-blue-02) 52%, var(--color-cwc-blue-02) 52%,
@@ -36,9 +35,19 @@
width: 100%; width: 100%;
} }
.our-work__vector-desktop {
display: block;
height: 100%;
width: 100%;
}
.our-work__vector-mobile {
display: none;
}
.our-work__inner { .our-work__inner {
display: grid; display: grid;
gap: clamp(2rem, 4vw, 4rem); gap: clamp(2rem, 4vw, 4rem) 2.5rem;
grid-template-areas: grid-template-areas:
"left content" "left content"
"left bottom"; "left bottom";
@@ -56,6 +65,7 @@
.our-work__content { .our-work__content {
grid-area: content; grid-area: content;
margin-left: 4rem;
max-width: 46.75rem; max-width: 46.75rem;
} }
@@ -96,6 +106,7 @@
.our-work__content p { .our-work__content p {
color: var(--color-white); color: var(--color-white);
font-size: var(--text-18px); font-size: var(--text-18px);
font-weight: 300;
line-height: 1.35; line-height: 1.35;
margin: 0; margin: 0;
} }
@@ -123,7 +134,7 @@
.our-work__media--left { .our-work__media--left {
align-self: start; align-self: start;
grid-area: left; grid-area: left;
margin-top: clamp(4rem, 8vw, 6.75rem); margin-top: clamp(3rem, 6vw, 5rem);
} }
.our-work__media--bottom { .our-work__media--bottom {
@@ -188,23 +199,39 @@
} }
} }
@media (max-width: 640px) { @media (max-width: 767px) {
.our-work { .our-work {
margin-bottom: 2rem; margin-bottom: 2rem;
padding-top: 3.25rem; padding-top: 4.25rem;
}
.our-work.has-left-image.has-bottom-image {
min-height: clamp(44rem, 220.9vw, 55.5rem);
}
.our-work__vector-desktop {
display: none;
}
.our-work__vector-mobile {
display: block;
inset: 0;
position: absolute;
} }
.our-work__inner { .our-work__inner {
gap: 1.4rem; gap: 0;
padding-inline: 2rem !important;
} }
.our-work__content { .our-work__content {
max-width: 20rem; margin-inline: 0;
max-width: 21rem;
text-align: left; text-align: left;
} }
.our-work__content .acf-innerblocks-container { .our-work__content .acf-innerblocks-container {
gap: 1rem; gap: 2.125rem;
} }
.our-work__content h1, .our-work__content h1,
@@ -213,43 +240,59 @@
.our-work__content h4, .our-work__content h4,
.our-work__content h5, .our-work__content h5,
.our-work__content h6 { .our-work__content h6 {
font-size: clamp(2rem, 9vw, 2.45rem); font-size: clamp(2.5rem, 11vw, 2.75rem);
} }
.our-work__content p { .our-work__content p {
font-size: var(--text-14px); font-size: var(--text-16px);
line-height: 1.35; line-height: 1.45;
text-wrap: wrap;
} }
.our-work__content .wp-block-acf-buttons > div { .our-work__content .wp-block-acf-buttons > div {
gap: 0.75rem; flex-wrap: nowrap;
gap: 2rem;
justify-content: flex-start; justify-content: flex-start;
} }
.our-work__content x-button {
flex: 1 1 0;
min-width: 0;
}
.our-work__content .button { .our-work__content .button {
font-size: var(--text-14px); font-size: var(--text-18px);
min-height: 2.25rem; min-height: 2.75rem;
min-width: 6.35rem; min-width: 0;
padding: 0.5rem 0.8rem; padding: 0.65rem 1rem;
white-space: nowrap;
width: 100%;
} }
.our-work__media--left { .our-work__media--left {
justify-self: start; justify-self: start;
margin: 0 0 -3.25rem; margin: 4rem 0 0;
max-width: 9.5rem; max-width: 11.8125rem;
position: relative; position: relative;
width: 55.9%;
z-index: 2; z-index: 2;
} }
.our-work__media--bottom { .our-work__media--bottom {
justify-self: end; justify-self: end;
margin-bottom: -2.35rem; margin: -3.5rem 0 0;
max-width: 20.5rem; max-width: none;
width: calc(100% - 2.25rem); position: relative;
transform: translateX(2rem);
width: 100%;
z-index: 1;
} }
.our-work:not(.has-left-image) .our-work__media--bottom { .our-work:not(.has-left-image) .our-work__media--bottom {
justify-self: center;
width: 100%; width: 100%;
} }
.our-work__image {
object-fit: contain;
}
} }
+14 -1
View File
@@ -11,6 +11,8 @@ namespace CWC;
$left_image = get_field( 'left_image' ); $left_image = get_field( 'left_image' );
$bottom_image = get_field( 'bottom_image' ); $bottom_image = get_field( 'bottom_image' );
$vector_url = get_stylesheet_directory_uri() . '/views/blocks/our-work/our-work-vector.svg';
$mobile_vector_url = get_theme_file_uri( '/static/img/mobile-work-vector.svg' );
$classes = 'our-work mx-break-out mb-36'; $classes = 'our-work mx-break-out mb-36';
@@ -31,14 +33,25 @@ $wrapper = blockWrapperAttributes( $classes, $is_preview );
<section <?php echo wp_kses_post( $wrapper ); ?>> <section <?php echo wp_kses_post( $wrapper ); ?>>
<div class="our-work__vector" aria-hidden="true"> <div class="our-work__vector" aria-hidden="true">
<div class="our-work__vector-mobile">
<img <img
src="<?php echo esc_url( get_stylesheet_directory_uri() . '/views/blocks/our-work/our-work-vector.svg' ); ?>" src="<?php echo esc_url( $mobile_vector_url ); ?>"
alt="" alt=""
loading="lazy" loading="lazy"
role="presentation" role="presentation"
/> />
</div> </div>
<div class="our-work__vector-desktop">
<img
src="<?php echo esc_url( $vector_url ); ?>"
alt=""
loading="lazy"
role="presentation"
/>
</div>
</div>
<div class="our-work__inner container"> <div class="our-work__inner container">
<?php if ( ! empty( $left_image ) ) : ?> <?php if ( ! empty( $left_image ) ) : ?>
<figure class="our-work__media our-work__media--left"> <figure class="our-work__media our-work__media--left">
+61 -2
View File
@@ -18,6 +18,10 @@
width: 100%; width: 100%;
} }
.pull-quote__vector .pull-quote__vector--mobile {
display: none;
}
.pull-quote__content { .pull-quote__content {
padding: 5rem 0; padding: 5rem 0;
position: relative; position: relative;
@@ -30,9 +34,9 @@
font-weight: 500; font-weight: 500;
line-height: 1; line-height: 1;
text-align: center; text-align: center;
text-wrap: balance;
strong, b { strong,
b {
color: var(--color-cwc-blue-02); color: var(--color-cwc-blue-02);
font-weight: 800; font-weight: 800;
} }
@@ -43,3 +47,58 @@
} }
} }
} }
@media (max-width: 767px) {
.pull-quote {
background: color-mix(in oklch, var(--color-cwc-blue-03) 30%, white);
border-bottom: 0;
min-height: calc(89.8vw - 1px);
}
.pull-quote::after {
background: var(--color-cwc-blue-03);
bottom: 0;
content: "";
height: 1rem;
left: 0;
position: absolute;
right: 0;
z-index: 2;
}
.pull-quote .pull-quote__vector {
bottom: 1rem;
}
.pull-quote .pull-quote__vector .pull-quote__vector--bg {
display: none;
}
.pull-quote .pull-quote__vector .pull-quote__vector--mobile {
display: block;
height: 100%;
max-width: none;
object-fit: cover;
width: 100%;
}
.pull-quote .pull-quote__content {
align-items: center;
display: flex;
margin-bottom: 1rem;
min-height: calc(89.8vw - 1rem - 1px);
padding: 2.5rem 2rem;
}
.pull-quote .pull-quote__text {
font-size: clamp(1.25rem, 5.535vw, 1.390625rem);
line-height: clamp(1.5rem, 6.78vw, 1.703125rem);
width: 100%;
}
}
@media (max-width: 360px) {
.pull-quote .pull-quote__content {
padding-block: 0.9375rem;
}
}

Some files were not shown because too many files have changed in this diff Show More