You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/23 06:07:38 UTC

(grails-static-website) branch fix/grails-8-guides-visual-polish created (now 21d47bf049b)

This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a change to branch fix/grails-8-guides-visual-polish
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git


      at 21d47bf049b Document the guides design system

This branch includes the following new commits:

     new 7ed8178d455 Polish the Grails 8 guides catalogue
     new 21d47bf049b Document the guides design system

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(grails-static-website) 01/02: Polish the Grails 8 guides catalogue

Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch fix/grails-8-guides-visual-polish
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 7ed8178d455fdb2b29408dbeaefa813af7ced047
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Jul 23 01:33:43 2026 -0400

    Polish the Grails 8 guides catalogue
    
    Assisted-by: opencode:gpt-5.6-sol playwright
---
 assets/images/restapis-guides.svg                  |  23 +
 assets/javascripts/navigation.js                   |  20 +-
 assets/javascripts/search.js                       |  52 ++-
 assets/stylesheets/screen.css                      | 512 ++++++++++++++++++++-
 .../groovy/website/model/guides/GuidesPage.groovy  | 343 ++++++++------
 .../website/model/guides/GuidesPageSpec.groovy     | 103 +++++
 templates/partials/site-head.html                  |  31 +-
 templates/partials/site-header.html                |   7 +-
 8 files changed, 916 insertions(+), 175 deletions(-)

