Skip to content

Commit 05d0c75

Browse files
authored
Merge pull request #549 from raifdmueller/feat/category-quicknav
feat(catalog): sticky category quick-nav (icon jump chips)
2 parents e0fb2ee + d9553a4 commit 05d0c75

5 files changed

Lines changed: 224 additions & 2 deletions

File tree

website/src/components/card-grid.js

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,18 @@ const CATEGORY_COLORS = {
3333
*/
3434
const CATEGORY_ICONS = {
3535
'communication-presentation': '💬',
36+
'creative-writing': '✍️',
3637
'design-principles': '🎯',
3738
'development-workflow': '⚙️',
3839
'dialogue-interaction': '🤝',
3940
documentation: '📚',
41+
'knowledge-management': '🧠',
4042
meta: '🔍',
4143
'problem-solving': '💡',
4244
'requirements-engineering': '📋',
4345
'software-architecture': '🏗️',
4446
'statistical-methods': '📊',
45-
'strategic-planning': '🎯',
47+
'strategic-planning': '♟️',
4648
'testing-quality': '🧪',
4749
}
4850

@@ -63,11 +65,72 @@ export function renderCardGrid(categories, anchors) {
6365

6466
return `
6567
<div class="card-grid-container">
68+
${renderCategoryNav(categories, anchors)}
6669
${categories.map((category) => renderCategorySection(category, anchors)).join('')}
6770
</div>
6871
`
6972
}
7073

74+
/**
75+
* Count the anchors that actually render in a category section — i.e. anchors
76+
* assigned to the category, excluding umbrella sub-anchors (which are not shown
77+
* as standalone cards).
78+
*/
79+
function countCategoryAnchors(category, allAnchors) {
80+
return allAnchors.filter(
81+
(anchor) => anchor.categories && anchor.categories.includes(category.id) && !anchor.umbrella
82+
).length
83+
}
84+
85+
/**
86+
* Render the sticky category quick-nav: one jump link per non-empty category,
87+
* shown as a category icon plus a live anchor count. The category name is the
88+
* accessible label (title + aria-label) since there is no visible text — the
89+
* icon itself is decorative (aria-hidden). Links are plain in-page anchors
90+
* (`#category-<id>`) so they work without JavaScript; the count badges are kept
91+
* in sync with the active search/role filter by applyCardFilters().
92+
*/
93+
function renderCategoryNav(categories, allAnchors) {
94+
const items = categories
95+
.map((category) => ({ category, count: countCategoryAnchors(category, allAnchors) }))
96+
.filter(({ count }) => count > 0)
97+
98+
if (items.length === 0) return ''
99+
100+
const links = items
101+
.map(({ category, count }) => {
102+
const id = escapeHtml(category.id)
103+
const name = i18n.t(`categories.${category.id}`) || category.name
104+
const icon = CATEGORY_ICONS[category.id] || '📌'
105+
return `
106+
<li>
107+
<a
108+
class="category-nav-link"
109+
href="#category-${id}"
110+
data-category-link="${id}"
111+
title="${escapeHtml(name)}"
112+
data-i18n-title="categories.${id}"
113+
aria-label="${escapeHtml(name)}"
114+
data-i18n-aria="categories.${id}"
115+
>
116+
<span class="category-nav-icon" aria-hidden="true">${icon}</span>
117+
<span class="category-nav-count">${count}</span>
118+
</a>
119+
</li>`
120+
})
121+
.join('')
122+
123+
return `
124+
<nav
125+
class="category-nav"
126+
aria-label="${escapeHtml(i18n.t('nav.categoryJump'))}"
127+
data-i18n-aria="nav.categoryJump"
128+
>
129+
<ul class="category-nav-list">${links}</ul>
130+
</nav>
131+
`
132+
}
133+
71134
/**
72135
* Render a single category section with its anchors
73136
*/
@@ -83,7 +146,7 @@ function renderCategorySection(category, allAnchors) {
83146
const categoryName = i18n.t(`categories.${category.id}`) || category.name
84147

85148
return `
86-
<section class="category-section" data-category="${escapeHtml(category.id)}">
149+
<section class="category-section" id="category-${escapeHtml(category.id)}" data-category="${escapeHtml(category.id)}">
87150
<h2 class="category-heading">
88151
<span class="category-icon" style="background-color: ${color}">${icon}</span>
89152
<span data-i18n="categories.${escapeHtml(category.id)}">${escapeHtml(categoryName)}</span>
@@ -420,10 +483,36 @@ export function applyCardFilters(roleId, searchQuery) {
420483
section.style.display = visibleCards.length > 0 ? 'block' : 'none'
421484
})
422485

486+
// Keep the quick-nav in sync: hide chips for emptied categories, refresh counts
487+
syncCategoryNav()
488+
423489
// Update counter
424490
updateAnchorCount(visibleCount, cards.length)
425491
}
426492

493+
/**
494+
* Sync the category quick-nav with the currently visible cards: hide the chip
495+
* for any category whose section has no visible cards, and update each chip's
496+
* count to the number of visible cards in its section.
497+
*/
498+
function syncCategoryNav() {
499+
document.querySelectorAll('.category-nav-link').forEach((link) => {
500+
const id = link.dataset.categoryLink
501+
const section = document.getElementById(`category-${id}`)
502+
if (!section) return
503+
504+
const visible = Array.from(section.querySelectorAll('.anchor-card')).filter(
505+
(card) => card.style.display !== 'none'
506+
).length
507+
508+
const item = link.closest('li')
509+
if (item) item.style.display = visible > 0 ? '' : 'none'
510+
511+
const countEl = link.querySelector('.category-nav-count')
512+
if (countEl) countEl.textContent = visible
513+
})
514+
}
515+
427516
/**
428517
* Update the anchor counter display
429518
*/

website/src/components/card-grid.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,82 @@ describe('umbrella anchors', () => {
6060
expect(html).toContain('anchor-card-umbrella')
6161
})
6262
})
63+
64+
describe('category quick-nav', () => {
65+
const categories = [
66+
{ id: 'testing-quality', name: 'Testing' },
67+
{ id: 'design-principles', name: 'Design' },
68+
{ id: 'empty-cat', name: 'Empty' },
69+
]
70+
const anchors = [
71+
{
72+
id: 'a1',
73+
title: 'A1',
74+
categories: ['testing-quality'],
75+
roles: ['r'],
76+
tags: [],
77+
proponents: [],
78+
},
79+
{
80+
id: 'a2',
81+
title: 'A2',
82+
categories: ['testing-quality'],
83+
roles: ['r'],
84+
tags: [],
85+
proponents: [],
86+
},
87+
{
88+
id: 'a3',
89+
title: 'A3',
90+
categories: ['design-principles'],
91+
roles: ['r'],
92+
tags: [],
93+
proponents: [],
94+
},
95+
{
96+
id: 'sub',
97+
title: 'Sub',
98+
categories: ['design-principles'],
99+
roles: ['r'],
100+
umbrella: 'a3',
101+
tier: 1,
102+
tags: [],
103+
proponents: [],
104+
},
105+
]
106+
107+
it('renders a quick-nav with a jump link per non-empty category', () => {
108+
const html = renderCardGrid(categories, anchors)
109+
expect(html).toContain('class="category-nav"')
110+
expect(html).toContain('href="#category-testing-quality"')
111+
expect(html).toContain('href="#category-design-principles"')
112+
// nav label must stay i18n-reactive on language switch
113+
expect(html).toContain('data-i18n-aria="nav.categoryJump"')
114+
})
115+
116+
it('omits categories with no non-umbrella anchors from the nav', () => {
117+
const html = renderCardGrid(categories, anchors)
118+
expect(html).not.toContain('href="#category-empty-cat"')
119+
})
120+
121+
it('shows the non-umbrella anchor count per category in the nav', () => {
122+
const html = renderCardGrid(categories, anchors)
123+
// testing-quality has 2 anchors; design-principles has 1 (sub is umbrella, excluded)
124+
expect(html).toMatch(/category-nav-count[^>]*>2</)
125+
expect(html).toMatch(/category-nav-count[^>]*>1</)
126+
})
127+
128+
it('gives each category section a matching id as the jump target', () => {
129+
const html = renderCardGrid(categories, anchors)
130+
expect(html).toContain('id="category-testing-quality"')
131+
expect(html).toContain('id="category-design-principles"')
132+
})
133+
134+
it('labels each icon-only chip with the category name for accessibility', () => {
135+
const html = renderCardGrid(categories, anchors)
136+
// icon is decorative; the name carries the accessible label (aria-label + title)
137+
expect(html).toContain('class="category-nav-icon"')
138+
expect(html).toContain('aria-label="categories.testing-quality"')
139+
expect(html).toContain('data-i18n-title="categories.testing-quality"')
140+
})
141+
})

website/src/styles/main.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,62 @@ body {
8585

8686
.category-section {
8787
@apply mb-12;
88+
/* Clear the sticky category quick-nav when jumped to via #category-<id> */
89+
scroll-margin-top: 3.5rem;
8890
}
8991

9092
.category-section[style*='display: none'] {
9193
@apply hidden;
9294
}
9395

96+
/* Sticky category quick-nav: icon + live-count jump chips above the card grid.
97+
A single horizontally scrollable row keeps the sticky bar short. Plain
98+
in-page anchors, so it works without JavaScript. The category name is the
99+
accessible label (title + aria-label); the icon is decorative. */
100+
.category-nav {
101+
position: sticky;
102+
top: 0;
103+
z-index: 30;
104+
/* Hidden on phones (search + role filter cover navigation there, and the
105+
sticky strip would eat scarce vertical space); shown from sm upward. */
106+
@apply hidden sm:block mb-8 py-2;
107+
background: var(--color-bg);
108+
border-bottom: 1px solid var(--color-border);
109+
}
110+
111+
.category-nav-list {
112+
@apply flex flex-nowrap gap-2 list-none p-0 m-0 overflow-x-auto;
113+
scrollbar-width: thin;
114+
}
115+
116+
.category-nav-link {
117+
@apply inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full no-underline whitespace-nowrap flex-shrink-0;
118+
@apply transition-colors duration-150;
119+
border: 1px solid var(--color-border);
120+
background: var(--color-bg-secondary);
121+
color: var(--color-text-secondary);
122+
}
123+
124+
.category-nav-link:hover,
125+
.category-nav-link:focus-visible {
126+
color: var(--color-text);
127+
border-color: var(--color-primary);
128+
}
129+
130+
.category-nav-icon {
131+
@apply text-base leading-none;
132+
}
133+
134+
.category-nav-count {
135+
@apply text-xs font-semibold px-1.5 rounded-full;
136+
background: var(--color-bg);
137+
color: var(--color-text-secondary);
138+
}
139+
140+
html {
141+
scroll-behavior: smooth;
142+
}
143+
94144
.category-heading {
95145
@apply text-2xl font-bold mb-6 flex items-center gap-3;
96146
@apply text-gray-900 dark:text-gray-100;

website/src/translations/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"nav.workflow": "Spec-Driven Dev",
1616
"nav.more": "Mehr",
1717
"nav.contracts": "Contracts",
18+
"nav.categoryJump": "Zu Kategorie springen",
1819
"contracts.title": "Semantic Contracts",
1920
"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.",
2021
"contracts.linkedinLink": "Lies die ganze Geschichte hinter Semantic Contracts auf LinkedIn \u2192",
@@ -91,6 +92,7 @@
9192
"footer.featuredIn": "Bekannt aus",
9293
"footer.heiseAlt": "heise online — Semantische Anker verkürzen den Kontext für das agentische Coden",
9394
"categories.communication-presentation": "Kommunikation & Präsentation",
95+
"categories.creative-writing": "Kreatives Schreiben & Storytelling",
9496
"categories.design-principles": "Design-Prinzipien & Muster",
9597
"categories.development-workflow": "Entwicklungs-Workflow",
9698
"categories.dialogue-interaction": "Dialog & Interaktionsmuster",

website/src/translations/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"nav.workflow": "Spec-Driven Dev",
1616
"nav.more": "More",
1717
"nav.contracts": "Contracts",
18+
"nav.categoryJump": "Jump to category",
1819
"contracts.title": "Semantic Contracts",
1920
"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.",
2021
"contracts.linkedinLink": "Read the full story behind Semantic Contracts on LinkedIn \u2192",
@@ -91,6 +92,7 @@
9192
"footer.featuredIn": "Featured in",
9293
"footer.heiseAlt": "heise online — Semantic Anchors shorten the context for agentic coding (German)",
9394
"categories.communication-presentation": "Communication & Presentation",
95+
"categories.creative-writing": "Creative Writing & Storytelling",
9496
"categories.design-principles": "Design Principles & Patterns",
9597
"categories.development-workflow": "Development Workflow",
9698
"categories.dialogue-interaction": "Dialogue & Interaction Patterns",

0 commit comments

Comments
 (0)