Skip to content

Commit 9b7679a

Browse files
authored
Merge pull request #366 from raifdmueller/feat/semantic-contracts
feat: Semantic Contracts page with selection and download
2 parents 2c3b0e5 + 65b8938 commit 9b7679a

9 files changed

Lines changed: 359 additions & 11 deletions

File tree

website/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const browserGlobals = {
2626
IntersectionObserver: 'readonly',
2727
requestAnimationFrame: 'readonly',
2828
performance: 'readonly',
29+
Blob: 'readonly',
2930
}
3031

3132
export default [

website/package-lock.json

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

website/public/data/contracts.json

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
[
2+
{
3+
"id": "specification",
4+
"title": "Specification",
5+
"titleDe": "Spezifikation",
6+
"description": "What we mean when we say 'spec'",
7+
"descriptionDe": "Was wir meinen, wenn wir 'Spec' sagen",
8+
"anchors": ["gherkin", "bdd-given-when-then"],
9+
"template": "When we talk about a \"specification\" or \"spec\", we mean:\n- Use Cases with Activity Diagrams (all paths, not just the happy path)\n- Acceptance criteria in Gherkin format (Given/When/Then)",
10+
"templateDe": "Wenn wir von einer \"Spezifikation\" oder \"Spec\" sprechen, meinen wir:\n- Use Cases mit Activity Diagrams (alle Pfade, nicht nur der Happy Path)\n- Akzeptanzkriterien im Gherkin-Format (Given/When/Then)",
11+
"category": "requirements"
12+
},
13+
{
14+
"id": "architecture-documentation",
15+
"title": "Architecture Documentation",
16+
"titleDe": "Architekturdokumentation",
17+
"description": "How we document architecture",
18+
"descriptionDe": "Wie wir Architektur dokumentieren",
19+
"anchors": ["arc42", "c4-diagrams", "adr-according-to-nygard"],
20+
"template": "When we say \"architecture documentation\", we mean:\n- arc42 template with all 12 sections\n- C4 diagrams for visualization\n- ADRs according to Nygard for decisions",
21+
"templateDe": "Wenn wir \"Architekturdokumentation\" sagen, meinen wir:\n- arc42-Template mit allen 12 Abschnitten\n- C4-Diagramme zur Visualisierung\n- ADRs nach Nygard für Entscheidungen",
22+
"category": "architecture"
23+
},
24+
{
25+
"id": "layer-boundaries",
26+
"title": "Layer Boundaries",
27+
"titleDe": "Schichtgrenzen",
28+
"description": "How we handle boundaries between layers",
29+
"descriptionDe": "Wie wir Grenzen zwischen Schichten handhaben",
30+
"anchors": ["clean-architecture", "hexagonal-architecture", "domain-driven-design", "solid-dip", "solid-isp"],
31+
"template": "At every layer boundary:\n- Expose only well-defined DTOs and contracts — never domain entities\n- Use explicit mapping at every seam\n- Apply Anti-Corruption Layers when integrating external systems\n- Dependency direction points inward (DIP)",
32+
"templateDe": "An jeder Schichtgrenze:\n- Nur definierte DTOs und Contracts exponieren — niemals Domain-Entitäten\n- Explizites Mapping an jeder Nahtstelle\n- Anti-Corruption-Layer bei Integration externer Systeme\n- Abhängigkeitsrichtung zeigt nach innen (DIP)",
33+
"category": "architecture"
34+
},
35+
{
36+
"id": "code-quality",
37+
"title": "Code Quality",
38+
"titleDe": "Code-Qualität",
39+
"description": "Our coding conventions and quality standards",
40+
"descriptionDe": "Unsere Coding-Konventionen und Qualitätsstandards",
41+
"anchors": ["tdd-london-school", "tdd-chicago-school", "solid-principles", "dry-principle", "kiss-principle", "conventional-commits"],
42+
"template": "Our code follows:\n- TDD (London or Chicago School as appropriate)\n- SOLID principles\n- DRY, KISS\n- Conventional Commits",
43+
"templateDe": "Unser Code folgt:\n- TDD (London oder Chicago School je nach Kontext)\n- SOLID-Prinzipien\n- DRY, KISS\n- Conventional Commits",
44+
"category": "development"
45+
},
46+
{
47+
"id": "documentation-style",
48+
"title": "Documentation Style",
49+
"titleDe": "Dokumentationsstil",
50+
"description": "How we write and organize documentation",
51+
"descriptionDe": "Wie wir Dokumentation schreiben und organisieren",
52+
"anchors": ["plain-english-strunk-white", "gutes-deutsch-wolf-schneider", "docs-as-code", "diataxis-framework"],
53+
"template": "All documentation uses:\n- Plain English according to Strunk & White (or Gutes Deutsch nach Wolf Schneider for German)\n- Docs-as-Code with AsciiDoc\n- Diataxis for document types",
54+
"templateDe": "Alle Dokumentation verwendet:\n- Gutes Deutsch nach Wolf Schneider (oder Plain English nach Strunk & White für Englisch)\n- Docs-as-Code mit AsciiDoc\n- Diataxis für Dokumenttypen",
55+
"category": "documentation"
56+
}
57+
]
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { i18n } from '../i18n.js'
2+
3+
const STORAGE_KEY = 'selected-contracts'
4+
5+
function esc(str) {
6+
const d = document.createElement('div')
7+
d.textContent = str
8+
return d.innerHTML
9+
}
10+
11+
function getSelectedContracts() {
12+
try {
13+
const val = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
14+
return Array.isArray(val) ? val : []
15+
} catch {
16+
return []
17+
}
18+
}
19+
20+
function setSelectedContracts(ids) {
21+
try {
22+
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
23+
} catch {
24+
// localStorage may be blocked
25+
}
26+
}
27+
28+
function getLocalizedField(contract, field) {
29+
const lang = i18n.currentLanguage || 'en'
30+
if (lang === 'de' && contract[field + 'De']) {
31+
return contract[field + 'De']
32+
}
33+
return contract[field]
34+
}
35+
36+
export function renderContractsPage() {
37+
const selected = getSelectedContracts()
38+
const selectedCount = selected.length
39+
40+
return `
41+
<div class="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
42+
<div class="mb-8">
43+
<h1 class="text-3xl font-bold text-[var(--color-text)] mb-2" data-i18n="contracts.title">
44+
${i18n.t('contracts.title')}
45+
</h1>
46+
<p class="text-[var(--color-text-secondary)] mb-6" data-i18n="contracts.subtitle">
47+
${i18n.t('contracts.subtitle')}
48+
</p>
49+
50+
<div class="flex items-center gap-4 mb-6">
51+
<button
52+
id="contracts-download"
53+
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
54+
>
55+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
57+
</svg>
58+
<span data-i18n="contracts.download">${i18n.t('contracts.download')}</span>
59+
<span id="contracts-count" class="rounded-full bg-blue-500 px-2 py-0.5 text-xs">${selectedCount}</span>
60+
</button>
61+
<button
62+
id="contracts-select-all"
63+
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
64+
data-i18n="contracts.selectAll"
65+
>${i18n.t('contracts.selectAll')}</button>
66+
<button
67+
id="contracts-deselect-all"
68+
class="text-sm text-[var(--color-text-secondary)] hover:underline"
69+
data-i18n="contracts.deselectAll"
70+
>${i18n.t('contracts.deselectAll')}</button>
71+
</div>
72+
</div>
73+
74+
<div id="contracts-grid" class="grid gap-4">
75+
</div>
76+
</div>
77+
`
78+
}
79+
80+
function renderContractCard(contract, isSelected) {
81+
const title = getLocalizedField(contract, 'title')
82+
const description = getLocalizedField(contract, 'description')
83+
const template = getLocalizedField(contract, 'template')
84+
85+
const anchorLinks = contract.anchors
86+
.map(
87+
(id) =>
88+
`<a href="#/anchor/${esc(id)}" class="inline-block rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800/40 transition-colors">${esc(id)}</a>`
89+
)
90+
.join(' ')
91+
92+
const templateHtml = template
93+
.split('\n')
94+
.map((line) => {
95+
if (line.startsWith('- ')) {
96+
return `<span class="text-[var(--color-text-secondary)]">• ${esc(line.slice(2))}</span>`
97+
}
98+
return `<span class="font-medium">${esc(line)}</span>`
99+
})
100+
.join('<br>')
101+
102+
return `
103+
<div class="contract-card rounded-lg border transition-all cursor-pointer
104+
${isSelected ? 'border-blue-500 bg-blue-50/50 dark:bg-blue-900/10 shadow-sm' : 'border-[var(--color-border)] bg-[var(--color-bg)] hover:border-blue-300 dark:hover:border-blue-700'}"
105+
data-contract-id="${esc(contract.id)}"
106+
>
107+
<div class="p-5">
108+
<div class="flex items-start gap-3">
109+
<input
110+
type="checkbox"
111+
class="contract-checkbox mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
112+
data-contract-id="${esc(contract.id)}"
113+
${isSelected ? 'checked' : ''}
114+
/>
115+
<div class="flex-1 min-w-0">
116+
<h3 class="text-lg font-semibold text-[var(--color-text)] mb-1">${esc(title)}</h3>
117+
<p class="text-sm text-[var(--color-text-secondary)] mb-3">${esc(description)}</p>
118+
<div class="rounded-md bg-[var(--color-bg-secondary)] p-3 mb-3 text-sm leading-relaxed">
119+
${templateHtml}
120+
</div>
121+
<div class="flex flex-wrap gap-1.5">
122+
${anchorLinks}
123+
</div>
124+
</div>
125+
</div>
126+
</div>
127+
</div>
128+
`
129+
}
130+
131+
export function initContractsPage(contracts) {
132+
const oldGrid = document.getElementById('contracts-grid')
133+
if (!oldGrid || !contracts) return
134+
135+
// Replace grid to remove stale event listeners from previous init
136+
const grid = oldGrid.cloneNode(false)
137+
oldGrid.replaceWith(grid)
138+
139+
const selected = getSelectedContracts()
140+
141+
grid.innerHTML = contracts.map((c) => renderContractCard(c, selected.includes(c.id))).join('')
142+
143+
// Checkbox toggle
144+
grid.addEventListener('change', (e) => {
145+
if (e.target.classList.contains('contract-checkbox')) {
146+
const id = e.target.dataset.contractId
147+
const current = getSelectedContracts()
148+
if (e.target.checked) {
149+
if (!current.includes(id)) current.push(id)
150+
} else {
151+
const idx = current.indexOf(id)
152+
if (idx >= 0) current.splice(idx, 1)
153+
}
154+
setSelectedContracts(current)
155+
updateUI()
156+
}
157+
})
158+
159+
// Card click toggles checkbox
160+
grid.addEventListener('click', (e) => {
161+
const card = e.target.closest('.contract-card')
162+
if (!card || e.target.tagName === 'A' || e.target.tagName === 'INPUT') return
163+
const checkbox = card.querySelector('.contract-checkbox')
164+
if (checkbox) {
165+
checkbox.checked = !checkbox.checked
166+
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
167+
}
168+
})
169+
170+
// Download button
171+
const downloadBtn = document.getElementById('contracts-download')
172+
if (downloadBtn) {
173+
downloadBtn.addEventListener('click', () => downloadContracts(contracts))
174+
}
175+
176+
// Select all
177+
const selectAllBtn = document.getElementById('contracts-select-all')
178+
if (selectAllBtn) {
179+
selectAllBtn.addEventListener('click', () => {
180+
setSelectedContracts(contracts.map((c) => c.id))
181+
initContractsPage(contracts)
182+
})
183+
}
184+
185+
// Deselect all
186+
const deselectAllBtn = document.getElementById('contracts-deselect-all')
187+
if (deselectAllBtn) {
188+
deselectAllBtn.addEventListener('click', () => {
189+
setSelectedContracts([])
190+
initContractsPage(contracts)
191+
})
192+
}
193+
194+
updateUI(contracts)
195+
}
196+
197+
function updateUI() {
198+
const selected = getSelectedContracts()
199+
const countEl = document.getElementById('contracts-count')
200+
if (countEl) countEl.textContent = selected.length
201+
202+
const downloadBtn = document.getElementById('contracts-download')
203+
if (downloadBtn) downloadBtn.disabled = selected.length === 0
204+
205+
// Update card styles
206+
document.querySelectorAll('.contract-card').forEach((card) => {
207+
const id = card.dataset.contractId
208+
const isSelected = selected.includes(id)
209+
card.classList.toggle('border-blue-500', isSelected)
210+
card.classList.toggle('bg-blue-50/50', isSelected)
211+
card.classList.toggle('dark:bg-blue-900/10', isSelected)
212+
card.classList.toggle('shadow-sm', isSelected)
213+
card.classList.toggle('border-[var(--color-border)]', !isSelected)
214+
})
215+
}
216+
217+
function downloadContracts(contracts) {
218+
const selected = getSelectedContracts()
219+
const lang = i18n.currentLanguage || 'en'
220+
const filtered = contracts.filter((c) => selected.includes(c.id))
221+
222+
if (filtered.length === 0) return
223+
224+
let md = '# Semantic Contracts\n\n'
225+
md +=
226+
lang === 'de'
227+
? 'Füge dies zu deiner AGENTS.md oder CLAUDE.md hinzu.\n\n'
228+
: 'Add this to your AGENTS.md or CLAUDE.md.\n\n'
229+
230+
for (const c of filtered) {
231+
const title = getLocalizedField(c, 'title')
232+
const template = getLocalizedField(c, 'template')
233+
md += `## ${title}\n\n${template}\n\n`
234+
}
235+
236+
md += '---\n'
237+
md +=
238+
lang === 'de'
239+
? 'Generiert von https://llm-coding.github.io/Semantic-Anchors/#/contracts\n'
240+
: 'Generated from https://llm-coding.github.io/Semantic-Anchors/#/contracts\n'
241+
242+
const blob = new Blob([md], { type: 'text/markdown' })
243+
const url = URL.createObjectURL(blob)
244+
const a = document.createElement('a')
245+
a.href = url
246+
a.download = 'semantic-contracts.md'
247+
a.click()
248+
URL.revokeObjectURL(url)
249+
}

website/src/components/header.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export function renderHeader() {
4040
<a href="#/changelog" class="nav-link text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors" data-route="/changelog" data-i18n="nav.changelog">${i18n.t('nav.changelog')}</a>
4141
<a href="#/agentskill" class="nav-link text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors" data-route="/agentskill" data-i18n="nav.agentskill">${i18n.t('nav.agentskill')}</a>
4242
<a href="#/workflow" class="nav-link text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors" data-route="/workflow" data-i18n="nav.workflow">${i18n.t('nav.workflow')}</a>
43+
<a href="#/contracts" class="nav-link text-[var(--color-text-secondary)] hover:text-[var(--color-text)] transition-colors" data-route="/contracts" data-i18n="nav.contracts">${i18n.t('nav.contracts')}</a>
4344
</div>
4445
<div class="flex items-center gap-3">
4546
<button
@@ -146,6 +147,7 @@ export function renderHeader() {
146147
<a href="#/changelog" class="nav-link mobile-nav-link px-3 py-2 rounded-md text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] transition-colors" data-route="/changelog" data-i18n="nav.changelog">${i18n.t('nav.changelog')}</a>
147148
<a href="#/agentskill" class="nav-link mobile-nav-link px-3 py-2 rounded-md text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] transition-colors" data-route="/agentskill" data-i18n="nav.agentskill">${i18n.t('nav.agentskill')}</a>
148149
<a href="#/workflow" class="nav-link mobile-nav-link px-3 py-2 rounded-md text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] transition-colors" data-route="/workflow" data-i18n="nav.workflow">${i18n.t('nav.workflow')}</a>
150+
<a href="#/contracts" class="nav-link mobile-nav-link px-3 py-2 rounded-md text-[var(--color-text-secondary)] hover:text-[var(--color-text)] hover:bg-[var(--color-bg-secondary)] transition-colors" data-route="/contracts" data-i18n="nav.contracts">${i18n.t('nav.contracts')}</a>
149151
</div>
150152
</div>
151153
</nav>

0 commit comments

Comments
 (0)