diff --git a/assets/images/restapis-guides.svg b/assets/images/restapis-guides.svg
new file mode 100644
index 00000000000..86a5cc0a92e
--- /dev/null
+++ b/assets/images/restapis-guides.svg
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="95px" height="95px" viewBox="0 0 95 95" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Catalogue variant: Grails orange on white cards (homepage keeps white restapis.svg). -->
+    <title>restapis</title>
+    <desc>Grails REST APIs icon for guides catalogue cards</desc>
+    <defs></defs>
+    <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="restapis" transform="translate(2.000000, 1.000000)">
+            <circle id="Oval" stroke="#feb672" stroke-width="2.8347" cx="45.848" cy="46.128" r="45.374"></circle>
+            <circle id="Oval" stroke="#feb672" stroke-width="2.8347" cx="45.374" cy="46.374" r="45.374"></circle>
+            <g id="Group" transform="translate(17.000000, 9.000000)" font-size="50" font-family="Courier-Bold, Courier" fill="#feb672" font-weight="bold">
+                <g id="Group-2">
+                    <text id="{">
+                        <tspan x="0.497558594" y="48">{</tspan>
+                    </text>
+                    <text id="{-copy" transform="translate(41.500000, 44.000000) rotate(180.000000) translate(-41.500000, -44.000000) ">
+                        <tspan x="26.4975586" y="62">{</tspan>
+                    </text>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>
diff --git a/assets/javascripts/navigation.js b/assets/javascripts/navigation.js
index 4770d15d7a4..89c29e8fb3f 100644
--- a/assets/javascripts/navigation.js
+++ b/assets/javascripts/navigation.js
@@ -1,4 +1,22 @@
 function show(showId, hideId) {
     document.getElementById(showId).style.display = 'block'
     document.getElementById(hideId).style.display = 'none'
-}
\ No newline at end of file
+}
+
+function toggleNavigation() {
+    const menus = document.getElementById('top-menus')
+    const toggle = document.getElementById('show-navigation-link')
+    if (!menus || !toggle) {
+        return
+    }
+    const isOpen = menus.classList.contains('is-open')
+    if (isOpen) {
+        menus.classList.remove('is-open')
+        toggle.textContent = 'Show Navigation'
+        toggle.setAttribute('aria-expanded', 'false')
+    } else {
+        menus.classList.add('is-open')
+        toggle.textContent = 'Hide Navigation'
+        toggle.setAttribute('aria-expanded', 'true')
+    }
+}
diff --git a/assets/javascripts/search.js b/assets/javascripts/search.js
index 8b670e44fea..f34696e8809 100644
--- a/assets/javascripts/search.js
+++ b/assets/javascripts/search.js
@@ -3,6 +3,7 @@ const guideClassName = 'guide'
 const mobileQueryInputFieldId = 'mobile-query'
 const multiGuideClassName = 'multi-guide'
 const queryInputFieldId = 'query'
+const searchStatusId = 'guides-search-status'
 
 
 const elementsClassNames = [
@@ -12,6 +13,7 @@ const elementsClassNames = [
     'versions-by-grails',
     'tags-by-topic',
     'guides-suggestion',
+    'guides-catalogue',
 ]
 
 window.addEventListener('load', () => {
@@ -62,13 +64,31 @@ function hideElementsByClassName(className) {
 function showElementsByClassName(className) {
     const elements = document.getElementsByClassName(className)
     for (let i = 0; i < elements.length; i++) {
-        elements[i].style.display = 'block'
+        elements[i].style.display = ''
+    }
+}
+
+function hasClass(node, className) {
+    if (!node || node.nodeType !== 1) {
+        return false
+    }
+    if (node.classList) {
+        return node.classList.contains(className)
+    }
+    const value = node.className || ''
+    return (' ' + value + ' ').indexOf(' ' + className + ' ') !== -1
+}
+
+function updateSearchStatus(message) {
+    const status = document.getElementById(searchStatusId)
+    if (status) {
+        status.textContent = message
     }
 }
 
 function titleAtMultiGuide(element) {
     for (let y = 0; y < element.childNodes.length; y++) {
-        if (element.childNodes[y].className === 'title') {
+        if (hasClass(element.childNodes[y], 'title')) {
             return element.childNodes[y].textContent
         }
     }
@@ -78,17 +98,17 @@ function titleAtMultiGuide(element) {
 function versionsAtMultiGuide(element) {
     const versions = []
     for (let y = 0; y < element.childNodes.length; y++) {
-        if (element.childNodes[y].className === 'align-left') {
+        if (hasClass(element.childNodes[y], 'align-left')) {
             const versionDiv = element.childNodes[y]
             let verEl
             let hrefEl
             const tagsArr = []
             for (let x = 0; x < versionDiv.childNodes.length; x++) {
-                if (versionDiv.childNodes[x].className === 'grails-version') {
+                if (hasClass(versionDiv.childNodes[x], 'grails-version')) {
                     verEl = versionDiv.childNodes[x].textContent
                     hrefEl = versionDiv.childNodes[x].getAttribute('href')
                 }
-                if (versionDiv.childNodes[x].className === 'tag') {
+                if (hasClass(versionDiv.childNodes[x], 'tag')) {
                     tagsArr.push(versionDiv.childNodes[x].textContent);
                 }
             }
@@ -105,7 +125,7 @@ function versionsAtMultiGuide(element) {
 function tagsAtGuide(element) {
     const tags = []
     for (let y = 0; y < element.childNodes.length; y++) {
-        if (element.childNodes[y].className === 'tag') {
+        if (hasClass(element.childNodes[y], 'tag')) {
             tags.push(element.childNodes[y].textContent);
         }
     }
@@ -115,12 +135,20 @@ function tagsAtGuide(element) {
 function onQueryChanged() {
     const query = queryValue().trim()
     const resultsDiv = document.getElementsByClassName('search-results')
+    if (!resultsDiv.length) {
+        return
+    }
     if (query === '') {
         showElementsToDisplaySearchResults()
         resultsDiv[0].innerHTML = ''
+        updateSearchStatus('')
         return
     }
 
+    // Hide catalogue and other browse surfaces for every non-empty query
+    // before branching into matches vs no-results.
+    hideElementsToDisplaySearchResults()
+
     const matchingGuides = []
     for (let i = 0; i < allGuides.length; i++) {
         let guide = allGuides[i]
@@ -129,11 +157,13 @@ function onQueryChanged() {
         }
     }
     if (matchingGuides.length > 0) {
-        hideElementsToDisplaySearchResults()
         resultsDiv[0].innerHTML = renderGuideGroup(matchingGuides, query)
+        const count = matchingGuides.length
+        updateSearchStatus(count === 1 ? '1 guide found' : count + ' guides found')
     } else {
         resultsDiv[0].innerHTML =
-            "<div class='guide-group'><div class='guide-group-header'><h2>No results found</h2></div></div>"
+            "<div class='guide-group'><div class='guide-group-header'><h3>No results found</h3></div></div>"
+        updateSearchStatus('No results found')
     }
 }
 
@@ -186,7 +216,7 @@ function renderGuideGroup(guides, query) {
     return `
     <div class="guide-group">
       <div class="guide-group-header">
-        <h2>Guides Filtered by: ${queryValue()}</h2>
+        <h3>Guides Filtered by: ${queryValue()}</h3>
       </div>
       <ul>
         ${items}
@@ -197,7 +227,7 @@ function renderGuideGroup(guides, query) {
 
 function renderGuideAsHtmlLi(guide, query) {
     const hiddenTags = (tags = []) =>
-        tags.map(tag => `<span style="display: none" class="tag">${tag}</span>`).join('')
+        tags.map(tag => `<span class="tag">${tag}</span>`).join('')
 
     // Multi-guide (no guide.tags)
     if (guide.tags == null) {
@@ -205,7 +235,7 @@ function renderGuideAsHtmlLi(guide, query) {
         const versionsHtml = guide.versions
             .filter(v => titleMatched || doesTagsMatchQuery(v.tags, query))
             .map(v => `
-              <div class="align-left">
+              <div class="align-left guides-version-chip">
                 <a class="grails-version" href="${v.href}">${v.grailsVersion}</a>
                 ${hiddenTags(v.tags)}
               </div>`
diff --git a/assets/stylesheets/screen.css b/assets/stylesheets/screen.css
index ba34feca9e5..5890c8d47cd 100644
--- a/assets/stylesheets/screen.css
+++ b/assets/stylesheets/screen.css
@@ -524,10 +524,41 @@ body.gorm .main-header {
 	background-color: #f5f5f5 !important;
 }
 
-#show-navigation-link {
+#show-navigation-link,
+button.navigation-toggle {
 	margin-top: 12px;
 	margin-bottom: 20px;
 	display: block;
+	width: 100%;
+	box-sizing: border-box;
+	background: transparent;
+	border: 1px solid #255AA8;
+	border-radius: 4px;
+	color: #255AA8;
+	font-family: Roboto, 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+	font-size: 16px;
+	line-height: 1.3;
+	padding: 12px 16px;
+	cursor: pointer;
+	text-decoration: none;
+	text-align: center;
+}
+
+#show-navigation-link:hover,
+#show-navigation-link:focus-visible,
+button.navigation-toggle:hover,
+button.navigation-toggle:focus-visible {
+	background-color: #255AA8;
+	color: #ffffff;
+	outline: 2px solid #255AA8;
+	outline-offset: 2px;
+}
+
+@media screen and (min-width: 961px) {
+	#show-navigation-link,
+	button.navigation-toggle {
+		display: none;
+	}
 }
 
 .secondary-menu ul {
@@ -541,10 +572,17 @@ body.gorm .main-header {
 #top-menus {
 	margin-top: 17px;
 	display: none;
-	background-color: gray;
+	/* #666666 with white link text ~5.74:1 (AA); gray/#808080 failed open-nav contrast */
+	background-color: #666666;
 	overflow: auto;
 	padding: 10px;
 }
+/* Class-based open state only applies below the desktop breakpoint.
+   Desktop media rule remains authoritative so a mobile close cannot
+   leave an inline display:none that suppresses nav after resize. */
+#top-menus.is-open {
+	display: block;
+}
 #top-menus a {
 	color: #ffffff;
 }
@@ -750,7 +788,8 @@ article.quote,
 	margin-right: 18px;
 }
 
-.guide-group h2 {
+.guide-group h2,
+.guide-group h3 {
 	height: 144px;
 	text-transform: uppercase;
 	font-size: 18px;
@@ -1294,7 +1333,8 @@ article.quote h2 {
 		float: left;
 		display: block;
 	}
-	#top-menus {
+	#top-menus,
+	#top-menus.is-open {
 		margin-top: 0;
 		display: block;
 		background-color: #ffffff;
@@ -1788,19 +1828,7 @@ body.grails3-plugins li.label a {
 	border-top-left-radius: 5px;
 	border-top-right-radius: 5px;
 	display: block;
-	/* Fallback (could use .jpg/.png alternatively) */
 	background-color: #F4F4F4;
-	/* SVG fallback for IE 9 (could be data URI, or could use filter) */
-	background-image: url('githubstar.png');
-	/* Safari 4, Chrome 1-9, iOS 3.2-4.3, Android 2.1-3.0 */
-	background-image: -webkit-gradient(linear, left top, right top, from(#F4F4F4), to(#F9F9F9));
-	/* Safari 5.1, iOS 5.0-6.1, Chrome 10-25, Android 4.0-4.3 */
-	background-image: -webkit-linear-gradient(left, #F4F4F4, #F9F9F9);
-	/* Firefox 3.6 - 15 */
-	background-image: -moz-linear-gradient(left, #F4F4F4, #F9F9F9);
-	/* Opera 11.1 - 12 */
-	background-image: -o-linear-gradient(left, #F4F4F4, #F9F9F9);
-	/* Opera 15+, Chrome 25+, IE 10+, Firefox 16+, Safari 6.1+, iOS 7+, Android 4.4+ */
 	background-image: linear-gradient(to right, #F4F4F4, #F9F9F9);
 	text-align: right;
 	margin-top: 20px;
@@ -2042,7 +2070,6 @@ ul.labels li.label a {
 	background-color: #FFFFFF;
 	color: #7c7c7c;
 	display: block;
-	background-color: white-smoke;
 }
 
 .breadcrumbs {
@@ -2606,3 +2633,454 @@ body.guide article.guide .tip td.content {
 	}
 }
 
+/* ==========================================================================
+   Guides catalogue (.guides-page) - index + version pages
+   ----------------------------------------------------------------------
+   Tokens use existing brand values and stay scoped so non-guides pages keep
+   their legacy layout. Featured catalogue leads; discovery follows.
+   ========================================================================== */
+
+.guides-page {
+	--guides-color-brand: #255AA8;
+	--guides-color-brand-hover: #1d4684;
+	--guides-color-brand-soft: rgba(37, 90, 168, 0.08);
+	--guides-color-accent: #feb672;
+	--guides-color-accent-wash: rgba(254, 182, 114, 0.2);
+	--guides-color-surface: #ffffff;
+	--guides-color-page: #F5F5F5;
+	/* #666666 on white ~5.74:1; meets WCAG AA for normal text */
+	--guides-color-text: #666666;
+	--guides-color-text-strong: #4f4f4f;
+	--guides-color-on-brand: #ffffff;
+	/* Dark ink on pale gold/orange tag chips for AA contrast */
+	--guides-color-tag-text: #3d2914;
+	--guides-color-border: #d8d8d8;
+	--guides-color-control-border: #767676;
+	--guides-content-max: 1141px;
+	--guides-shadow-card: 0 8px 24px rgba(37, 90, 168, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
+	--guides-space-2: 8px;
+	--guides-space-3: 12px;
+	--guides-space-4: 16px;
+	--guides-space-5: 24px;
+	--guides-space-6: 32px;
+	--guides-radius: 4px;
+	--guides-card-min: 280px;
+	/* Keep catalogue content clear of the fixed Kapa launcher */
+	--guides-kapa-safe: 68px;
+	padding-bottom: calc(var(--guides-space-6) + var(--guides-kapa-safe));
+}
+
+.guides-page .tag {
+	display: none;
+}
+
+@media screen and (max-width: 960px) {
+	.guides-header {
+		min-height: 92px;
+		padding: 0;
+		overflow: hidden;
+	}
+
+	.guides-header .content {
+		display: flex;
+		align-items: center;
+		min-height: 92px;
+	}
+
+	.guides-header h1 {
+		float: none;
+		margin: 0;
+	}
+}
+
+.guides-page .guides-section-title {
+	margin-top: var(--guides-space-6);
+	margin-bottom: var(--guides-space-5);
+	color: var(--guides-color-on-brand);
+	background-color: var(--guides-color-brand);
+	border-bottom: 2px solid var(--guides-color-accent);
+	border-radius: var(--guides-radius);
+	box-shadow: 0 8px 18px rgba(37, 90, 168, 0.14);
+}
+
+.guides-page .guides-featured .guides-section-title,
+.guides-page .guides-version-catalogue .guides-section-title {
+	margin: 0 calc(var(--guides-space-5) * -1) var(--guides-space-5);
+	border-radius: var(--guides-radius) var(--guides-radius) 0 0;
+}
+
+.guides-page .guides-featured-intro {
+	margin: calc(var(--guides-space-5) * -1) 0 var(--guides-space-5) 0;
+	padding: 0 var(--guides-space-4);
+	font-size: 16px;
+	line-height: 1.5;
+	color: var(--guides-color-text);
+}
+
+.guides-page .guides-featured {
+	margin-top: var(--guides-space-5);
+	padding: 0 var(--guides-space-5) var(--guides-space-5);
+	background:
+		radial-gradient(circle at 92% 0, var(--guides-color-accent-wash), transparent 34%),
+		linear-gradient(180deg, var(--guides-color-brand-soft), var(--guides-color-surface) 42%);
+	border: 1px solid var(--guides-color-border);
+	border-radius: var(--guides-radius);
+	box-sizing: border-box;
+}
+
+.guides-page .guides-card-list {
+	list-style: none;
+	margin: 0 0 var(--guides-space-5) 0;
+	padding: 0;
+	display: grid;
+	grid-template-columns: 1fr;
+	gap: var(--guides-space-3);
+}
+
+.guides-page .guides-card {
+	list-style: none;
+	margin: 0;
+	padding: var(--guides-space-4);
+	background-color: var(--guides-color-surface);
+	border-top: 2px solid var(--guides-color-accent);
+	border-radius: 0 0 var(--guides-radius) var(--guides-radius);
+	box-shadow: var(--guides-shadow-card);
+}
+
+.guides-page .guides-card-title {
+	display: block;
+	margin-bottom: var(--guides-space-2);
+	font-family: 'Roboto-Medium', Roboto, 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+	font-size: 16px;
+	font-weight: normal;
+	text-transform: none;
+	letter-spacing: 0.2px;
+	color: var(--guides-color-text-strong);
+	line-height: 1.35;
+}
+
+.guides-page .guides-card-meta {
+	display: block;
+	margin-bottom: var(--guides-space-2);
+	font-size: 14px;
+	line-height: 1.4;
+	color: var(--guides-color-text);
+}
+
+.guides-page .guides-card-action {
+	display: inline-block;
+	font-size: 14px;
+	font-family: 'Roboto-Medium', Roboto, sans-serif;
+	text-decoration: none;
+	color: var(--guides-color-brand);
+}
+
+.guides-page .guides-card-action:hover,
+.guides-page .guides-card-action:focus {
+	text-decoration: underline;
+	color: var(--guides-color-brand-hover);
+}
+
+.guides-page .guides-catalogue {
+	margin-bottom: var(--guides-space-6);
+}
+
+.guides-page .guides-category-grid {
+	/* Multi-column masonry: cards keep natural height (no stretched interiors) */
+	column-count: 1;
+	column-gap: var(--guides-space-5);
+	margin: 0 0 var(--guides-space-5) 0;
+}
+
+.guides-page .guides-category-grid .guide-group,
+.guides-page .guides-category-grid .guides-category-card {
+	display: inline-block;
+	width: 100%;
+	margin: 0 0 var(--guides-space-5) 0;
+	height: auto;
+	box-sizing: border-box;
+	border-radius: var(--guides-radius);
+	box-shadow: var(--guides-shadow-card);
+	break-inside: avoid;
+	page-break-inside: avoid;
+	-webkit-column-break-inside: avoid;
+}
+
+.guides-page .guides-category-grid .guide-group-header {
+	height: auto;
+	min-height: 0;
+	padding: var(--guides-space-4);
+	display: flex;
+	align-items: center;
+	gap: var(--guides-space-3);
+}
+
+.guides-page .guides-category-grid .guide-group-header img {
+	float: none;
+	flex: 0 0 64px;
+	width: 64px;
+	height: 64px;
+	margin-right: 0;
+}
+
+.guides-page .guides-category-grid .guide-group h2,
+.guides-page .guides-category-grid .guide-group h3 {
+	height: auto;
+	min-height: 0;
+	padding-top: 0;
+	margin: 0;
+	font-size: 16px;
+	line-height: 1.3;
+}
+
+/* Category title links: brand blue meets AA at 16px on white (~4.6:1+) */
+.guides-page .guides-category-grid .guide-group-header a {
+	color: var(--guides-color-brand);
+	text-decoration: none;
+}
+
+.guides-page .guides-category-grid .guide-group-header a:hover,
+.guides-page .guides-category-grid .guide-group-header a:focus {
+	color: var(--guides-color-brand-hover);
+	text-decoration: underline;
+}
+
+.guides-page .guides-category-grid .guide-group li,
+.guides-page .guides-category-list .guides-item {
+	min-height: 0;
+	padding: var(--guides-space-3) var(--guides-space-4);
+	border-top: 2px solid var(--guides-color-page);
+	font-size: 16px;
+	line-height: 1.35;
+}
+
+.guides-page .guides-category-list {
+	padding: 0;
+	margin: 0;
+	list-style: none;
+}
+
+.guides-page .multi-guide {
+	display: flex;
+	flex-wrap: wrap;
+	gap: var(--guides-space-2);
+}
+
+.guides-page .multi-guide .title {
+	flex-basis: 100%;
+}
+
+.guides-page .guides-version-chip {
+	float: none;
+}
+
+.guides-page .guides-version-chip .grails-version,
+.guides-page .guide-group li a.grails-version {
+	float: none;
+	display: inline-block;
+	padding: 4px 10px;
+	margin: 0;
+	font-size: 12px;
+	line-height: 1.2;
+	letter-spacing: 0.4px;
+	text-transform: uppercase;
+	text-decoration: none;
+	color: var(--guides-color-on-brand);
+	background-color: var(--guides-color-brand);
+	border-radius: var(--guides-radius);
+	background-image: none;
+}
+
+.guides-page .guides-version-chip .grails-version:hover,
+.guides-page .guides-version-chip .grails-version:focus,
+.guides-page .guide-group li a.grails-version:hover,
+.guides-page .guide-group li a.grails-version:focus {
+	background-color: var(--guides-color-brand-hover);
+	color: var(--guides-color-on-brand);
+	text-decoration: none;
+}
+
+.guides-page .guides-discovery {
+	margin-top: var(--guides-space-5);
+	margin-bottom: var(--guides-space-6);
+}
+
+.guides-page .guides-discovery-layout {
+	display: grid;
+	grid-template-columns: 1fr;
+	gap: var(--guides-space-5);
+	align-items: start;
+}
+
+.guides-page .guides-discovery-layout-single {
+	display: block;
+}
+
+.guides-page .guides-discovery .tag-cloud {
+	max-height: 360px;
+	overflow-y: auto;
+	padding-right: var(--guides-space-2);
+	scrollbar-gutter: stable;
+}
+
+.guides-page .guides-search {
+	margin-top: 0;
+	margin-bottom: var(--guides-space-4);
+}
+
+.guides-page .guides-search-label {
+	position: absolute;
+	width: 1px;
+	height: 1px;
+	padding: 0;
+	margin: -1px;
+	overflow: hidden;
+	clip: rect(0, 0, 0, 0);
+	white-space: nowrap;
+	border: 0;
+}
+
+.guides-page .guides-search .search {
+	margin-top: 0;
+	margin-bottom: 0;
+}
+
+.guides-page .guides-search .search input {
+	box-sizing: border-box;
+	width: 100%;
+	max-width: 100%;
+	height: 48px;
+	padding: var(--guides-space-3) var(--guides-space-4);
+	border: 1px solid var(--guides-color-control-border);
+	border-radius: var(--guides-radius);
+	font-size: 16px;
+	color: var(--guides-color-text-strong);
+	background-color: var(--guides-color-surface);
+}
+
+/* Visible focus for mouse and keyboard (not only :focus-visible) */
+.guides-page .guides-search .search input:focus,
+.guides-page .guides-search .search input:focus-visible {
+	outline: 2px solid var(--guides-color-brand);
+	outline-offset: 2px;
+	border-color: var(--guides-color-brand);
+}
+
+.guides-page .search-results .guide-group-header h3 {
+	color: var(--guides-color-text-strong);
+}
+
+.guides-page .guides-visually-hidden,
+.guides-page .guides-search-status {
+	position: absolute;
+	width: 1px;
+	height: 1px;
+	padding: 0;
+	margin: -1px;
+	overflow: hidden;
+	clip: rect(0, 0, 0, 0);
+	white-space: nowrap;
+	border: 0;
+}
+
+.guides-page .guides-latest .column-header,
+.guides-page .guides-discovery .column-header {
+	margin-top: var(--guides-space-5);
+}
+
+.guides-page a:focus,
+.guides-page a:focus-visible,
+.guides-page button:focus,
+.guides-page button:focus-visible,
+.guides-page input:focus,
+.guides-page input:focus-visible {
+	outline: 2px solid var(--guides-color-brand);
+	outline-offset: 2px;
+}
+
+/* Keep footer sentences clear of the fixed 44px Kapa launcher on narrow screens */
+@media screen and (max-width: 960px) {
+	footer {
+		padding-right: 68px;
+		box-sizing: border-box;
+	}
+}
+
+.guides-page .guides-card:focus-within {
+	box-shadow: 0 0 0 2px var(--guides-color-brand);
+}
+
+.guides-page .guide-group li a {
+	color: var(--guides-color-text);
+}
+
+.guides-page .tags-by-topic li a {
+	color: var(--guides-color-tag-text);
+}
+
+/* Kapa Ask AI: keep launcher clear of content on narrow viewports */
+#kapa-widget-container,
+.kapa-widget-container {
+	z-index: 40;
+}
+
+@media screen and (min-width: 768px) {
+	.guides-page .guides-card-list {
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+		gap: var(--guides-space-4);
+	}
+
+	.guides-page .guides-category-grid {
+		column-count: 2;
+		column-gap: var(--guides-space-5);
+	}
+}
+
+@media screen and (min-width: 961px) {
+	.guides-page .guides-discovery-layout {
+		grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
+		gap: var(--guides-space-6);
+	}
+
+	.guides-page .guides-card-list {
+		grid-template-columns: repeat(2, minmax(0, 1fr));
+	}
+
+	.guides-page .guides-featured > .guides-card-list {
+		grid-template-columns: repeat(3, minmax(0, 1fr));
+	}
+
+	.guides-page .guides-category-grid {
+		column-count: 3;
+		column-gap: var(--guides-space-5);
+	}
+}
+
+@media (prefers-reduced-motion: reduce) {
+	html,
+	body {
+		scroll-behavior: auto;
+	}
+
+	a,
+	button,
+	#show-navigation-link,
+	button.navigation-toggle {
+		transition: none;
+	}
+
+	button:active,
+	#show-navigation-link:active,
+	button.navigation-toggle:active {
+		transform: none;
+	}
+
+	.guides-page,
+	.guides-page a,
+	.guides-page button,
+	.guides-page input,
+	.guides-page .guides-card {
+		transition: none;
+	}
+}
+
diff --git a/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy b/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
index e9cc9bfe02f..6909f4c9c74 100644
--- a/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
+++ b/buildSrc/src/main/groovy/website/model/guides/GuidesPage.groovy
@@ -57,8 +57,8 @@ class GuidesPage {
     private static final Pattern VERSION_TAG_PATTERN = ~/^grails\d+$/
 
     /**
-     * The category-image map. Categories listed here are rendered both on the
-     * guides index page AND as standalone category pages under
+     * Display order for the category grid. Categories listed here are rendered
+     * both on the guides index page AND as standalone category pages under
      * {@code /guides/categories/<slug>.html}. Categories that exist in
      * {@code conf/guides.yml} but are NOT listed here are reachable only via
      * tags / search / Latest Guides.
@@ -92,9 +92,19 @@ class GuidesPage {
             spa: new Category(name: 'Frontend SPA', image: 'react.svg'),
             testing: new Category(name: 'Grails Testing', image: 'testing.svg'),
             weblayer: new Category(name: 'Web Layer', image: 'views.svg'),
-            restapis: new Category(name: 'Grails REST APIs', image: 'restapis.svg'),
+            restapis: new Category(name: 'Grails REST APIs', image: 'restapis-guides.svg'),
     ]
 
+    /** Ordered category tracks for the responsive catalogue grid. */
+    static final List<String> CATEGORY_GRID_ORDER = [
+            'apprentice', 'advanced',
+            'weblayer', 'restapis',
+            'devops', 'gorm',
+            'testing', 'security',
+            'spa', 'async',
+            'cloud',
+    ].asImmutable()
+
 
     @CompileDynamic
     static String renderGuide(Guide guide, String versionFilter = null) {
@@ -108,7 +118,7 @@ class GuidesPage {
                 : Collections.emptyList() as List<String>
 
         renderHtml {
-            li {
+            li(class: 'guides-item') {
                 if (guide instanceof SingleGuide) {
                     String version = versionFilter ?: guide.versionNumber
                     a(
@@ -116,10 +126,7 @@ class GuidesPage {
                             href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html", guide.title
                     )
                     guide.tags.each {
-                        span(
-                                style: 'display: none',
-                                class: 'tag', it
-                        )
+                        span(class: 'tag', it)
                     }
                 } else if (multi) {
                     // When a version filter is active (e.g. /versions/8.html), collapse
@@ -132,10 +139,7 @@ class GuidesPage {
                                 multiGuide.title
                         )
                         filteredTags.each {
-                            span(
-                                    style: 'display: none',
-                                    class: 'tag', it
-                            )
+                            span(class: 'tag', it)
                         }
                     } else {
                         div(class: (guide.tags.contains('quick-cast') ? 'quick-cast multi-guide' : 'multi-guide')) {
@@ -143,7 +147,7 @@ class GuidesPage {
                             // Newest majors first so Grails 8 is the first chip readers see.
                             for (def grailsVersion : versionKeys) {
                                 def tagList = multiGuide.grailsMayorVersionTags[grailsVersion] as Set<String>
-                                div(class: 'align-left') {
+                                div(class: 'align-left guides-version-chip') {
                                     a(
                                             class: 'grails-version',
                                             href: "$GUIDES_URL/${multiGuide.name}/${grailsVersion}/guide/index.html"
@@ -151,11 +155,7 @@ class GuidesPage {
                                         mkp.yield("grails$grailsVersion")
                                     }
                                     tagList.each {
-                                        span(
-                                                style: 'display: none',
-                                                class: 'tag',
-                                                it
-                                        )
+                                        span(class: 'tag', it)
                                     }
                                 }
                             }
@@ -175,14 +175,14 @@ class GuidesPage {
             String version = null
     ) {
         renderHtml {
-            div(class: 'header-bar chalices-bg') {
+            div(class: 'header-bar chalices-bg guides-header') {
                 div(class: 'content') {
                     if (tag || category || version) {
                         h1 {
                             a(href: '[%url]/index.html', 'Guides')
                             if (tag) {
                                 mkp.yield(" → #$tag.title")
-                            } else if(category) {
+                            } else if (category) {
                                 mkp.yield(" → $category.name")
                             } else if (version) {
                                 mkp.yield(" → Grails $version")
@@ -193,53 +193,99 @@ class GuidesPage {
                     }
                 }
             }
-            div(class: 'content') {
+            div(class: 'content guides-page') {
                 omitEmptyAttributes = true
                 omitNullAttributes = true
-                div(class: 'two-columns') {
-                    div(class: 'column') {
-                        mkp.yieldUnescaped(rightColumn(tag, category, version, guides))
+                if (tag || category) {
+                    div(class: 'two-columns') {
+                        div(class: 'column') {
+                            mkp.yieldUnescaped(rightColumn(tag, category, version, guides))
+                        }
+                        div(class: 'column') {
+                            if (tag) {
+                                mkp.yieldUnescaped(guideGroupByTag(tag, guides))
+                            } else {
+                                mkp.yieldUnescaped(
+                                        guideGroupByCategory(
+                                                category,
+                                                guides.findAll { it.category == category.name },
+                                                false
+                                        )
+                                )
+                            }
+                        }
                     }
-                    div(class: 'column') {
-                        // leftColumn only renders the clouds (and thus uses the
-                        // version list) on the index and version pages; skip the
-                        // scan/sort of availableVersions for tag/category pages.
-                        def versions = (tag || category) ? [] : GuidesPage.availableVersions(guides)
-                        mkp.yieldUnescaped(leftColumn(tag, category, tags, versions, version))
-                        if (tag) {
-                            mkp.yieldUnescaped(guideGroupByTag(tag, guides))
-                        } else if (category) {
-                            mkp.yieldUnescaped(
-                                    guideGroupByCategory(
-                                            category,
-                                            guides.findAll { it.category == category.name },
-                                            false
-                                    )
-                            )
-                        } else if (!version) {
-                            // Version pages render their list in the left column
-                            // (see rightColumn); this column only holds the clouds.
+                } else {
+                    // Index and version pages: promote the modern catalogue first,
+                    // then keep discovery close enough to use before the long grid.
+                    String featuredVersion = version ?: FEATURED_VERSION
+                    List<Guide> featured = guidesForVersionList(featuredVersion, guides)
+                    if (version) {
+                        mkp.yieldUnescaped(guidesForVersion(version, guides))
+                        if (featured) {
+                            mkp.yieldUnescaped(categoryGrid(guides, featuredVersion, true))
+                        }
+                        mkp.yieldUnescaped(discoverySection(tag, category, tags, guides, version))
+                    } else {
+                        if (featured) {
+                            mkp.yieldUnescaped(featuredVersionSection(featuredVersion, featured, false))
+                        }
+                        mkp.yieldUnescaped(discoverySection(tag, category, tags, guides, version))
+                        if (featured) {
+                            mkp.yieldUnescaped(categoryGrid(guides, featuredVersion, false))
+                        } else {
+                            // No featured-version guides yet - fall back to the full
+                            // unfiltered category grid so the page is never empty.
+                            mkp.yieldUnescaped(categoryGrid(guides, null, false))
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Search, latest guides, and version/tag clouds. Rendered after the featured
+     * catalogue so Grails 8 content leads the page.
+     */
+    @CompileDynamic
+    static String discoverySection(
+            Tag tag,
+            Category category,
+            Set<Tag> tags,
+            List<Guide> guides,
+            String version = null
+    ) {
+        if (tag || category) {
+            return ''
+        }
+        List<String> versions = GuidesPage.availableVersions(guides)
+        renderHtml {
+            section(class: 'guides-discovery', 'aria-labelledby': 'guides-discovery-heading') {
+                h2(id: 'guides-discovery-heading', class: 'column-header guides-section-title', 'Find Guides')
+                div(class: version ? 'guides-discovery-layout guides-discovery-layout-single' : 'guides-discovery-layout') {
+                    if (!version) {
+                        div(class: 'guides-discovery-primary') {
+                            mkp.yieldUnescaped(searchBox(tag, category, version))
                             div(class: 'search-results') {
                                 mkp.yieldUnescaped('')
                             }
+                            // Dedicated live region for screen readers (not the visible results box).
+                            div(
+                                    id: 'guides-search-status',
+                                    class: 'guides-search-status guides-visually-hidden',
+                                    role: 'status',
+                                    'aria-live': 'polite',
+                                    'aria-atomic': 'true'
+                            ) {
+                                mkp.yieldUnescaped('')
+                            }
+                            mkp.yieldUnescaped(GuidesPage.latestGuides(guides))
                         }
                     }
-                }
-                // Featured modern catalogue: every guide published for the current
-                // major, un-capped, sits above the legacy category grid so readers
-                // hit Grails 8 first. On version pages the same grid is rendered
-                // filtered to that major so /versions/8.html is a full catalogue
-                // (flat list in the sidebar + categorized body), not a partial peek.
-                if (!(tag || category)) {
-                    String featuredVersion = version ?: FEATURED_VERSION
-                    List<Guide> featured = guidesForVersionList(featuredVersion, guides)
-                    if (featured) {
-                        mkp.yieldUnescaped(featuredVersionSection(featuredVersion, featured, version != null))
-                        mkp.yieldUnescaped(categoryGrid(guides, featuredVersion, version != null))
-                    } else if (!version) {
-                        // No featured-version guides yet - fall back to the full
-                        // unfiltered category grid so the page is never empty.
-                        mkp.yieldUnescaped(categoryGrid(guides, null, false))
+                    div(class: 'guides-discovery-secondary') {
+                        mkp.yieldUnescaped(GuidesPage.versionCloud(versions))
+                        mkp.yieldUnescaped(GuidesPage.tagCloud(tags))
                     }
                 }
             }
@@ -280,21 +326,26 @@ class GuidesPage {
     @CompileDynamic
     static String latestGuides(List<Guide> guides) {
         renderHtml {
-            div(class: 'latest-guides') {
+            div(class: 'latest-guides guides-latest') {
                 h3(class: 'column-header', 'Latest Guides')
-                ul {
+                ul(class: 'guides-card-list') {
                     guides.findAll { it.publicationDate }
                             .sort { a, b -> b.publicationDate <=> a.publicationDate }
                             .take(NUMBER_OF_LATEST_GUIDES)
                             .each { guide ->
-                                li {
-                                    b(guide.title)
-                                    span {
+                                li(class: 'guides-card') {
+                                    // Nested under the Latest Guides h3 section heading.
+                                    h4(class: 'guides-card-title', guide.title)
+                                    span(class: 'guides-card-meta') {
                                         mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate))
                                         mkp.yield(' - ')
                                         mkp.yield(guide.category)
                                     }
-                                    a(href: "$GUIDES_URL/${guide.name}/${guide.versionNumber}/guide/index.html", 'Read More')
+                                    a(
+                                            class: 'guides-card-action',
+                                            href: "$GUIDES_URL/${guide.name}/${guide.versionNumber}/guide/index.html",
+                                            'Read More'
+                                    )
                                 }
                             }
                 }
@@ -340,9 +391,16 @@ class GuidesPage {
     static String searchBox(Tag tag, Category category, String version = null) {
         if (!(tag || category || version)) {
             renderHtml {
-                div(class: 'searchbox', style: 'margin-top: 50px !important') {
-                    div(class: 'search', style: 'margin-bottom: 0px !important') {
-                        input(type: 'text', id: 'query', placeholder: 'SEARCH')
+                div(class: 'searchbox guides-search') {
+                    div(class: 'search') {
+                        label(class: 'guides-search-label', 'for': 'query', 'Search guides')
+                        input(
+                                type: 'search',
+                                id: 'query',
+                                name: 'query',
+                                placeholder: 'SEARCH',
+                                'aria-label': 'Search guides'
+                        )
                     }
                 }
             }
@@ -368,22 +426,25 @@ class GuidesPage {
             return ''
         }
         inCategory = sortGuidesForDisplay(inCategory, versionFilter ?: FEATURED_VERSION)
+        // cssStyle retained for call-site compatibility; presentation lives in CSS.
         renderHtml {
-            div(class: 'guide-group', style: cssStyle) {
+            div(class: 'guide-group guides-category-card') {
                 div(class: 'guide-group-header') {
                     img(
                             src: "[%url]/images/$category.image" as String,
-                            alt: category.name
+                            alt: ''
                     )
-                    if (linkToCategory)  {
+                    if (linkToCategory) {
+                        // Grid cards nest under the catalogue section h2.
                         a(href: "$GUIDES_URL/categories/${category.slug}.html") {
-                            h2(category.name)
+                            h3(category.name)
                         }
                     } else {
+                        // Standalone category pages: this is the page-level heading.
                         h2(category.name)
                     }
                 }
-                ul {
+                ul(class: 'guides-category-list') {
                     inCategory.each {
                         mkp.yieldUnescaped(GuidesPage.renderGuide(it, onlyMatchingVersion ? versionFilter : null))
                     }
@@ -393,7 +454,7 @@ class GuidesPage {
     }
 
     /**
-     * Two-column category grid used on the index and on version pages. When
+     * Responsive category grid used on the index and on version pages. When
      * {@code onlyMatchingVersion} is true, each category only lists guides that
      * ship the given major (so /versions/8.html surfaces the full Grails 8 set
      * organized by track). When false, every guide stays visible but guides for
@@ -401,76 +462,71 @@ class GuidesPage {
      */
     @CompileDynamic
     static String categoryGrid(List<Guide> guides, String preferVersion, boolean onlyMatchingVersion) {
-        List<List<Category>> pairs = [
-                [categories.apprentice, categories.advanced],
-                [categories.weblayer, categories.restapis],
-                [categories.devops, categories.gorm],
-                [categories.testing, categories.security],
-                [categories.spa, categories.async],
-                [categories.cloud],
-        ]
-        StringBuilder html = new StringBuilder()
-        boolean firstPair = true
-        for (List<Category> pair : pairs) {
-            Category leftCat = pair[0]
-            Category rightCat = pair.size() > 1 ? pair[1] : null
-            String left = leftCat
-                    ? guideGroupByCategory(
-                    leftCat, guides, true, firstPair ? 'margin-top: 0' : '', preferVersion, onlyMatchingVersion)
-                    : ''
-            String right = rightCat
-                    ? guideGroupByCategory(
-                    rightCat, guides, true, firstPair ? 'margin-top: 0' : '', preferVersion, onlyMatchingVersion)
-                    : ''
-            if (!left && !right) {
+        String heading = onlyMatchingVersion && preferVersion
+                ? "Grails $preferVersion by Category"
+                : 'Browse by Category'
+        StringBuilder cards = new StringBuilder()
+        for (String key : CATEGORY_GRID_ORDER) {
+            Category cat = categories[key]
+            if (!cat) {
                 continue
             }
-            html << renderHtml {
-                div(class: 'two-columns') {
-                    div(class: 'column') {
-                        if (left) {
-                            mkp.yieldUnescaped(left)
-                        }
-                    }
-                    div(class: 'column') {
-                        if (right) {
-                            mkp.yieldUnescaped(right)
-                        }
-                    }
+            String card = guideGroupByCategory(
+                    cat, guides, true, '', preferVersion, onlyMatchingVersion)
+            if (card) {
+                cards << card
+            }
+        }
+        if (!cards.length()) {
+            return ''
+        }
+        renderHtml {
+            section(
+                    class: 'guides-catalogue',
+                    'aria-labelledby': 'guides-catalogue-heading'
+            ) {
+                h2(
+                        id: 'guides-catalogue-heading',
+                        class: 'column-header guides-section-title',
+                        heading
+                )
+                div(class: 'guides-category-grid') {
+                    mkp.yieldUnescaped(cards.toString())
                 }
             }
-            firstPair = false
         }
-        html.toString()
     }
 
     /**
      * Full-width "Grails N Guides" block listing every guide for that major,
      * newest first, with no take() cap. On the index this is the featured modern
-     * catalogue; on a version page it sits under the sidebar list as the
-     * categorized companion (the sidebar list itself is still rendered by
-     * {@link #guidesForVersion}).
+     * catalogue leading the page before discovery content.
      */
     @CompileDynamic
     static String featuredVersionSection(String version, List<Guide> featured, boolean onVersionPage) {
-        // On the version page the left column already has the flat list; the
-        // body below is the categorized grid only. On the index we lead with
-        // this flat "all of them" list so nothing modern is buried.
+        // On the version page the version list is rendered by guidesForVersion.
         if (onVersionPage) {
             return ''
         }
         renderHtml {
-            div(class: 'latest-guides featured-version-guides', style: 'margin-top: 0') {
-                h3(class: 'column-header', "Grails $version Guides")
-                p(style: 'margin: -30px 0 30px 0; padding-left: 28px;') {
+            section(
+                    class: 'latest-guides featured-version-guides guides-featured',
+                    'aria-labelledby': 'guides-featured-heading'
+            ) {
+                h2(
+                        id: 'guides-featured-heading',
+                        class: 'column-header guides-section-title',
+                        "Grails $version Guides"
+                )
+                p(class: 'guides-featured-intro') {
                     mkp.yield("Every guide published for Grails $version. ")
                     a(href: "$GUIDES_URL/versions/${version}.html", "Browse by version →")
                 }
-                ul {
+                ul(class: 'guides-card-list') {
                     featured.each { guide ->
-                        li {
-                            b(guide.title)
-                            span {
+                        li(class: 'guides-card') {
+                            h3(class: 'guides-card-title', guide.title)
+                            span(class: 'guides-card-meta') {
                                 if (guide.publicationDate) {
                                     mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate))
                                     mkp.yield(' - ')
@@ -478,6 +534,7 @@ class GuidesPage {
                                 mkp.yield(guide.category)
                             }
                             a(
+                                    class: 'guides-card-action',
                                     href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html",
                                     'Read More'
                             )
@@ -491,12 +548,12 @@ class GuidesPage {
     @CompileDynamic
     static String guideGroupByTag(Tag tag, List<Guide> guides) {
         renderHtml {
-            div(class: 'guide-group') {
+            div(class: 'guide-group guides-category-card') {
                 div(class: 'guide-group-header') {
-                    img(src: '[%url]/images/documentation.svg', alt: 'Guides')
+                    img(src: '[%url]/images/documentation.svg', alt: '')
                     h2("Guides filtered by #$tag.title")
                 }
-                ul {
+                ul(class: 'guides-category-list') {
                     guides
                             .findAll { Guide guide -> guide.tags.contains(tag.title) }
                             .each { mkp.yieldUnescaped(GuidesPage.renderGuide(it)) }
@@ -532,29 +589,34 @@ class GuidesPage {
 
     /**
      * Renders every guide published for {@code version} using the same visual
-     * treatment as the {@link #latestGuides} list on the index (title, date +
-     * category, "Read More"), but with no {@code take()} cap - the version
-     * filter is meant to surface the full set, newest first. Each "Read More"
-     * link targets the matching version variant of the guide. This list lives
-     * in the left column with the version + tag clouds beside it on the right,
-     * mirroring the index layout.
+     * treatment as the featured index list (title, date + category, "Read More"),
+     * but with no {@code take()} cap - the version filter is meant to surface the
+     * full set, newest first. Each "Read More" link targets the matching version
+     * variant of the guide.
      */
     @CompileDynamic
     static String guidesForVersion(String version, List<Guide> guides) {
         List<Guide> matched = guidesForVersionList(version, guides)
         renderHtml {
-            div(class: 'latest-guides') {
-                h3(class: 'column-header', "Guides for Grails $version")
+            section(
+                    class: 'latest-guides guides-featured guides-version-catalogue',
+                    'aria-labelledby': 'guides-version-heading'
+            ) {
+                h2(
+                        id: 'guides-version-heading',
+                        class: 'column-header guides-section-title',
+                        "Guides for Grails $version"
+                )
                 if (matched) {
-                    p(style: 'margin: -30px 0 30px 0; padding-left: 0;') {
+                    p(class: 'guides-featured-intro') {
                         mkp.yield("${matched.size()} guide${matched.size() == 1 ? '' : 's'} for Grails $version")
                     }
                 }
-                ul {
+                ul(class: 'guides-card-list') {
                     matched.each { guide ->
-                        li {
-                            b(guide.title)
-                            span {
+                        li(class: 'guides-card') {
+                            h3(class: 'guides-card-title', guide.title)
+                            span(class: 'guides-card-meta') {
                                 if (guide.publicationDate) {
                                     mkp.yield(new SimpleDateFormat('MMM dd, yyyy').format(guide.publicationDate))
                                     mkp.yield(' - ')
@@ -562,6 +624,7 @@ class GuidesPage {
                                 mkp.yield(guide.category)
                             }
                             a(
+                                    class: 'guides-card-action',
                                     href: "$GUIDES_URL/${guide.name}/${version}/guide/index.html",
                                     'Read More'
                             )
@@ -636,7 +699,7 @@ class GuidesPage {
      * A {@link SingleGuide} contributes its single {@code versionNumber}; a
      * {@link GrailsVersionedGuide} contributes every key in
      * {@code grailsMayorVersionTags}. Versions are guaranteed numeric major
-     * versions in {@code conf/guides.yml}, so they sort numerically descending.
+     * versions in conf/guides.yml, so they sort numerically descending.
      */
     static List<String> availableVersions(List<Guide> guides) {
         Set<String> versions = [] as Set<String>
diff --git a/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy b/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy
index 4a562de9005..a646faf16ae 100644
--- a/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy
+++ b/buildSrc/src/test/groovy/website/model/guides/GuidesPageSpec.groovy
@@ -62,6 +62,9 @@ class GuidesPageSpec extends Specification {
         html.contains('/multi/8/guide/index.html')
         html.contains('/multi/6/guide/index.html')
         html.contains('/multi/3/guide/index.html')
+        html.contains('multi-guide')
+        html.contains("class='align-left guides-version-chip'")
+        html.contains("class='grails-version'")
     }
 
     def 'renderGuide with version filter collapses multi-version row to one link'() {
@@ -230,6 +233,32 @@ class GuidesPageSpec extends Specification {
         index.contains('/g8-b/8/guide/index.html')
         index.contains('Legacy Guide')
 
+        and: 'featured catalogue precedes discovery search/clouds'
+        index.indexOf('featured-version-guides') < index.indexOf('guides-discovery')
+        index.indexOf('Grails 8 Guides') < index.indexOf("id='query'")
+        index.indexOf('guides-discovery') < index.indexOf('guides-category-grid')
+        index.contains('Browse by Category')
+        index.contains("class='guides-category-grid'")
+        !index.contains('style=')
+
+        and: 'search exposes a dedicated polite live status region'
+        index.contains("id='guides-search-status'")
+        index.contains("class='guides-search-status guides-visually-hidden'")
+        index.contains("role='status'")
+        index.contains("aria-live='polite'")
+        index.contains("aria-atomic='true'")
+        index.indexOf("id='query'") < index.indexOf("id='guides-search-status'")
+        index.indexOf("class='search-results'") < index.indexOf("id='guides-search-status'")
+
+        and: 'heading ranks nest without equal-rank peers'
+        index.contains("id='guides-catalogue-heading'")
+        index.contains('>Web Layer</h3>')
+        !index.contains('>Web Layer</h2>')
+        index.contains("<h3 class='column-header'>Latest Guides</h3>")
+        index.contains("<h4 class='guides-card-title'>")
+        index.contains("id='guides-featured-heading'")
+        index.contains("<h3 class='guides-card-title'>")
+
         and: 'version page lists every G8 guide uncapped with a count'
         versionPage.contains('2 guides for Grails 8')
         versionPage.contains('/g8-a/8/guide/index.html')
@@ -237,6 +266,77 @@ class GuidesPageSpec extends Specification {
         !versionPage.contains('/legacy/4/guide/index.html')
         versionPage.contains('G8 A')
         versionPage.contains('G8 B')
+
+        and: 'version page promotes catalogue before discovery and uses category grid'
+        versionPage.indexOf('guides-version-catalogue') < versionPage.indexOf('guides-discovery')
+        versionPage.indexOf('guides-category-grid') < versionPage.indexOf('guides-discovery')
+        versionPage.contains('Grails 8 by Category')
+        versionPage.contains("class='guides-category-grid'")
+        versionPage.contains('guides-discovery-layout-single')
+        !versionPage.contains('guides-discovery-primary')
+        !versionPage.contains('style=')
+    }
+
+    def 'categoryGrid emits responsive grid markup with catalogue heading'() {
+        given:
+        List<Guide> guides = [
+                new SingleGuide(
+                        name: 'g8-a',
+                        title: 'G8 A',
+                        category: 'Web Layer',
+                        versionNumber: '8',
+                        publicationDate: Date.parse('yyyy-MM-dd', '2026-06-01'),
+                        tags: [],
+                        authors: [],
+                ),
+        ]
+
+        when:
+        String indexGrid = GuidesPage.categoryGrid(guides, '8', false)
+        String versionGrid = GuidesPage.categoryGrid(guides, '8', true)
+        String standalone = GuidesPage.guideGroupByCategory(
+                GuidesPage.categories.weblayer, guides, false)
+
+        then:
+        indexGrid.contains("class='guides-category-grid'")
+        indexGrid.contains('Browse by Category')
+        indexGrid.contains('guides-catalogue')
+        indexGrid.contains('guide-group')
+        !indexGrid.contains('two-columns')
+        versionGrid.contains('Grails 8 by Category')
+        versionGrid.contains("class='guides-category-grid'")
+        !versionGrid.contains('style=')
+
+        and: 'grid cards use h3 under the catalogue h2; standalone category keeps h2'
+        indexGrid.contains("id='guides-catalogue-heading'")
+        indexGrid.contains('Browse by Category</h2>')
+        indexGrid.contains('>Web Layer</h3>')
+        !indexGrid.contains('>Web Layer</h2>')
+        standalone.contains('>Web Layer</h2>')
+        !standalone.contains('>Web Layer</h3>')
+    }
+
+    def 'latestGuides nests card titles as h4 under the Latest Guides h3'() {
+        given:
+        List<Guide> guides = [
+                new SingleGuide(
+                        name: 'g8-a',
+                        title: 'G8 A',
+                        category: 'Web Layer',
+                        versionNumber: '8',
+                        publicationDate: Date.parse('yyyy-MM-dd', '2026-06-01'),
+                        tags: [],
+                        authors: [],
+                ),
+        ]
+
+        when:
+        String html = GuidesPage.latestGuides(guides)
+
+        then:
+        html.contains("<h3 class='column-header'>Latest Guides</h3>")
+        html.contains("<h4 class='guides-card-title'>G8 A</h4>")
+        !html.contains("<h3 class='guides-card-title'>")
     }
 
     def 'real conf/guides.yml exposes every version-8 entry on the version page'() {
@@ -260,6 +360,9 @@ class GuidesPageSpec extends Specification {
         // category was missing from the hardcoded grid (regression: Grails REST APIs).
         v8Names.every { String name -> grid.contains("/${name}/8/guide/index.html") }
         grid.contains('Grails REST APIs')
+        grid.contains('restapis-guides.svg')
+        grid.contains('>Grails REST APIs</h3>')
+        !grid.contains('>Grails REST APIs</h2>')
     }
 
     private static File locateGuidesYml() {
diff --git a/templates/partials/site-head.html b/templates/partials/site-head.html
index 13e85bebe08..a6aaa5b354d 100644
--- a/templates/partials/site-head.html
+++ b/templates/partials/site-head.html
@@ -1,7 +1,7 @@
 <meta charset='UTF-8'/>
 <meta name='viewport' content='width=device-width, initial-scale=1'/>
 <link rel='icon' href='https://grails.apache.org/images/favicon.ico'/>
-<link rel='mask-icon' href='https://grails.apache.org/images/grails-pinned-icon.svg' color='feb672'/>
+<link rel='mask-icon' href='https://grails.apache.org/images/grails-pinned-icon.svg' color='#feb672'/>
 <link rel='alternate' type='application/rss+xml' title='RSS' href='https://grails.apache.org/rss.xml'/>
 <link rel='stylesheet' href='https://grails.apache.org/stylesheets/screen.css'/>
 <link rel='stylesheet' href='https://grails.apache.org/stylesheets/plugin.css'/>
@@ -9,7 +9,8 @@
 
 <!-- Matomo -->
 <script>
-  var _paq = window._paq = window._paq || [];
+  var _paq = window._paq || [];
+  window._paq = _paq;
   _paq.push(['setDoNotTrack', true]);
   _paq.push(['disableCookies']);
   _paq.push(['trackPageView']);
@@ -38,7 +39,29 @@
     data-modal-title="Apache Grails AI Assistant"
     data-modal-example-questions-title="Try asking me..."
     data-modal-example-questions="How does database migration work?,How does Spring Security work?"
-    data-button-text-color="#FBB576"
+    data-button-text="Ask AI"
+    data-button-text-color="#feb672"
+    data-button-text-font-size="0"
+    data-button-bg-color="#3F4346"
+    data-button-height="44px"
+    data-button-width="44px"
+    data-button-image-height="24"
+    data-button-image-width="24"
+    data-button-padding="10px"
+    data-button-border-radius="50%"
+    data-button-position-bottom="12px"
+    data-button-position-right="12px"
+    data-launcher-button-text="Ask AI"
+    data-launcher-button-font-size="0"
+    data-launcher-button-background-color="#3F4346"
+    data-launcher-button-height="44px"
+    data-launcher-button-width="44px"
+    data-launcher-button-image-height="24"
+    data-launcher-button-image-width="24"
+    data-launcher-button-padding="10px"
+    data-launcher-button-border-radius="50%"
+    data-launcher-button-bottom="12px"
+    data-launcher-button-right="12px"
     data-modal-header-bg-color="#FFFFFF"
-    data-modal-title-color="#FBB576"
+    data-modal-title-color="#255AA8"
     data-consent-required="true"></script>
diff --git a/templates/partials/site-header.html b/templates/partials/site-header.html
index 500c77c7a4a..3ce3ea6e462 100644
--- a/templates/partials/site-header.html
+++ b/templates/partials/site-header.html
@@ -1,8 +1,11 @@
 <header class='main-header'>
     <div class='content'>
         <a href='https://grails.apache.org/index.html'><img class='grails-logo' src='https://grails.apache.org/images/grails_logo.svg' alt='Grails Logo'/></a>
-        <a href='javascript:show("top-menus", "show-navigation-link")' id='show-navigation-link'
-           class='mobile align-center'>Show Navigation</a>
+        <button type='button' id='show-navigation-link'
+            class='mobile align-center navigation-toggle'
+            aria-expanded='false'
+            aria-controls='top-menus'
+            onclick='toggleNavigation()'>Show Navigation</button>
         <div id='top-menus'>
             <nav class='secondary-menu' id='secondary-menu'>
                 <ul>


(grails-static-website) 02/02: Document the guides design system

Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jamesfredley pushed a commit to branch fix/grails-8-guides-visual-polish
in repository https://gitbox.apache.org/repos/asf/grails-static-website.git

commit 21d47bf049b7bfbb5c8dc1bf11b4dd3f6e1223a7
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Jul 23 01:52:54 2026 -0400

    Document the guides design system
    
    Assisted-by: opencode:gpt-5.6-sol playwright
---
 DESIGN.md | 215 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 215 insertions(+)

diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 00000000000..581a023fe95
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,215 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+# Apache Grails Website Design System
+
+This document defines the reusable visual system for the guides catalogue. Implementations must place these tokens and components in `assets/stylesheets/screen.css` rather than introduce one-off styles. Catalogue tokens are scoped to the emitted `.guides-page` wrapper because the shared document template does not apply page metadata to the `<body>` element.
+
+## 1. Atmosphere & Identity
+
+The Apache Grails website is direct, technical, and welcoming. White and off-white reading surfaces keep dense documentation calm, Grails blue establishes structure, and the warm orange accent carries the framework's identity without overpowering the content. The signature is the chalice-led blue header paired with orange-topped white content cards.
+
+## 2. Color
+
+### Palette
+
+| Role | Token | Value | Usage |
+| --- | --- | --- | --- |
+| Brand | `--guides-color-brand` | `#255AA8` | Section headers, version chips, category title links, focus outlines, Kapa modal title |
+| Brand hover | `--guides-color-brand-hover` | `#1d4684` | Interactive brand surfaces on hover and focus |
+| Brand soft | `--guides-color-brand-soft` | `rgba(37, 90, 168, 0.08)` | Featured catalogue atmosphere |
+| Accent | `--guides-color-accent` | `#feb672` | Card top borders, hidden launcher text accent, catalogue REST icon, identity details |
+| Launcher dark | Kapa widget only | `#3F4346` | Ask AI launcher / project chrome background (not a page surface) |
+| Legacy tag gold | Existing `.tag-cloud` scale | `#F0C45A` | Existing occurrence-scaled tag navigation |
+| Accent wash | `--guides-color-accent-wash` | `rgba(254, 182, 114, 0.2)` | Featured catalogue highlight |
+| Surface | `--guides-color-surface` | `#ffffff` | Cards, inputs, panels |
+| Page | `--guides-color-page` | `#F5F5F5` | Page ground and quiet dividers |
+| Text | `--guides-color-text` | `#666666` | Body and metadata (AA on white/off-white) |
+| Strong text | `--guides-color-text-strong` | `#4f4f4f` | Guide titles and high-emphasis labels |
+| Tag chip text | `--guides-color-tag-text` | `#3d2914` | Dark ink on gold/orange tag chips |
+| On brand | `--guides-color-on-brand` | `#ffffff` | Text on blue surfaces |
+| Quiet border | `--guides-color-border` | `#d8d8d8` | Nonessential dividers and card boundaries |
+| Control border | `--guides-color-control-border` | `#767676` | Input and control boundaries (3:1+ on white/off-white) |
+
+### Rules
+
+- Grails blue establishes hierarchy; orange is an accent, not a second background system.
+- White cards sit on the off-white page ground.
+- New solid catalogue colors used for text, backgrounds, borders, or controls must be declared in this table and as scoped CSS custom properties before use. Derived alpha shadows may reuse brand RGB values when their complete shadow value is documented in the depth table.
+- Purple gradients and unrelated accent hues do not belong in this design system.
+
+## 3. Typography
+
+### Font stack
+
+| Family | Usage |
+| --- | --- |
+| Roboto, Roboto Light, Roboto Medium | Catalogue UI, guide titles, metadata, navigation |
+| Archia Light, Archia Thin, Archia Medium | Marketing display text elsewhere on the site |
+| Inter | Long-form rendered guide articles only |
+| JetBrains Mono | Code inside rendered guide articles only |
+
+### Scale
+
+| Level | Size | Weight | Line height | Usage |
+| --- | --- | --- | --- | --- |
+| Page title | `24px` | Medium | `1.2` | Blue header bar |
+| Section | `18px` | Medium | `28px` | `.column-header` labels |
+| Card title | `16px` | Medium | `1.35` | Guide and category cards |
+| Body | `16px` | Regular | `1.5` | Catalogue introductions and controls |
+| Metadata | `14px` | Regular | `1.4` | Date and category |
+| Guide version chip | `12px` | Light | `1.2` | Version links inside guide cards |
+| Version navigation chip | `16px` | Regular | `14px` | Legacy version-cloud navigation |
+
+### Heading hierarchy (guides catalogue)
+
+| Context | Rank | Notes |
+| --- | --- | --- |
+| Featured / version / catalogue / discovery section titles | `h2` | One section label each |
+| Category grid card titles | `h3` | Nested under catalogue `h2` |
+| Standalone category or tag page title | `h2` | Page-level heading when not nested |
+| Latest Guides section | `h3` | Nested under discovery `h2` |
+| Latest guide card titles | `h4` | Nested under Latest Guides `h3` |
+| Featured / version guide card titles | `h3` | Nested under their section `h2` |
+| Search result / no-result titles | `h3` | Nested under Find Guides `h2` |
+
+Large headings may use balanced wrapping. Body copy should use natural wrapping and remain readable at 200 percent zoom.
+
+## 4. Spacing & Layout
+
+### Spacing tokens
+
+| Token | Value | Usage |
+| --- | --- | --- |
+| `--guides-space-2` | `8px` | Chip gaps and tight relationships |
+| `--guides-space-3` | `12px` | Compact list rows |
+| `--guides-space-4` | `16px` | Default card and input padding |
+| `--guides-space-5` | `24px` | Grid gaps and section rhythm |
+| `--guides-space-6` | `32px` | Major section separation |
+| Legacy section step | `50px` | Existing `.column-header` and page spacing |
+
+### Grid
+
+- Maximum content width is `1141px`, inherited from `.content`.
+- The existing desktop breakpoint is `961px`; the catalogue adds a content-driven tablet breakpoint at `768px`.
+- Catalogue and discovery layouts are one column below `768px`.
+- Category cards use CSS multi-column masonry (`column-count` 1 / 2 / 3 at mobile / tablet / desktop) with `break-inside: avoid` so cards keep natural height instead of stretched equal-height grid rows.
+- Browser mechanics such as `column-count`, percentages, and intrinsic sizing remain raw CSS rather than design tokens.
+
+## 5. Components
+
+### Featured catalogue
+
+- **Structure:** contained guides header followed by a labelled `section`, blue section heading, lightly layered blue-orange atmosphere, introduction, guide-card grid.
+- **Variants:** current featured major on the index; selected major on a version page.
+- **Spacing:** section gap 6, card gap 3 or 4, card padding 4.
+- **States:** links expose default, hover, active, visited, and visible focus behavior.
+- **Accessibility:** the heading labels the section; source order places the promoted catalogue before discovery controls.
+- **Motion:** inherited color transitions only; no decorative movement.
+
+### Guide card
+
+- **Structure:** title, metadata, text action inside a list item.
+- **Variants:** featured and version cards use `h3.guides-card-title`; latest cards use `h4.guides-card-title` under the Latest Guides `h3`.
+- **Spacing:** padding 4; title-to-meta and meta-to-action use space 2.
+- **States:** focus within receives a clear brand outline; links retain underline feedback.
+- **Accessibility:** titles remain text, metadata remains legible, and the action stays keyboard reachable.
+- **Layout:** cards flow through the responsive guide-card grid.
+
+### Category grid and category card
+
+- **Structure:** labelled catalogue section (`h2`), multi-column masonry container, image-and-title card header (`h3` when linked in the grid), guide list.
+- **Variants:** full catalogue and selected-major-only catalogue; standalone category pages keep an `h2` title.
+- **Spacing:** column gap 5; compact list rows use spaces 3 and 4; cards stack with space 5 between blocks in a column.
+- **States:** category heading links use `--guides-color-brand` / hover variant (AA on white).
+- **Accessibility:** category images are decorative because the adjacent heading supplies the name.
+- **Icons:** homepage blue surfaces keep shared `restapis.svg` (white); catalogue cards use `restapis-guides.svg` (orange `#feb672` on white).
+- **Layout:** one column on mobile; two columns from `768px`; three columns from `961px`; cards never stretch to equal row height.
+
+### Discovery
+
+- **Structure:** labelled section containing search/latest guides and version/tag navigation.
+- **Variants:** index with search and latest cards; version page with navigation only.
+- **Spacing:** section gap 6 and column gap 5 or 6.
+- **States:** search uses an explicit label, native input behavior, and visible `:focus` / `:focus-visible` outline.
+- **Accessibility:** a separate visually hidden `#guides-search-status` node uses `role="status"`, `aria-live="polite"`, and `aria-atomic="true"`; JS sets `""` on reset, `"N guide(s) found"` on matches, and `"No results found"` otherwise. Visible search result headings are `h3` under Find Guides.
+- **Layout:** one column below desktop; two columns from `961px` only when both columns contain content.
+
+### Version chip
+
+- **Structure:** direct `.align-left.guides-version-chip` child of `.multi-guide`, containing the link and hidden search tags.
+- **Guide variant:** `12px` inherited Roboto Light text, `4px` radius, and compact `4px 10px` padding.
+- **Version-cloud navigation:** legacy `16px` inherited Roboto Regular text, `3px` radius, and `7px 12px 5px` padding.
+- **States:** both use blue default, darker blue hover, and the catalogue's visible keyboard outline.
+- **Accessibility:** search indexing uses `classList.contains('align-left')` so extra chip classes remain compatible.
+
+### Mobile navigation
+
+- **Structure:** `#show-navigation-link` is a visible `button` that toggles `#top-menus.is-open`.
+- **States:** closed (`Show Navigation`, `aria-expanded="false"`) and open (`Hide Navigation`, `aria-expanded="true"`).
+- **Color:** open mobile panel background is `#666666` with white links (AA); desktop panel stays white.
+- **Behavior:** `toggleNavigation()` toggles the `is-open` class only (no inline `display`); desktop `@media (min-width: 961px)` keeps `#top-menus` visible after mobile open/close then resize. Legacy `show()` remains for other callers.
+
+### Kapa Ask AI launcher
+
+- **Structure:** 44x44 control with 12px bottom/right offsets; accessible DOM text `"Ask AI"` with `0` font-size so the control stays icon-only visually.
+- **Colors:** hidden launcher text may use accent `#feb672`; modal title uses brand blue `#255AA8` on white; launcher background uses documented dark `#3F4346`.
+- **Attributes:** documented legacy `data-button-*` plus current `data-launcher-button-*` height/width/background/bottom/right/image/padding; unsupported new-launcher aliases (`text-color`, `text-font-size`, `bg-color`, `position-bottom/right`) are not used.
+- **Layout:** catalogue pages reserve bottom safe space; below `961px`, `footer` gets a `68px` right exclusion zone so sentences are not covered.
+
+## 6. Motion & Interaction
+
+| Type | Duration | Easing | Usage |
+| --- | --- | --- | --- |
+| Color feedback | `200ms` | ease in-out | Links and interactive surfaces |
+| Button press | `100ms` | ease | Existing one-pixel pressed translation |
+| Card shadow | `300ms` | ease | Existing reusable content cards |
+
+- Motion communicates interaction only.
+- New animation may use only `transform`, `opacity`, or color changes.
+- `prefers-reduced-motion: reduce` sets `scroll-behavior: auto`, disables link/button transitions, and removes button press `transform` (including the navigation toggle).
+- Every interactive element needs a visible focus state; hover cannot be the only cue. Search focus uses both `:focus` and `:focus-visible`.
+
+## 7. Depth & Surface
+
+The catalogue uses a restrained mixed strategy inherited from the site: white surfaces, an orange top rule for identity, and a very soft card shadow. Deeper shadows, glass effects, and heavy borders are not part of this surface.
+
+| Token | Value | Usage |
+| --- | --- | --- |
+| `--guides-radius` | `4px` | Cards, chips, inputs |
+| `--guides-shadow-card` | Blue-tinted soft elevation plus one-pixel grounding shadow | Guide and category cards |
+| Section shadow | `0 8px 18px rgba(37, 90, 168, 0.14)` | Section title elevation derived from brand blue |
+| Legacy version-navigation radius | `3px` | Existing version-cloud chips |
+| Accent rule | `2px solid var(--guides-color-accent)` | Guide and category card tops |
+
+## 8. Accessibility Constraints & Accepted Debt
+
+### Constraints
+
+- Target WCAG 2.2 AA with at least 4.5:1 contrast for normal text and 3:1 for large text and non-text controls.
+- Keyboard users must reach every guide, category, version, and search control with a visible focus indicator.
+- Users at 200 percent zoom and users on 375px-wide screens must receive a single readable column without horizontal scrolling of primary content.
+- Reduced-motion users receive no non-essential transitions.
+- The dense catalogue must lead with the promoted version and clear section labels to reduce memory and wayfinding load.
+- Nested headings must not share rank with their parent section label.
+
+### Accepted debt
+
+| Item | Location | Why accepted | Owner / Exit |
+| --- | --- | --- | --- |
+| Legacy float layout | Non-guides `.two-columns` pages | Outside the Grails 8 catalogue scope | Replace when those pages are redesigned |
+| Occurrence-scaled tag cloud sizes | `.tag1` through `.tag50` | Size scale retained; chip text forced to `--guides-color-tag-text` for AA | Revisit with a dedicated taxonomy UX change |
+| Separate article typography | `body.guide` | Rendered guide reading system is intentionally distinct | Keep unless long-form documentation is redesigned |