Skip to content

Commit 5ac798c

Browse files
raifdmuellerclaude
andcommitted
feat(catalog): sticky category quick-nav (icon jump chips)
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-<id>) — 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) <noreply@anthropic.com>
1 parent e0fb2ee commit 5ac798c

5 files changed

Lines changed: 218 additions & 2 deletions

File tree

website/src/components/card-grid.js

Lines changed: 87 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,68 @@ 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 class="category-nav" aria-label="${escapeHtml(i18n.t('nav.categoryJump'))}">
125+
<ul class="category-nav-list">${links}</ul>
126+
</nav>
127+
`
128+
}
129+
71130
/**
72131
* Render a single category section with its anchors
73132
*/
@@ -83,7 +142,7 @@ function renderCategorySection(category, allAnchors) {
83142
const categoryName = i18n.t(`categories.${category.id}`) || category.name
84143

85144
return `
86-
<section class="category-section" data-category="${escapeHtml(category.id)}">
145+
<section class="category-section" id="category-${escapeHtml(category.id)}" data-category="${escapeHtml(category.id)}">
87146
<h2 class="category-heading">
88147
<span class="category-icon" style="background-color: ${color}">${icon}</span>
89148
<span data-i18n="categories.${escapeHtml(category.id)}">${escapeHtml(categoryName)}</span>
@@ -420,10 +479,36 @@ export function applyCardFilters(roleId, searchQuery) {
420479
section.style.display = visibleCards.length > 0 ? 'block' : 'none'
421480
})
422481

482+
// Keep the quick-nav in sync: hide chips for emptied categories, refresh counts
483+
syncCategoryNav()
484+
423485
// Update counter
424486
updateAnchorCount(visibleCount, cards.length)
425487
}
426488

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

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,80 @@ 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+
})
113+
114+
it('omits categories with no non-umbrella anchors from the nav', () => {
115+
const html = renderCardGrid(categories, anchors)
116+
expect(html).not.toContain('href="#category-empty-cat"')
117+
})
118+
119+
it('shows the non-umbrella anchor count per category in the nav', () => {
120+
const html = renderCardGrid(categories, anchors)
121+
// testing-quality has 2 anchors; design-principles has 1 (sub is umbrella, excluded)
122+
expect(html).toMatch(/category-nav-count[^>]*>2</)
123+
expect(html).toMatch(/category-nav-count[^>]*>1</)
124+
})
125+
126+
it('gives each category section a matching id as the jump target', () => {
127+
const html = renderCardGrid(categories, anchors)
128+
expect(html).toContain('id="category-testing-quality"')
129+
expect(html).toContain('id="category-design-principles"')
130+
})
131+
132+
it('labels each icon-only chip with the category name for accessibility', () => {
133+
const html = renderCardGrid(categories, anchors)
134+
// icon is decorative; the name carries the accessible label (aria-label + title)
135+
expect(html).toContain('class="category-nav-icon"')
136+
expect(html).toContain('aria-label="categories.testing-quality"')
137+
expect(html).toContain('data-i18n-title="categories.testing-quality"')
138+
})
139+
})

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)