Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 91 additions & 2 deletions website/src/components/card-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '🧪',
}

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

return `
<div class="card-grid-container">
${renderCategoryNav(categories, anchors)}
${categories.map((category) => renderCategorySection(category, anchors)).join('')}
</div>
`
}

/**
* 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-<id>`) 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 `
<li>
<a
class="category-nav-link"
href="#category-${id}"
data-category-link="${id}"
title="${escapeHtml(name)}"
data-i18n-title="categories.${id}"
aria-label="${escapeHtml(name)}"
data-i18n-aria="categories.${id}"
>
<span class="category-nav-icon" aria-hidden="true">${icon}</span>
<span class="category-nav-count">${count}</span>
</a>
</li>`
})
.join('')

return `
<nav
class="category-nav"
aria-label="${escapeHtml(i18n.t('nav.categoryJump'))}"
data-i18n-aria="nav.categoryJump"
>
<ul class="category-nav-list">${links}</ul>
</nav>
`
}

/**
* Render a single category section with its anchors
*/
Expand All @@ -83,7 +146,7 @@ function renderCategorySection(category, allAnchors) {
const categoryName = i18n.t(`categories.${category.id}`) || category.name

return `
<section class="category-section" data-category="${escapeHtml(category.id)}">
<section class="category-section" id="category-${escapeHtml(category.id)}" data-category="${escapeHtml(category.id)}">
<h2 class="category-heading">
<span class="category-icon" style="background-color: ${color}">${icon}</span>
<span data-i18n="categories.${escapeHtml(category.id)}">${escapeHtml(categoryName)}</span>
Expand Down Expand Up @@ -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
*/
Expand Down
79 changes: 79 additions & 0 deletions website/src/components/card-grid.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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</)
expect(html).toMatch(/category-nav-count[^>]*>1</)
})

it('gives each category section a matching id as the jump target', () => {
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"')
})
})
50 changes: 50 additions & 0 deletions website/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,62 @@ body {

.category-section {
@apply mb-12;
/* Clear the sticky category quick-nav when jumped to via #category-<id> */
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);
}
Comment on lines +100 to +138
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stylelint-Fehler in der neuen Quick-Nav-CSS beheben

In diesem Block fehlen die geforderten Leerzeilen vor einzelnen Deklarationen (u. a. Line 107, Line 113, Line 119, Line 136), was aktuell gegen declaration-empty-line-before verstößt und CI/Lint brechen kann.

Vorgeschlagener Fix
 .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-count {
   `@apply` text-xs font-semibold px-1.5 rounded-full;
+
   background: var(--color-bg);
   color: var(--color-text-secondary);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.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);
}
.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);
}
🧰 Tools
🪛 Stylelint (17.12.0)

[error] 107-107: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)


[error] 113-113: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)


[error] 119-119: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)


[error] 136-136: Expected empty line before declaration (declaration-empty-line-before)

(declaration-empty-line-before)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@website/src/styles/main.css` around lines 100 - 138, The CSS rules violate
the declaration-empty-line-before lint rule; update each selector block
(.category-nav, .category-nav-list, .category-nav-link, .category-nav-count) to
insert a blank line before the relevant declarations (e.g., before the `@apply`
lines, before background/border/color declarations) so there is an empty line
separating declaration groups as required by declaration-empty-line-before; keep
the existing properties and order, only add the missing blank lines to satisfy
Stylelint.


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;
Expand Down
2 changes: 2 additions & 0 deletions website/src/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions website/src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading