diff --git a/.changeset/meeting-card-place-headline.md b/.changeset/meeting-card-place-headline.md
new file mode 100644
index 0000000..64541b2
--- /dev/null
+++ b/.changeset/meeting-card-place-headline.md
@@ -0,0 +1,14 @@
+---
+'@edoxen/browser': minor
+---
+
+Expose Meeting.type as an i18n'd badge + filterable Type facet.
+
+- `MeetingListItem` carries `type` (MeetingType enum) and `MeetingListFacets` exposes a `types[]` list. `/data/meetings.json` now includes the type per item.
+- `MeetingCard` renders a type badge in the meta row (small caps muted label), resolved through the new `meetingTypeLabel()` helper.
+- `MeetingDetail` badge row uses the helper instead of the raw enum value.
+- The search island adds a Type facet (between Year and Location, meetings mode) — chip labels are humanized enum values; selection round-trips via `#types=plenary,working_group` in the URL hash. The facet text is also part of the search haystack so typing "plenary" filters meetings.
+- Card headline is composed from place (flag + "City, Country" or "🌐 Virtual"), with the entity title carried as the link's `title` attribute tooltip. Detail h1, JSON-LD, sitemaps, and `
` continue to use the entity title. Falls back to the title when no place data is present.
+- New config knob: `terminology.meetingTypes` — per-locale overrides (`{ eng: { plenary: 'Plénière CIML' } }`). Resolution order: consumer override → built-in i18n table (`meeting.type.`) → humanized enum value. The built-in table ships English + French for all 17 MeetingType values; other locales fall back to English until they're translated.
+
+`@edoxen/edoxen` (separate package) gains a symmetric `meetingTypeLabel(type)` helper + `MEETING_TYPE_LABELS` constant, mirroring the existing `actionTypeLabel` pattern.
diff --git a/packages/browser/README.md b/packages/browser/README.md
index 9682ce0..a400b20 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -284,6 +284,82 @@ When `nav` is not configured, the default nav (Meetings / Decisions /
About) is derived from `terminology` + `decisionsSlug` — the example
above yields Meetings / Resolutions / About with a `/resolutions` link.
+#### `terminology.meetingTypes` — translating meeting type labels
+
+Every meeting carries a `type` from the
+[`MeetingType` enum](https://github.com/edoxen/edoxen-model/blob/main/models/meeting_type.lutaml)
+(`plenary`, `working_group`, `task_group`, …, 17 values total). The
+browser renders this as a small-caps badge on meeting cards + the detail
+page header, and as a `Type` facet chip in the search island.
+
+Built-in labels cover **English + French**. Other locales (中文, Español,
+العربية, Русский) fall back to English until you translate them.
+
+Translate or override per-locale via `terminology.meetingTypes`:
+
+```ts
+terminology: {
+ // …decision / decisions / meeting / meetings as above…
+ meetingTypes: {
+ eng: {
+ plenary: 'Plenary', // override an English label
+ working_group: 'Working Group', // committee-specific phrasing
+ },
+ fra: {
+ plenary: 'Plénière CIML', // French committee style
+ working_group: 'Groupe de travail CIML',
+ },
+ zho: { // Simplified Chinese (简体中文)
+ plenary: '全体会议',
+ working_group: '工作组',
+ subcommittee: '分技术委员会',
+ task_group: '任务组',
+ ad_hoc: '特设组',
+ joint: '联席会议',
+ other: '其他',
+ // …translate as many of the 17 values as your committee uses
+ },
+ 'zho-Hant': { // Traditional Chinese (繁體中文)
+ plenary: '全體會議',
+ working_group: '工作組',
+ subcommittee: '分技術委員會',
+ task_group: '任務組',
+ ad_hoc: '特設組',
+ joint: '聯席會議',
+ other: '其他',
+ },
+ },
+},
+```
+
+Resolution order for each type value:
+
+1. **`terminology.meetingTypes[locale][type]`** — your per-locale override
+2. **Built-in `meeting.type.` string** — English + French ship
+ out of the box; partial coverage for other locales
+3. **Humanized enum value** — `'working_group'` → `'Working Group'`,
+ never throws on data the gem hasn't categorized yet
+
+So a committee whose meetings are commonly styled "Plenary Session" can
+override just that one value and inherit the rest. The
+[`src/i18n/meeting-types.spec.ts`](packages/browser/src/i18n/meeting-types.spec.ts)
+drift guardrail fails the build if the gem adds a `MeetingType` value
+that neither your override nor the built-in table covers.
+
+> **Script variants note (e.g. 简体中文 vs 繁體中文)**: the built-in UI
+> locale table uses ISO 639-3 codes and `normalizeUiLocale` currently
+> truncates any code longer than 3 chars to its first 3 — so `zho-Hant`
+> resolves into the same `zho` bucket as Simplified. The translation
+> blocks above illustrate the labels you'd supply; serving both
+> simultaneously as distinct routed locales needs a small change to
+> `normalizeUiLocale` (preserve the script subtag) plus a `UiLocale`
+> type widening. File a feature request if you need both served as
+> separate `/zho-Hans/…` and `/zho-Hant/…` routes.
+
+The same data drives the `Type` facet in the search island (chip labels
+humanize on the client; pass localized labels to the island via
+`data-*` attributes if you need them in non-English UIs).
+
## Theming
The default look is **elegant professional warm**: a warm paper canvas
diff --git a/packages/browser/docs/i18n-keys.yaml b/packages/browser/docs/i18n-keys.yaml
index aa0813e..9108abc 100644
--- a/packages/browser/docs/i18n-keys.yaml
+++ b/packages/browser/docs/i18n-keys.yaml
@@ -90,6 +90,32 @@
'decisions.empty': No decisions. # Shown when the decisions list is empty
'meetings.empty': No meetings. # Shown when the meetings list is empty
+# ── Meetings — virtual + type labels ────────────────────────────────
+# `meeting.virtual` is the "Virtual" badge shown on cards/detail for
+# meetings with no physical venue. The `meeting.type.*` keys map each
+# MeetingType enum value (plenary, working_group, …, 17 total) to a
+# display label — see README §Terminology › meetingTypes for the
+# per-locale override API. Built-in English + French values ship; the
+# humanize fallback covers anything missing.
+'meeting.virtual': Virtual # Online / no-place meeting
+'meeting.type.plenary': Plenary
+'meeting.type.working_group': Working Group
+'meeting.type.task_group': Task Group
+'meeting.type.ad_hoc': Ad Hoc
+'meeting.type.joint': Joint
+'meeting.type.general_assembly': General Assembly
+'meeting.type.committee': Committee
+'meeting.type.subcommittee': Subcommittee
+'meeting.type.conference': Conference
+'meeting.type.workshop': Workshop
+'meeting.type.seminar': Seminar
+'meeting.type.webinar': Webinar
+'meeting.type.hearing': Hearing
+'meeting.type.markup': Markup
+'meeting.type.board_meeting': Board Meeting
+'meeting.type.annual_general_meeting': Annual General Meeting
+'meeting.type.other': Other
+
# ── Decade timeline ─────────────────────────────────────────────────
'decade.browseLabel': Browse by decade # Accessible label for the decade nav
diff --git a/packages/browser/e2e/smoke.spec.ts b/packages/browser/e2e/smoke.spec.ts
index 4379b36..85786f2 100644
--- a/packages/browser/e2e/smoke.spec.ts
+++ b/packages/browser/e2e/smoke.spec.ts
@@ -210,6 +210,7 @@ test.describe('fixture site — home, lists and data endpoints', () => {
await page.goto('/meetings')
await expect(page.locator('section#decade-2020')).toBeVisible()
const card = page.locator('.edoxen-meeting-card', { has: page.locator('a[href="/meetings/urn:test:meeting:2025"]') })
+ await expect(card.locator('.edoxen-meeting-card__type')).toHaveText('Plenary')
await expect(card.locator('.edoxen-meeting-card__committee')).toHaveText('sc-1')
await expect(card.locator('.edoxen-meeting-card__count')).toContainText('1 Resolutions')
await expect(page.locator('.edoxen-decade-timeline__link').first()).toHaveAttribute('href', '#decade-2020')
@@ -280,6 +281,9 @@ test.describe('fixture site — home, lists and data endpoints', () => {
// Facets are grouped under labels — years never mix with locations.
await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Year' })).toBeVisible()
await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Location' })).toBeVisible()
+ await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Type' })).toBeVisible()
+ // Meeting-type facet chips carry the humanized enum value.
+ await expect(island.locator('.edoxen-search-filter__facet--meeting-type', { hasText: 'Plenary' })).toContainText('(3)')
// Country chips carry flag + localized country name; Virtual is last.
await expect(island.locator('.edoxen-search-filter__facet--country', { hasText: '🇩🇪 Germany' })).toContainText('(1)')
await expect(island.locator('.edoxen-search-filter__facet--country', { hasText: '🇨🇭 Switzerland' })).toContainText('(1)')
@@ -295,10 +299,11 @@ test.describe('fixture site — home, lists and data endpoints', () => {
const input = island.locator('input[type="search"]')
await input.fill('sc-1')
await expect(results).toHaveCount(1)
- await expect(results.first()).toContainText('2025 Refs Plenary')
+ // Headline is composed from place — full entity title rides along as a tooltip.
+ await expect(results.first().locator('a.edoxen-search-filter__result-title'))
+ .toHaveAttribute('title', '2025 Refs Plenary')
await expect(results.first().locator('a.edoxen-search-filter__result-title'))
.toHaveAttribute('href', '/meetings/urn:test:meeting:2025')
- // UN/LOCODE resolved to a place name on the result card.
await expect(results.first()).toContainText('Berlin, Germany')
await expect(island).toHaveAttribute('data-filtering', 'true')
await expect(page.locator('section#decade-2020')).toBeHidden()
@@ -310,7 +315,8 @@ test.describe('fixture site — home, lists and data endpoints', () => {
const chChip = island.locator('.edoxen-search-filter__facet--country', { hasText: 'Switzerland' })
await chChip.click()
await expect(results).toHaveCount(1)
- await expect(results.first()).toContainText('2026 Register Refs Plenary')
+ await expect(results.first().locator('a.edoxen-search-filter__result-title'))
+ .toHaveAttribute('title', '2026 Register Refs Plenary')
await expect(results.first()).toContainText('Geneva, Switzerland')
await expect(page).toHaveURL(/#.*countries=CH/)
await chChip.click()
@@ -319,7 +325,8 @@ test.describe('fixture site — home, lists and data endpoints', () => {
// The Virtual chip matches the online meeting (no city/country).
await virtualChip.click()
await expect(results).toHaveCount(1)
- await expect(results.first()).toContainText('2027 Virtual Plenary')
+ await expect(results.first().locator('a.edoxen-search-filter__result-title'))
+ .toHaveAttribute('title', '2027 Virtual Plenary')
await expect(results.first()).toContainText('🌐 Virtual')
await expect(page).toHaveURL(/#.*countries=virtual/)
await virtualChip.click()
@@ -329,8 +336,16 @@ test.describe('fixture site — home, lists and data endpoints', () => {
await expect(y2026).toContainText('(1)')
await y2026.click()
await expect(results).toHaveCount(1)
- await expect(results.first()).toContainText('2026 Register Refs Plenary')
+ await expect(results.first()).toContainText('Geneva, Switzerland')
await y2026.click()
+
+ // Meeting-type facet narrows by enum value and round-trips via hash.
+ // All three fixture meetings are type=plenary, so the chip matches all 3.
+ const plenaryChip = island.locator('.edoxen-search-filter__facet--meeting-type', { hasText: 'Plenary' })
+ await plenaryChip.click()
+ await expect(results).toHaveCount(3)
+ await expect(page).toHaveURL(/#.*types=plenary/)
+ await plenaryChip.click()
})
test('JSON data endpoints are served from the built site', async ({ request }) => {
@@ -338,7 +353,6 @@ test.describe('fixture site — home, lists and data endpoints', () => {
expect(decisions.ok()).toBeTruthy()
const decisionsBody = (await decisions.json()) as {
items: Array>
- facetActions: string[]
}
expect(decisionsBody.items.length).toBeGreaterThan(0)
const first = decisionsBody.items.find((i) => i['urn'] === 'urn:test:resolution:1')
@@ -346,17 +360,20 @@ test.describe('fixture site — home, lists and data endpoints', () => {
expect(first?.['date']).toBe('2024-06-15')
expect(first?.['snippet']).toContain('Publishes the test standard')
expect(first?.['meetingUrn']).toBe('urn:test:meeting:2025')
- expect(decisionsBody.facetActions).toContain('publishes')
const meetings = await request.get('/data/meetings.json')
expect(meetings.ok()).toBeTruthy()
- const meetingsBody = (await meetings.json()) as { items: Array> }
+ const meetingsBody = (await meetings.json()) as {
+ items: Array>
+ }
const m2025 = meetingsBody.items.find((i) => i['urn'] === 'urn:test:meeting:2025')
- // The island searches the flattened title + committee code + city.
+ // The island searches the flattened title + committee code + city + type
+ // and computes all facet counts client-side from items[].
expect(typeof m2025?.['title']).toBe('string')
expect(m2025?.['committeeCode']).toBe('sc-1')
expect(m2025?.['city']).toBe('DEBER')
expect(m2025?.['countryCode']).toBe('DE')
+ expect(m2025?.['type']).toBe('plenary')
const registers = await request.get('/data/registers.json')
expect(registers.ok()).toBeTruthy()
@@ -467,5 +484,11 @@ test.describe('fixture site — home, lists and data endpoints', () => {
await expect(footer.locator('.edoxen-footer__links a', { hasText: 'Committee home' })).toHaveAttribute('href', 'https://example.org/committee')
// Bottom bar
await expect(footer.locator('.edoxen-footer__copy')).toContainText(`© ${new Date().getFullYear()} TEST/TC 1`)
+ // Edoxen attribution carries the brand mark (both theme variants inlined,
+ // CSS toggles visibility) and links to edoxen.org.
+ const edoxenLink = footer.locator('a.edoxen-footer__edoxen-link')
+ await expect(edoxenLink).toHaveAttribute('href', 'https://www.edoxen.org')
+ await expect(edoxenLink.locator('.edoxen-brand-mark__svg--light svg')).toBeVisible()
+ await expect(edoxenLink.locator('.edoxen-brand-mark__svg--dark svg')).toBeHidden()
})
})
diff --git a/packages/browser/src/astro/assets/edoxen-logo-dark.svg b/packages/browser/src/astro/assets/edoxen-logo-dark.svg
new file mode 100644
index 0000000..f0def02
--- /dev/null
+++ b/packages/browser/src/astro/assets/edoxen-logo-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/browser/src/astro/assets/edoxen-logo-light.svg b/packages/browser/src/astro/assets/edoxen-logo-light.svg
new file mode 100644
index 0000000..3c3c283
--- /dev/null
+++ b/packages/browser/src/astro/assets/edoxen-logo-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/browser/src/astro/components/EdoxenBrandMark.astro b/packages/browser/src/astro/components/EdoxenBrandMark.astro
new file mode 100644
index 0000000..a62f220
--- /dev/null
+++ b/packages/browser/src/astro/components/EdoxenBrandMark.astro
@@ -0,0 +1,54 @@
+---
+// The Edoxen brand mark — small wordmark used in the footer attribution.
+// Two variants are inlined and toggled by `data-theme` on : the
+// light variant (dark ink, for light backgrounds) shows in light mode;
+// the dark variant (light ink, for dark backgrounds) shows in dark mode.
+// Both inline so the mark works on subpath hosts without asset-path
+// juggling and survives consumer basePath changes.
+import lightSvg from '../assets/edoxen-logo-light.svg?raw'
+import darkSvg from '../assets/edoxen-logo-dark.svg?raw'
+
+interface Props {
+ /** Optional alt text for screen readers; defaults to "Edoxen". */
+ alt?: string
+}
+const { alt = 'Edoxen' } = Astro.props
+---
+
+
+
+
+
+
+
diff --git a/packages/browser/src/astro/components/MeetingCard.astro b/packages/browser/src/astro/components/MeetingCard.astro
index 4903140..fa2d0d9 100644
--- a/packages/browser/src/astro/components/MeetingCard.astro
+++ b/packages/browser/src/astro/components/MeetingCard.astro
@@ -1,7 +1,7 @@
---
import type { MeetingListItem } from '../../data/index.js'
import { isVirtualMeeting } from '../../data/index.js'
-import { pickLocalizedValue, formatDateRange, regionName, t } from '../../i18n/index.js'
+import { pickLocalizedValue, formatDateRange, regionName, t, meetingTypeLabel } from '../../i18n/index.js'
import { urnToPath } from '../../urn.js'
import { countryFlag } from '@edoxen/edoxen'
import cfg from 'virtual:edoxen-config'
@@ -12,7 +12,7 @@ interface Props {
urlPrefix?: string
}
const { item: m, lang = 'en', urlPrefix: prefix = '/' } = Astro.props
-const title = pickLocalizedValue(m.title, lang, m.urn)
+const fullTitle = pickLocalizedValue(m.title, lang, m.urn)
const dateLabel = m.startDate ? formatDateRange(m.startDate, m.endDate, lang) : ''
const virtual = isVirtualMeeting(m)
const flag = !virtual && m.countryCode ? countryFlag(m.countryCode) : ''
@@ -20,6 +20,14 @@ const flag = !virtual && m.countryCode ? countryFlag(m.countryCode) : ''
// the country renders as a localized name, not the raw alpha-2 code.
const city = m.cityNames?.[lang] ?? m.cityNames?.[lang.slice(0, 2)] ?? m.cityNames?.en ?? m.city ?? ''
const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : ''
+// Browse views (index, home) compose the headline from place — matches the
+// original venue+year browse model and avoids restating dates/committee the
+// meta row already carries. Fall back to the entity title when no place.
+const placeLabel = virtual
+ ? t('meeting.virtual', lang, cfg.uiStrings, cfg.terminology)
+ : [city, country].filter(Boolean).join(', ')
+const headline = placeLabel || fullTitle
+const typeLabel = meetingTypeLabel(m.type, lang, cfg.terminology, cfg.uiStrings)
---
@@ -29,6 +37,9 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : ''
{dateLabel}
)}
+ {typeLabel && (
+ {typeLabel}
+ )}
{m.status && (
{m.status}
)}
@@ -41,18 +52,11 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : ''
)}
- {title}
- {virtual ? (
-
- 🌐
- {t('meeting.virtual', lang, cfg.uiStrings, cfg.terminology)}
-
- ) : (city || country) && (
-
- {flag && {flag} }
- {[city, country].filter(Boolean).join(', ')}
-
- )}
+
+ {virtual && 🌐 }
+ {!virtual && flag && {flag} }
+ {headline}
+
diff --git a/packages/browser/src/astro/components/MeetingDetail.astro b/packages/browser/src/astro/components/MeetingDetail.astro
index 81a6083..7cffe17 100644
--- a/packages/browser/src/astro/components/MeetingDetail.astro
+++ b/packages/browser/src/astro/components/MeetingDetail.astro
@@ -18,6 +18,7 @@ import {
formatDate,
formatDateRange,
humanizeVerb,
+ meetingTypeLabel,
regionName,
t,
urlPrefix,
@@ -121,7 +122,7 @@ function hostLabel(h: HostRef): string {