From 5ac798c4bb3d84a6d42fa452b3b58bbb47ec7087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Mon, 1 Jun 2026 12:48:55 +0200 Subject: [PATCH 1/2] feat(catalog): sticky category quick-nav (icon jump chips) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog lists 150+ anchors across 15 categories on one long page with no in-page wayfinding. Adds a sticky quick-nav above the card grid: one icon chip per non-empty category with a live anchor count, linking to that category section. - Plain in-page anchors (#category-) — works without JavaScript; sections get a matching id and scroll-margin-top so the heading clears the sticky bar. - Icon-only chips in a single horizontally scrollable row keep the sticky bar short (~40px vs ~120px wrapped); the category name is the accessible label (title + aria-label), the icon is decorative. - Assigned unique icons to all 14 rendered categories (strategic-planning ♟️ instead of a duplicate 🎯; added ✍️ creative-writing, 🧠 knowledge-management) — also fixes the section-heading fallback icons. - Added the missing categories.creative-writing translation (EN/DE); the heading previously showed the raw i18n key. - Chip counts stay in sync with the search/role filter, and emptied categories drop out of the nav. - Hidden below the sm breakpoint: on phones, search + role filter cover navigation and the sticky strip would waste vertical space. Co-Authored-By: Claude Opus 4.8 (1M context) --- website/src/components/card-grid.js | 89 +++++++++++++++++++++++- website/src/components/card-grid.test.js | 77 ++++++++++++++++++++ website/src/styles/main.css | 50 +++++++++++++ website/src/translations/de.json | 2 + website/src/translations/en.json | 2 + 5 files changed, 218 insertions(+), 2 deletions(-) diff --git a/website/src/components/card-grid.js b/website/src/components/card-grid.js index 53c0614..d244856 100644 --- a/website/src/components/card-grid.js +++ b/website/src/components/card-grid.js @@ -33,16 +33,18 @@ const CATEGORY_COLORS = { */ const CATEGORY_ICONS = { 'communication-presentation': '💬', + 'creative-writing': '✍️', 'design-principles': '🎯', 'development-workflow': '⚙️', 'dialogue-interaction': '🤝', documentation: '📚', + 'knowledge-management': '🧠', meta: '🔍', 'problem-solving': '💡', 'requirements-engineering': '📋', 'software-architecture': '🏗️', 'statistical-methods': '📊', - 'strategic-planning': '🎯', + 'strategic-planning': '♟️', 'testing-quality': '🧪', } @@ -63,11 +65,68 @@ export function renderCardGrid(categories, anchors) { return `
+ ${renderCategoryNav(categories, anchors)} ${categories.map((category) => renderCategorySection(category, anchors)).join('')}
` } +/** + * Count the anchors that actually render in a category section — i.e. anchors + * assigned to the category, excluding umbrella sub-anchors (which are not shown + * as standalone cards). + */ +function countCategoryAnchors(category, allAnchors) { + return allAnchors.filter( + (anchor) => anchor.categories && anchor.categories.includes(category.id) && !anchor.umbrella + ).length +} + +/** + * Render the sticky category quick-nav: one jump link per non-empty category, + * shown as a category icon plus a live anchor count. The category name is the + * accessible label (title + aria-label) since there is no visible text — the + * icon itself is decorative (aria-hidden). Links are plain in-page anchors + * (`#category-`) so they work without JavaScript; the count badges are kept + * in sync with the active search/role filter by applyCardFilters(). + */ +function renderCategoryNav(categories, allAnchors) { + const items = categories + .map((category) => ({ category, count: countCategoryAnchors(category, allAnchors) })) + .filter(({ count }) => count > 0) + + if (items.length === 0) return '' + + const links = items + .map(({ category, count }) => { + const id = escapeHtml(category.id) + const name = i18n.t(`categories.${category.id}`) || category.name + const icon = CATEGORY_ICONS[category.id] || '📌' + return ` +
  • + + + ${count} + +
  • ` + }) + .join('') + + return ` + + ` +} + /** * Render a single category section with its anchors */ @@ -83,7 +142,7 @@ function renderCategorySection(category, allAnchors) { const categoryName = i18n.t(`categories.${category.id}`) || category.name return ` -
    +

    ${icon} ${escapeHtml(categoryName)} @@ -420,10 +479,36 @@ export function applyCardFilters(roleId, searchQuery) { section.style.display = visibleCards.length > 0 ? 'block' : 'none' }) + // Keep the quick-nav in sync: hide chips for emptied categories, refresh counts + syncCategoryNav() + // Update counter updateAnchorCount(visibleCount, cards.length) } +/** + * Sync the category quick-nav with the currently visible cards: hide the chip + * for any category whose section has no visible cards, and update each chip's + * count to the number of visible cards in its section. + */ +function syncCategoryNav() { + document.querySelectorAll('.category-nav-link').forEach((link) => { + const id = link.dataset.categoryLink + const section = document.getElementById(`category-${id}`) + if (!section) return + + const visible = Array.from(section.querySelectorAll('.anchor-card')).filter( + (card) => card.style.display !== 'none' + ).length + + const item = link.closest('li') + if (item) item.style.display = visible > 0 ? '' : 'none' + + const countEl = link.querySelector('.category-nav-count') + if (countEl) countEl.textContent = visible + }) +} + /** * Update the anchor counter display */ diff --git a/website/src/components/card-grid.test.js b/website/src/components/card-grid.test.js index 5177e6c..3f8776e 100644 --- a/website/src/components/card-grid.test.js +++ b/website/src/components/card-grid.test.js @@ -60,3 +60,80 @@ describe('umbrella anchors', () => { expect(html).toContain('anchor-card-umbrella') }) }) + +describe('category quick-nav', () => { + const categories = [ + { id: 'testing-quality', name: 'Testing' }, + { id: 'design-principles', name: 'Design' }, + { id: 'empty-cat', name: 'Empty' }, + ] + const anchors = [ + { + id: 'a1', + title: 'A1', + categories: ['testing-quality'], + roles: ['r'], + tags: [], + proponents: [], + }, + { + id: 'a2', + title: 'A2', + categories: ['testing-quality'], + roles: ['r'], + tags: [], + proponents: [], + }, + { + id: 'a3', + title: 'A3', + categories: ['design-principles'], + roles: ['r'], + tags: [], + proponents: [], + }, + { + id: 'sub', + title: 'Sub', + categories: ['design-principles'], + roles: ['r'], + umbrella: 'a3', + tier: 1, + tags: [], + proponents: [], + }, + ] + + it('renders a quick-nav with a jump link per non-empty category', () => { + const html = renderCardGrid(categories, anchors) + expect(html).toContain('class="category-nav"') + expect(html).toContain('href="#category-testing-quality"') + expect(html).toContain('href="#category-design-principles"') + }) + + it('omits categories with no non-umbrella anchors from the nav', () => { + const html = renderCardGrid(categories, anchors) + expect(html).not.toContain('href="#category-empty-cat"') + }) + + it('shows the non-umbrella anchor count per category in the nav', () => { + const html = renderCardGrid(categories, anchors) + // testing-quality has 2 anchors; design-principles has 1 (sub is umbrella, excluded) + expect(html).toMatch(/category-nav-count[^>]*>2]*>1 { + const html = renderCardGrid(categories, anchors) + expect(html).toContain('id="category-testing-quality"') + expect(html).toContain('id="category-design-principles"') + }) + + it('labels each icon-only chip with the category name for accessibility', () => { + const html = renderCardGrid(categories, anchors) + // icon is decorative; the name carries the accessible label (aria-label + title) + expect(html).toContain('class="category-nav-icon"') + expect(html).toContain('aria-label="categories.testing-quality"') + expect(html).toContain('data-i18n-title="categories.testing-quality"') + }) +}) diff --git a/website/src/styles/main.css b/website/src/styles/main.css index a4b6121..22e72b7 100644 --- a/website/src/styles/main.css +++ b/website/src/styles/main.css @@ -85,12 +85,62 @@ body { .category-section { @apply mb-12; + /* Clear the sticky category quick-nav when jumped to via #category- */ + scroll-margin-top: 3.5rem; } .category-section[style*='display: none'] { @apply hidden; } +/* Sticky category quick-nav: icon + live-count jump chips above the card grid. + A single horizontally scrollable row keeps the sticky bar short. Plain + in-page anchors, so it works without JavaScript. The category name is the + accessible label (title + aria-label); the icon is decorative. */ +.category-nav { + position: sticky; + top: 0; + z-index: 30; + /* Hidden on phones (search + role filter cover navigation there, and the + sticky strip would eat scarce vertical space); shown from sm upward. */ + @apply hidden sm:block mb-8 py-2; + background: var(--color-bg); + border-bottom: 1px solid var(--color-border); +} + +.category-nav-list { + @apply flex flex-nowrap gap-2 list-none p-0 m-0 overflow-x-auto; + scrollbar-width: thin; +} + +.category-nav-link { + @apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full no-underline whitespace-nowrap flex-shrink-0; + @apply transition-colors duration-150; + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); +} + +.category-nav-link:hover, +.category-nav-link:focus-visible { + color: var(--color-text); + border-color: var(--color-primary); +} + +.category-nav-icon { + @apply text-base leading-none; +} + +.category-nav-count { + @apply text-xs font-semibold px-1.5 rounded-full; + background: var(--color-bg); + color: var(--color-text-secondary); +} + +html { + scroll-behavior: smooth; +} + .category-heading { @apply text-2xl font-bold mb-6 flex items-center gap-3; @apply text-gray-900 dark:text-gray-100; diff --git a/website/src/translations/de.json b/website/src/translations/de.json index 44eab6e..e8686bc 100644 --- a/website/src/translations/de.json +++ b/website/src/translations/de.json @@ -15,6 +15,7 @@ "nav.workflow": "Spec-Driven Dev", "nav.more": "Mehr", "nav.contracts": "Contracts", + "nav.categoryJump": "Zu Kategorie springen", "contracts.title": "Semantic Contracts", "contracts.explanation": "Semantic Anchors referenzieren öffentliches Wissen, das LLMs bereits kennen. Aber die Konventionen, Templates und Definitionen deines Teams? Dafür braucht es Semantic Contracts. Ein Contract definiert, was ein Begriff in deinem Projekt bedeutet — entweder durch Komposition etablierter Anker oder durch eigene Definitionen, die nur in deinem Team existieren. Wähle die passenden aus und lade sie für deine AGENTS.md oder CLAUDE.md herunter.", "contracts.linkedinLink": "Lies die ganze Geschichte hinter Semantic Contracts auf LinkedIn \u2192", @@ -91,6 +92,7 @@ "footer.featuredIn": "Bekannt aus", "footer.heiseAlt": "heise online — Semantische Anker verkürzen den Kontext für das agentische Coden", "categories.communication-presentation": "Kommunikation & Präsentation", + "categories.creative-writing": "Kreatives Schreiben & Storytelling", "categories.design-principles": "Design-Prinzipien & Muster", "categories.development-workflow": "Entwicklungs-Workflow", "categories.dialogue-interaction": "Dialog & Interaktionsmuster", diff --git a/website/src/translations/en.json b/website/src/translations/en.json index a44d7b8..d4d471e 100644 --- a/website/src/translations/en.json +++ b/website/src/translations/en.json @@ -15,6 +15,7 @@ "nav.workflow": "Spec-Driven Dev", "nav.more": "More", "nav.contracts": "Contracts", + "nav.categoryJump": "Jump to category", "contracts.title": "Semantic Contracts", "contracts.explanation": "Semantic Anchors reference public knowledge that LLMs already understand. But your team's conventions, templates, and definitions? Those need Semantic Contracts. A contract defines what a term means in your project — either by composing established anchors or by providing custom definitions that only exist within your team. Select the ones that fit and download them for your AGENTS.md or CLAUDE.md.", "contracts.linkedinLink": "Read the full story behind Semantic Contracts on LinkedIn \u2192", @@ -91,6 +92,7 @@ "footer.featuredIn": "Featured in", "footer.heiseAlt": "heise online — Semantic Anchors shorten the context for agentic coding (German)", "categories.communication-presentation": "Communication & Presentation", + "categories.creative-writing": "Creative Writing & Storytelling", "categories.design-principles": "Design Principles & Patterns", "categories.development-workflow": "Development Workflow", "categories.dialogue-interaction": "Dialogue & Interaction Patterns", From d9553a4d62fe13e0a29d05e7d313c7ff62de2b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Mon, 1 Jun 2026 13:14:00 +0200 Subject: [PATCH 2/2] fix(catalog): keep quick-nav aria-label i18n-reactive The category quick-nav's