diff --git a/website/src/components/card-grid.js b/website/src/components/card-grid.js index 53c0614..907b00d 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,72 @@ 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 +146,7 @@ function renderCategorySection(category, allAnchors) { const categoryName = i18n.t(`categories.${category.id}`) || category.name return ` -
    +

    ${icon} ${escapeHtml(categoryName)} @@ -420,10 +483,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..02b85bd 100644 --- a/website/src/components/card-grid.test.js +++ b/website/src/components/card-grid.test.js @@ -60,3 +60,82 @@ 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"') + // nav label must stay i18n-reactive on language switch + expect(html).toContain('data-i18n-aria="nav.categoryJump"') + }) + + 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",